LY Corporation Tech Blog

支持 LY Corporation 和 LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam) 服務,宣傳技術和開發文化。

沒想過的前端錯誤處理 可能比你有做的還多 @ SITCON 2024

一切都得從一個實習申請作業說起…

去年年初我在找實習時,其中一家公司的實習申請作業有一個加分項目,是希望你能在專案中做錯誤處理。當時我就只有用 try...catch 和顯示錯誤訊息而已。而 Google 相關的關鍵字也找不到其他更好的作法。但在經過實習的學習與自己的研究後,對於「如何在前端做好錯誤處理」這個問題,我終於有了一個答案。

說到錯誤處理,大部分的人腦海中第一個想到的可能會是 try...catch,在前端的話頂多就是再加上錯誤訊息給使用者看,以及用 console.log() 輸出,方便自己之後需要修 bug 的時候用來 debug。

我們能做得更好嗎?

除了 try...catch 和顯示錯誤訊息外,我們還有其他能做的錯誤處理嗎?

其實是有的,我認為有做好另外處理的程式應該滿足以下兩個條件:

  1. 對使用者來說,應該具備容錯能力
  2. 對開發者來說,應該要有整合錯誤監控系統記錄錯誤

什麼是容錯能力(Fault tolerance)?

根據維基百科,容錯能力的定義如下:

"Fault tolerance is the property that enables a system to continue operating properly in the event of the failure of some (one or more faults within) of its components."

(容錯能力是指,一個系統要擁有當它的元件發生意外錯誤時,還能正常運作的特性。)

假設有一家餐廳支援三種付款方式,現金、信用卡和 LINE Pay。如果刷卡機突然壞掉的話,顧客還是能用現金和 LINE Pay 付款。那我們就可以說,這個餐廳的收款系統是有容錯能力的。因為即使刷卡機壞掉,收款系統依然能正常運作,客人還是能付錢給餐廳。

「容錯能力」通常會被忽視。我們可能會花時間寫很多測試,確保網頁「大概」不會壞,但我們卻不會花時間去想,當有意外錯誤發生時應該怎麼處理。而通常一個程式的高可用性(high availability)是優先考慮的,因此花時間思考怎麼處理意外錯誤也很重要。

什麼是錯誤監控系統?

錯誤監控系統——顧名思義,就是可以幫我們監控網站錯誤的系統。 當使用者瀏覽我們的網站,使用到一半發生錯誤時,監控系統就會幫我們將錯誤以及相關的偵錯資訊,記錄到它的資料庫裡面。方便我們追蹤和整理網站發生過的所有錯誤,我們也能藉由這些偵錯資訊提升我們修復 bug 的效率。

其實這種系統存在很久,並不是什麼很新的東西。他們已經發展成熟,並被各大公司採用。而且他們有的不只能在前端使用,在 APP、後端甚至是遊戲開發也能使用。大家可以依照自己專案的需求選擇。

錯誤監控系統入門

我們在監控系統上最常用基本功能有以下三個:

  1. 錯誤資訊頁面
  2. 錯誤搜尋
  3. 即時錯誤通知

接下來我會以 Sentry 為例子,向大家介紹這些功能,以及他們可以帶來什麼好處。

Sentry 是目前在前端中主流的錯誤監控服務之一,他們有將系統架在自己公司的伺服器上,大家可以直接免費使用。另外他們也有將系統的程式碼開源出來,你也可以選擇自己部署到其他伺服器上來使用。

錯誤資訊頁面

點進任意一個錯誤記錄後,在最上面可以看到錯誤的名稱跟錯誤訊息,再來可以看到這個錯誤發生的時間。接著是些些錯誤相關的基本資訊,像是使用者 IP、瀏覽器和作業系統的名稱和版本,如果是行動裝置的話,還會顯示行動裝置的型號。除此之外,下面還有其他的偵錯資訊可以參考。如果你有設定 Sentry 記錄自訂的除錯資訊的話,也會顯示在這裡。

再往下看可以看到 Stack trace,讓你去知道發生錯誤的地方,以及發生錯誤時函式呼叫的順序。預設是由新到舊,由上往下排序,你可以點右上角的按鈕將順序改為由新到舊。但這部份會顯示專案經過 bundler(像是 Webpack)處理過後的程式碼,可能會讓人看不太出來對應到原始碼實際上是哪裡發生問題。

如果你想看原始碼的話,可以將 source map 上傳到 Sentry,這樣這個區塊顯示的就會是原始碼了。

