Copyright © 2008 ~ 2024 MUKI space* / omegaBook theme All Rights Reserved.

前言

為什麼又那麼快生了一篇文章呢?原因是我在上一篇:Next.js 串接 TradingView 開發的圖表套件,並透過 Server 端呼叫外部的富果 API,用 Server Components 處理外部 API 時,我以為用 throw new Error 就能在前端接收到錯誤,但透過網友指正後,我才意識到犯了個嚴重的錯:

我在 Server 端丟的錯誤,當然是由 Server 端捕獲啊 XD,我到底在想啥 😂,所以晚上花了一些時間研究該怎麼把 API 的錯誤丟到前端,讓前端有更好的體驗。

後端捕獲的錯誤從哪看

同上一篇,用 TSE.action.tsx 的程式碼來做範例,我這邊丟的 Error 為 HTTP error! status: ${response.status}

'use server'

const apiTSEIndexUrl = 'https://api.fugle.tw/marketdata/v1.0/stock/historical/candles/IX0001'

export async function fetchTSEIndex() {
  try {
    // headers & payload 的宣告暫時省略
    const response = await fetch(`${apiTSEIndexUrl}?${payload}`, {
      headers: headers,
    })
    if (!response.ok) {
      // 我丟出去的錯誤
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json()
    console.log('data', data)
    return data.data
  } catch (error) {
    // 會在後端捕獲到錯誤
    console.error('API 請求錯誤:', error)
  }
}

▼ 可以透過 VS Code 的 Terminal 視窗看到後端的 console,如果你想看後端任何相關訊息,都可以用 console 後,再到 Terminal 視窗查看

調整後端丟到前端的資料

▼ 我希望富果 API 不管成功或錯誤,都會丟資料給前端。所以我將 response.json() 整包傳給前端,並在 catch{} 丟出錯誤讓前端捕獲

'use server'

const apiTSEIndexUrl = 'https://api.fugle.tw/marketdata/v1.0/stock/historical/candles/IX0001'

export async function fetchTSEIndex() {
  try {
    // headers & payload 的宣告暫時省略
    const response = await fetch(`${apiTSEIndexUrl}?${payload}`, {
      headers: headers,
    })
    
    // 以下是修改後的語法
    const data = await response.json()
    
    // 因為 throw new Error 只能接字串,所以我改用 throw 丟整包資料
    if (!response.ok) { throw data }
    
    return { data }
  } catch (error) {
    console.error('API 請求錯誤:', error)
    
    // 因為 new Error 只能接受 string 格式的資料,所以我先用 JSON.stringify 轉型
    // 這個 throw 是丟到前端
    throw new Error(JSON.stringify({
      data: error
    }))
  }
}

處理前端收到的資料

我們來測試一下,當 API 有錯時,前端會收到什麼資料

▼ 用 try...catch 呼叫 API

const fetchTSEData = async () => {
    try {
      const response = await fetchTSEIndex()
      setTSEIndex(response.data.data)
    } catch (error: any) {
      
      // 用 JSON.parse 處理資料
      const errorData = JSON.parse(error.message)
      console.log('errorData', errorData)
    }
  }

▼ console 畫面如下,data 物件就是富果 API 傳遞過來的,也就是我在後端的 catch{} 寫的 throw new Error ....

至此,我們就能順利接到後端回傳的錯誤資訊,利於我們判斷 API 是否有誤。

但使用這個方法的奇妙之處,就是要用 JSON.stringify 以及 JSON.parse 處理錯誤,感覺有點神奇。雖然我們可以用 return {} 把物件傳到前端,但這樣就不是拋出一個錯誤,所以前端接收到時會進入 try {} 而不是 catch {}

所以... 歡迎大家提供更好的寫法給我 😂,到底要怎麼做,才能讓前端捕獲到的錯誤是物件格式呢 🤔️?

捕獲錯誤時的畫面處理

最後,我希望 API 發生錯誤時,在前端能有提示,並增加 loading 功能。

我翻了一下 Material UI,發現他並沒有像 Ant Design 那樣很直覺的 loading 元件,所以我使用 Fade 以及 CircularProgress 兩個元件來處理

▼ 因為怕程式碼太長而模糊焦點,我只放跟提示有關的程式碼,文末會再補上完整程式碼

'use client'

import React, { useEffect, useState, useTransition } from 'react'

// 使用這兩個 Material UI 元件
import Fade from '@mui/material/Fade'
import CircularProgress from '@mui/material/CircularProgress'

const TSE = () => {
  // 宣告 loading
  const [loading, setLoading] = useState(true)

  const fetchTSEData = async () => {
    // 設定 loading
    setLoading(true)
    try {
      const response = await fetchTSEIndex()
      setTSEIndex(response.data.data)
    } catch (error: any) {
      const errorData = JSON.parse(error.message)
      console.log('errorData', errorData)
    } finally {
      // 設定 loading
      setTimeout(() => {
        setLoading(false)
      }, 1000)
    }
  }
  
  useEffect(() => {
    // 把 fetchTSEData 拉出來變一個 function
    startTransition(fetchTSEData)
  }, [])
  
  useEffect(() => {
    const fetchChart = () // 省略
    
    if (tseIndex.length === 0) return
    // 加入 loading 判斷
    // 如果 loading 是 true 就 return ,避免 TSE 的 DOM 還未渲染,造成圖表無法出現
    if (loading) return
    fetchChart()
  }, [tseIndex, loading])
  
  return (
    <>
      {/*
        Fade 的 timeout 一定要設 0,不然會出現 DIV 交錯的畫面
        unmountOnExit 一定要設為 true,CSS 才會是 display: none,不然預設是 visibility: hidden;
      */}
      <Fade in={loading} timeout={0} unmountOnExit={true}>
        <div className="flex items-center justify-center bg-info h-96">
          <CircularProgress color='secondary' size={20} />
          <h2 className="text-white text-center ml-2">資料載入中...</h2>
        </div>
      </Fade>
      {/*
        Fade 的 timeout 一定要設 0,不然會出現 DIV 交錯的畫面
        unmountOnExit 一定要設為 true,CSS 才會是 display: none,不然預設是 visibility: hidden;
      */}
      <Fade in={!loading} timeout={0} unmountOnExit={true}>
        {(tseIndex.length === 0) ?
          {/* API 錯誤時的處理  */}
          <div className="flex flex-col items-center justify-center bg-info h-96">
            <h2 className="text-white text-center">OOPS!資料有誤,暫時無法顯示</h2>
            <button
              className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
              onClick={() => { fetchTSEData() }}
            >
              重新整理
            </button>
            <div className="text-secondary text-center text-xs mt-4">如果錯誤持續發生,請聯繫管理員</div>
          </div> :
          <div id="TSE" className="bg-info h-96"></div>
        }
      </Fade>
    </>
  )
}

▼ 拍了一個簡易影片給大家看效果,我故意把 API 的參數拿掉,所以不管怎麼重新整理都是錯的

影片分享了一開始會出現「載入中」的畫面,以及 API 錯誤時會跳出「資料有誤」的提示,並提供重新整理的功能。

▼ 接下來是呼叫 API 成功時的畫面,載入成功後會直接顯示圖表

延伸:Next.js 內建的 error 處理方式

如果今天後端有丟錯誤,但前端沒有寫 catch{} 捕獲的話,會發生什麼事呢?

▼ 將前端的 catch{} 語法移除

try {
  const response = await fetchTSEIndex()
  setTSEIndex(response.data.data)
} finally {
  setTimeout(() => {
    setLoading(false)
  }, 1000)
}

根據 Next.js 的文件表示,我們可以在路由增加一個 error.tsx 檔案,當前端沒有寫 catch{} 捕獲錯誤時,error.tsx 會協助我們處理這些意外

▼ 將 error.tsx 至於 app 底下,他也可以放在每個路由目錄中,可以把它想成是 error 專用的路由

// 語法來源:https://nextjs.org/docs/app/building-your-application/routing/error-handling

'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Optionally log the error to an error reporting service
    console.error('error tsx', error)
  }, [error])

  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={
          // Attempt to recover by trying to re-render the invoices route
          () => reset()
        }
      >
        Try again
      </button>
    </main>
  )
}

