一切都得從一個實習申請作業說起…
去年年初我在找實習時,其中一家公司的實習申請作業有一個加分項目,是希望你能在專案中做錯誤處理。當時我就只有用 try...catch 和顯示錯誤訊息而已。而 Google 相關的關鍵字也找不到其他更好的作法。但在經過實習的學習與自己的研究後,對於「如何在前端做好錯誤處理」這個問題,我終於有了一個答案。
說到錯誤處理,大部分的人腦海中第一個想到的可能會是 try...catch,在前端的話頂多就是再加上錯誤訊息給使用者看,以及用 console.log() 輸出,方便自己之後需要修 bug 的時候用來 debug。
我們能做得更好嗎?
除了 try...catch 和顯示錯誤訊息外,我們還有其他能做的錯誤處理嗎?
其實是有的,我認為有做好另外處理的程式應該滿足以下兩個條件:
- 對使用者來說,應該具備容錯能力
- 對開發者來說,應該要有整合錯誤監控系統記錄錯誤
什麼是容錯能力(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、後端甚至是遊戲開發也能使用。大家可以依照自己專案的需求選擇。
錯誤監控系統入門
我們在監控系統上最常用基本功能有以下三個:
- 錯誤資訊頁面
- 錯誤搜尋
- 即時錯誤通知
接下來我會以 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 和瀏覽器提供的錯誤捕獲機制有以下三種:
- try...catch
- Promise.prototype.catch()
- 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 中只有壞掉的元件消失或顯示錯誤訊息,但其他部分還顯示在畫面上並且可以正常使用時,會讓使用者感到困惑,帶來不好的使用者體驗。
那我們該怎麼判斷呢?
我們可以遍歷整顆元件樹,並對每個元件問這個問題。