再接著會看到一個叫 Breadcrumbs 的區塊,顯示了在發生錯誤前使用者做了哪些操作,像是打了哪些 API、跟哪個 DOM 元素互動或是訪問那個頁面等等。讓你可以去做一樣的操作來復現使用者遇到的錯誤。預設的順序一樣是由新到舊,如果你看不習慣的話可以按右上角的按鈕改成由舊到新。

最後的區塊就是顯示最上面錯誤資訊的詳細資料,如果你需要進一步的參考資料的話可以到這個區塊來看。

在錯誤記錄頁面的右邊會顯示這個錯誤的相關統計數據,像是最近一次發生的時間和發生的頻率,還有都是哪些條件會觸發這個錯誤,這些條件的佔比是多少,以及發生最多的條件是哪個?

錯誤資訊頁面可以帶給我們什麼好處?

第一個是可以提昇我們修復 bug 的效率。當今天我們遇到一個 bug,但看不出來發生的原因時,監控系統幫我們記錄的這些偵錯資訊可以讓我們快速找到 bug 發生的原因。甚至當遇到只有特定環境才會發生的錯誤時,這些資訊可以讓我們有機會去還原使用者環境,並按照使用著的操作去覆現錯誤。

我們團隊的測試人員就曾經有在內部的測試環境測出一個只有在 Android 手機上才會有的 bug。而當時我們只靠錯誤監控系統上記錄的資訊就將 bug 修復了,沒有再去跑流程借 Android 的測試機來做錯誤的復現和測試。可以說如果有整合錯誤監控系統的話真的很方便,能大大提昇錯誤修復的效率。

第二個是可以幫助我們判斷一個錯誤需要修復的優先度。上一頁提到的相關數據統計可以讓你對某個錯誤的處理做一些評估。想是如果這個錯誤不常發生,或是會發生的環境在所有使用者中佔比很低的話,可能就能可以考慮不用那麼急著處理,讓工程師先處理其他影響比較大的錯誤,或是開發其他比較重要的功能。

在 Sentry 的錯誤紀錄列表,我們可以根據一些條件過濾出想找的錯誤,例如發生的專案、環境和時間。在搜尋欄這邊我們可以設定其他條件,像是特定作業系統,或是輸入錯誤名稱的關鍵字來快速找出我們想看的錯誤。

即時錯誤通知

這是我自己用 WebHook 串 Discord 的範例,大家可以照這個連結操作,大概一分鐘就弄好了。還蠻簡單的。

目前 Sentry 有提供官方整合的有 Discord、Slack 和 Microsoft Teams。一樣是付錢的方案才能使用。

那如果你不想付錢,或是想整合的通訊軟體沒有提供官方整合的話,也可以透過 Sentry 提供的 WebHook 做串接。前面給大家看的範例就是我自己串 WebHook 做出來的。

那在用 WebHook 之前記得要先到 Alert 的頁面設定,當符合通知條件時發生時要觸發 WebHook,像圖片中的我是設定有新問題時觸發 WebHook。這樣整合的通訊軟體才會收到錯誤通知。

錯誤即時通知可以帶給我們什麼好處

第一個是可以串接我們常見的通訊軟體,像是 Slack 和 Discord 等,當發生錯誤時可以即時通知開發團隊,讓開發者可以第一時間馬上做處理。同時幫我們省去開發錯誤通知系統的時間。

第二個是讓我們可以在使用者或客戶回報問題前就能主動進行修復。想想看,如果使用者在尚未主動通知前,開發者就已經發現問題,並著手處理甚至修復,那這樣不是很好嗎?由於錯誤系統會自動記錄網站發生的錯誤並即時通知,我們可以清楚的知道有哪些錯誤需要處理。而且在實務上我們不只會在上線的環境會整合錯誤監控系統,在內部的測試環境也會做整合。這樣如果在測試環境出問題的話,我們就能事先修復,降低使用者遇到錯誤的機率。

這樣聽起來,錯誤監控系統好像很厲害,這樣我們還需要 try...catch 和顯示錯誤訊息嗎?

答案是....要的。因為將錯誤記錄送到錯誤監控系統這部分,我們可能是要自己寫程式去做的,所以還是需要 try...catch 或是前端框架的錯誤捕獲機制,像是 <errorboundary> 等,當錯誤發生時捕獲錯誤,並執行程式將錯誤記錄倒錯誤監控系統。</errorboundary>

另外我們也是需要顯示錯誤訊息給使用者看,這對使用者體驗很重要。因為當錯誤發生時,我們必須要告訴使用者現在發生了什麼事,以及他可以採取什麼行動。

想想看,如果你今天用一個網站,如果跑了很久還在載入動畫或是頁面一片空白,然後你按 F12 卻看到一堆錯誤訊息的話,一定會很傻眼,對吧?

