為什麼又那麼快生了一篇文章呢?原因是我在上一篇: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 成功時的畫面,載入成功後會直接顯示圖表
如果今天後端有丟錯誤,但前端沒有寫 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 的畫面
我還是會在打 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 教學時,查各種資料、做範例,消化吸收後再寫文章分享的那種感覺!
最後老話一句,有任何問題或是錯誤的地方,都請告訴我唷,歡迎指正,感謝 🙏
有考慮過依賴 SWR 或是使用 Router handler 嗎?
我 server action 處理資料變動,尤其是 form 居多
用在資料載入好像沒拿到好處?
我也覺得用在資料載入好像沒啥意義,不過因為我想要特意練習 Server actions,所以才拿這個功能來做練習 XD。
剛看了 SWR 跟 Router handler 的相關文件,感覺都很棒,可能下隻 api 資料就會拿其中一種方式來試試看,感謝分享!!