▼ 這時候如果 API 有錯,前端又沒有 catch{},就會出現 error.tsx 的畫面

Error Handling 的使用時機

我還是會在打 API 時用 catch{} 捕獲錯誤,因為使用 error.tsx 的特性是只要有錯誤發生,就會停止後面的渲染,直接丟 error 畫面給你,這樣一來,我首頁的「台股即時報價」功能也不會顯示。

所以 error.tsx 比較適合處理意外的錯誤,如果真的忘了處理某些錯誤時,丟出 error 畫面會讓使用者體驗較好,但如果是自己能掌控的錯誤,選擇用 catch{} 處理會更好

參考資料:

完整程式碼

最後分別放上 Server 端以及 Client 端的完整程式碼,你也可以到我的 github 看「股票小特助」的專案唷!

'use server'

const apiTSEIndexUrl = 'https://api.fugle.tw/marketdata/v1.0/stock/historical/candles/IX0001'

const formatDate = (date: Date) => {
  const year = date.getFullYear()
  const month = (date.getMonth() + 1).toString().padStart(2, '0')
  const day = date.getDate().toString().padStart(2, '0')
  return `${year}-${month}-${day}`
}

export async function fetchTSEIndex() {
  const today = new Date()
  const lastYear = new Date()
  lastYear.setFullYear(lastYear.getFullYear() - 1)
  lastYear.setDate(lastYear.getDate() + 1)

  try {
    const headers = {
      'X-API-KEY': { 你的金鑰 },
      'Content-Type': 'application/json'
    }
    const payload = new URLSearchParams({
      from: formatDate(lastYear),
      to: formatDate(today),
      fields: 'open,high,low,close,volume'
    })
    const response = await fetch(`${apiTSEIndexUrl}?${payload}`, {
      headers: headers,
    })
    const data = await response.json()
    if (!response.ok) { throw data }
    return { data }
  } catch (error: any) {
    console.error('API 請求錯誤:', error)
    throw new Error(JSON.stringify({
      data: error
    }))
  }
}
'use client'
import React, { useEffect, useState, useTransition } from 'react'
import Fade from '@mui/material/Fade'
import CircularProgress from '@mui/material/CircularProgress'
import { fetchTSEIndex } from '../api/TSE.action'
import { createChart } from 'lightweight-charts'