前端能做的錯誤處理有哪些?

這邊我們先總結一下前端能做的錯誤處理到底有哪些。

第一個是我們會需要 try...catch 等錯誤捕獲機制,來幫助我們當錯誤發生時做錯誤處理,不管是顯示錯誤訊息給使用者,還是將錯誤紀錄送到錯誤監控系統都會需要。

而發生錯誤後的錯誤處理我們可以分成兩個面向,一個是對使用者,另一個是對開發者。

對於使用者,為了良好的使用者體驗,我們需要顯示錯誤訊息,告訴他們現在發生了什麼事,以及可以採取什麼行動。

對於開發者,也就是我們自己,可以將錯誤資訊送到錯誤監控系統紀錄起來,方便我們未來進行修復,或是在使用者回報前先一步進行處理。

前端的錯誤捕獲機制

我們先來看最基本的錯誤捕獲機制。這邊可以拆成兩個部分,一個是原生 JavaScript 和瀏覽器內建的機制,另一個部分是前端框架的機制。

原生的錯誤捕獲機制

那原生 JavaScript 和瀏覽器提供的錯誤捕獲機制有以下三種:

  1. try...catch
  2. Promise.prototype.catch()
  3. window.onerror

前面兩個大家應該都蠻熟的,這邊就介紹最後一個就好。

我們有時後寫程式可能會有一些疏忽,該做錯誤處理的地方沒有用到 try...catch Promise.catch(),就會有 Uncaught Error 產生。所以我們會需要一個全域的錯誤捕獲機制來幫助我們捕獲這些錯誤,這就是 window.onerror 的作用。

window.onerror = (event, source, lineNumber, colNumber, error) => {
// error handling...
}

window.onerror 可以傳 5 個參數。最後一個是錯誤物件,如果我們要送錯誤記錄的畫會需要用到。

前端框架的錯誤捕獲機制

在各個框架中,都有提供當渲染元件時,發生錯誤會觸發的 Hook。 我們可以在這些 Hook 中拿到錯誤物件,並做我們想做的錯誤處理,像是顯示錯誤訊息以及紀錄錯誤到監控系統。

  • React: componentDidCatch, getDerivedStateFromError
  • Vue: onErrorCaptured
  • Angular: handleError

但在以元件開發為主的前端框架中,在所有元件都要加上類似的錯誤處理邏輯似乎不太合理。我們有沒有辦法復用這些邏輯呢?

可以。我們可以實作一個叫 <ErrorBoundary /> <errorboundary> 的元件來幫助我們達成這件事情。</errorboundary>

舉例來說,如果今天 X 首頁中間的 timeline 元件沒有包 <ErrorBoundary /><errorboundary>。</errorboundary>

然後因為一些原因,使用者在取得貼文的資料時發生錯誤,渲染元件的程式碼就會被中斷,而 timeline 這個元件就不會被渲染到畫面上,那這個區塊就會變成空白的,網頁就會看起來像壞掉一樣。

為了避免這種情況發生,我們就需要用 <errorboundary> 這個元件把我們的元件包起來。當 Uncaught Error 發生時我們就可以去做錯誤處理,讓網站看起來不會像壞掉一樣。</errorboundary>

如何實作一個 <ErrorBoundary />

實作 <ErrorBoundary /> <errorboundary> 時,我們會需要定義兩個 prop,一個是 children,另一個是 fallback(顯示錯誤訊息的元件)。另外我們還會需要定義一個 state 幫助我們判斷有沒有發生過錯誤,這裡我叫他 hasError,預設是 false ,代表沒有發生過錯誤。</errorboundary>

當渲染 children 發生錯誤時,上一頁提到的那些 Hook 就會被觸發,這時我們要把 hasError 設成 true,並做我們想做的錯誤處理,例如送錯誤記錄到監控系統。然後當每次渲染元件時,我們就判斷 hasError 的狀態,如果是 true 就顯示錯誤訊息,也就是 fallback 這個 prop。那如果是 false 就顯示 children。

什麼元件需要包 <ErrorBoundary /><errorboundary>?</errorboundary>

要需要用 <ErrorBoundary /> <errorboundary> 包的元件主要有兩種:</errorboundary>

網頁程式的進入點

當壞掉時,不會影響其他在元件樹上同層的元件運作的元件

為什麼進入點需要包 <ErrorBoundary /><errorboundary>?</errorboundary>

當你旋染某個元件發生未知的錯誤時,你當前的頁面可能會整個都沒辦法正常運作,所以我們會需要做錯誤處理,像是顯示錯誤頁面之類的。所以我們會需要在網頁程式的進入點包 <errorboundary>。</errorboundary>

當壞掉時,不會影響其他在元件樹上同層的元件運作的元件

再來我們來看第二個部分。

「當壞掉時,不會影響其他在元件樹上同層的元件運作的元件。」

這句話是什麼意思?

我們一樣來看 X 的例子。可以看到 X 首頁的中間區塊可以分成三個元件。最上方的 Switch Tab、新推文的輸入框以及 Timeline。這三個元件都負責不同的功能,彼此沒有相依關係。

而當取得貼文資料發生錯誤,Timeline 壞掉時,Switch Tab 和輸入框應該還是要顯示在畫面上,並正常運作。

所以 Timeline 就是「當壞掉時,不會影響其他在元件樹上同層的元件運作的元件」,所以我們應該要用 <ErrorBoundary /> <errorboundary> 把它包起來。</errorboundary>

聽到這裡你可能會想,那為什麼不要乾脆直接把每個元件都用 <ErrorBoundary /> <errorboundary> 包起來就好。</errorboundary>

這乍聽之下是個很安全的做法,我們盡量最小化了每個元件發生錯誤時的影響,但實際上這樣做會有一些問題。

第一個是並不是所有元件都會需要包,像是 UI 類的元件,有些在渲染時一定不會發生錯誤,例如一個單純只是顯示使用者頭像的元件,這種的就不需要包 <ErrorBoundary /><errorboundary>。</errorboundary>

第二個是會影響效能。如果你每個元件都包 <errorboundary> ,元件樹的深度就和節點數量會變兩倍。雖然在一些小專案可能不會有感覺,但如果在大型專案的話可能就會影響到效能。</errorboundary>

最後是可能會造成不好的使用者體驗。

壞一半的 UI === 全壞爛的 UX

為什麼將所有元件都用 <ErrorBoundary /> <errorboundary> 包起來會對影響使用者體驗呢?我們來看下面這個例子。</errorboundary>

這是一組電商的結帳 UI,由三個元件組成:購物車資訊、付款資訊和結帳按鈕。然後三個元件都用 <ErrorBoundary /> 包起來。

當付款資訊發生非預期的錯誤時,使用者還是可以看到購物車的資訊,並按下按鈕嘗試結帳。那當他按下按鈕時會用什麼付款方式,是信用卡、轉帳還是貨到付款?那如果是信用卡的話,是用哪張信用卡付款?開發者自己可能都不會知道,更不用說是使用者了。

由此可見,當一組 UI 中只有壞掉的元件消失或顯示錯誤訊息,但其他部分還顯示在畫面上並且可以正常使用時,會讓使用者感到困惑,帶來不好的使用者體驗。

那我們該怎麼判斷呢?

我們可以遍歷整顆元件樹,並對每個元件問這個問題。

當這個元件壞掉時,其他同層的元件該跟著消失嗎?

讓我們以 X 為例子模擬一遍吧!

這個頁面大概可以切成幾個元件,像是左下方的使用者資訊,中間的 Timeline,跟右邊的 Trends for you 和其他的元件。基本上這幾個元件之中的任意一個壞掉,都不會影響其他元件使用。所以代表這幾個元件就算有的壞掉,其他的應該也要繼續顯示在頁面上,並且能被正常使用,而不是直接跳一個錯誤頁面出來。所以這些元件應該每個都要包 <ErrorBoundary />

我們在來看另一個例子。這是一個追蹤其他使用者的元件,這邊我們可以切成兩個元件,使用者資訊和追蹤按鈕。

那當使用者訊顯示失敗的話,我們應該要顯示追蹤按鈕嗎?很明顯不用對吧,因為這樣的話使用者跟本不知道按下去會追蹤誰。所以這兩個元件都不用包 <errorboundary> 。</errorboundary>

顯示錯誤訊息實做

當我們有 <ErrorBoundary /> <errorboundary> 這個元件之後,要實作錯誤訊息的顯示就很簡單了。</errorboundary>

以剛剛我們 <ErrorBoundary /> <errorboundary> 的實作邏輯來說,我們可以把顯示錯誤訊息的元件放到 fallback 這個 prop,這樣當這個元件發生 Uncaguht Error 就會顯示錯誤訊息了。</errorboundary>

<ErrorBoundary fallback={<ErrorMessage />}>
  <ComponentMightOccurError />
</ErrorBoundary>

將錯誤紀錄到監控系統

當我們能捕獲錯誤後,我們就可以將錯誤資料送到錯誤監控系統了。