const TSE = () => {
  const [isPending, startTransition] = useTransition()
  const [loading, setLoading] = useState(true)
  const [tseIndex, setTSEIndex] = useState<any>([])

  const fetchTSEData = async () => {
    setLoading(true)
    try {
      const response = await fetchTSEIndex()
      setTSEIndex(response.data.data)
    } catch (error: any) {
      const errorData = JSON.parse(error.message)
    } finally {
      setTimeout(() => {
        setLoading(false)
      }, 1000)
    }
  }

  useEffect(() => {
    startTransition(fetchTSEData)
  }, [])

  useEffect(() => {
    const fetchChart = () => {
      const parentElement = document.getElementById('TSE') as HTMLElement
      const chartWidth = parentElement.clientWidth
      const chartHeight = parentElement.clientHeight

      const chart = createChart(parentElement, {
        width: chartWidth,
        height: chartHeight,
        layout: {
          background: {
            color: '#292E2F',
          },
          textColor: 'rgba(255, 255, 255, 0.6)',
        },
        grid: {
          vertLines: {
            color: '#3C4142',
          },
          horzLines: {
            color: '#3C4142',
          },
        }
      })
      const data = tseIndex.map((item: any) => {
        return {
          time: item.date,
          open: item.open,
          close: item.close,
          high: item.high,
          low: item.low,
          value: item.close,
        }
      })
      data.reverse()
      const candlestickSeries = chart.addCandlestickSeries({ upColor: '#EF5350', downColor: '#26A69A', borderVisible: false, wickUpColor: '#EF5350', wickDownColor: '#26A69A' })
      candlestickSeries.setData(data)
    }
    if (tseIndex.length === 0) return
    if (loading) return
    fetchChart()
  }, [tseIndex, loading])

  return (
    <>
      <Fade in={loading} timeout={0} unmountOnExit={true}>
        <div className="flex items-center justify-center bg-info h-96">
          <CircularProgress color='secondary' size={20} />
          <h2 className="text-white text-center ml-2">資料載入中...</h2>
        </div>
      </Fade>
      <Fade in={!loading} timeout={0} unmountOnExit={true}>
        {(tseIndex.length === 0) ?
          <div className="flex flex-col items-center justify-center bg-info h-96">
            <h2 className="text-white text-center">OOPS!資料有誤,暫時無法顯示</h2>
            <button
              className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
              onClick={() => { fetchTSEData() }}
            >
              重新整理
            </button>
            <div className="text-secondary text-center text-xs mt-4">如果錯誤持續發生,請聯繫管理員</div>
          </div> :
          <div id="TSE" className="bg-info h-96"></div>
        }
      </Fade>
    </>
  )
}

export default TSE

後記碎碎念

最近很常寫程式相關的文章,但直到寫完了這篇,我才有一種回到之前寫 CSS 教學時,查各種資料、做範例,消化吸收後再寫文章分享的那種感覺!

最後老話一句,有任何問題或是錯誤的地方,都請告訴我唷,歡迎指正,感謝 🙏

歡迎給我點鼓勵,讓我知道你來過 :)

5
Subscribe
Notify of
guest

2 則留言
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Nim
Nim
8 months ago

有考慮過依賴 SWR 或是使用 Router handler 嗎?

我 server action 處理資料變動,尤其是 form 居多

用在資料載入好像沒拿到好處?