那我們要怎麼把錯誤紀錄送到 Sentry 呢?在 Sentry 有一個 captureException() 的函式。我們只要把錯誤物件傳進這個函式呼叫,就可以把錯誤紀錄到 Sentry 了。

如果是用 try... catch 的話,我們就拿 catch 的錯誤物件,在 error 被 catch 的時候呼叫 Sentry.captureException() 就好。

try {
  // do something...
} catch (error) {
  Sentry.captureException(error);
}

那如果是在 <ErrorBoundary /> <errorboundary> 的話,就是在 <ErrorBoundary /> <errorboundary> 處理錯誤的元件生命週期 Hook 被觸發時呼叫 Sentry.captureException(),一樣要帶上錯誤物件。</errorboundary></errorboundary>

import * Sentry as "@sentry/react";

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
   Sentry.captureException(error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}

catch 的時機

看到這邊你可能會想,我們該不會所有的函式都需要用 try…catch 或 Promise.catch() 包起來送錯誤紀錄吧?

確實不需要,需要包的函式只有以下兩種:

  1. 開發團隊自己寫,且確定可能會拋出錯誤的 utility function
  2. 第三方 library 中,文件明確寫出可能會拋出錯誤的 function

如果有其他意外的錯誤的話也超過了我們已知的範圍,就交給 Sentry 自動紀錄,等發現了再看要怎麼處理就好。

自動記錄所有 API 請求的錯誤

對於 API 請求的部分,我們可以把 fetch() 和 Sentry.captureException() 重新封裝成一個函式。(fetch() 和 XMLHttpRequest)都是原生的 API

import * as Sentry from "@sentry/react";

export const request = (url, options) => {
  return fetch(url, options)
           .then((response) => response.json())
           .catch((error) => {
             Sentry.captureException(error);
             return Promise.reject(error);
           });
};

再利用這個函式寫出每個 HTTP Method 對應的 function,這樣我們就不用每次打 API 時都要加上送錯誤記錄的程式碼了。

export const get = (url) => request(url);

export const post = (url, options) => {
  return request(url, {
    ...options,
    method: "POST",
  });
}

// Other HTTP Methods...

如果我們是用 axios 的話更方便。我們可以建立一個 axios 的實體,並設定 interceptors,在請求和回應失敗的時候送錯誤記錄到 Sentry。這樣就不用每次打 API 時都要加上送錯誤記錄的程式碼了。

import * as Sentry from "@sentry/react";
import axios from "axios";

const handleError = (error) => {
  Sentry.captureException(error);
  throw error;
};

const instance = axios.create();

instance.interceptors.request.use(null, handleError);
instance.interceptors.response.use(null, handleError);

export default instance;

window.onerror() 呢?

如果我們今天用 Sentry 的話,就不需要另外寫 code 在 window.onerror 來將 Uncaught Error 紀錄起來。Sentry 會自動幫我們紀錄,除非你需要對 Uncaught Error 做其他的錯誤處理,不然你不會需要用到。

最後的一些小建議

不是所有專案都需要做錯誤監控,也不需要一次做到位

在我們導入一項技術前,要好好思考我們是否有使用這個技術的需要,不能單純只是為了用而用。像是如果有一個專案是不需要長期維護的話,或許就不需要整合錯誤監控系統。

而對於已經開發一段時間的專案,我們想要整合監控系統的話,也不需要一次做到位,我們可以一個個功能慢慢做上去就好。

注意不要將使用者的機密資訊傳到錯誤監控系統

當我們紀錄錯誤到監控系統時,要注意不要將使用者的機密資訊,像是密碼或是信用卡卡號等紀錄到監控系統。

一切都是 trade-off

以前面的例子來看,你可能覺得要做到那麼細的錯誤處理很麻煩。前面的方法只是一個參考,提供一個方式讓大家判斷什麼元件需要包 <errorboundary> 。但實務上需不需要做到那麼細可能還需要考慮其他因素,像是如果今天你開發的專案有時程壓力,那可能就先不用做那麽細的錯誤處理,之後有時間再回來調整就好。或是在團隊開發中,要做到那麼細的錯誤處理不只需要工程師的時間成本,也需要其他職位團隊成員的努力,像是設計師等等。那真的值得花那麼多時間和人力成本去做到那麼細的錯誤處理嗎?這也是需要思考以及和團隊成員討論的。</errorboundary>

SITCON 2024

這篇文章是從我在 SITCON 2024 同名議程中的內容整理出來的。很高興有機會能在 SITCON 完成人生中的第一場技術演講,希望這次分享對剛開始學習前端的朋友有幫助。

SITCON 2024 議程簡報連結

如果對於實習生生活有興趣,歡迎參考過去實習分享文章清單: