為了提升應用穩定性,我們對前端項目開展了腳本異常治理的工作,對生產上報的js error進行了整體排查,試圖通過降低腳本異常的發生頻次來提升相關告警的準確率,結合最近在這方面閱讀的相關資料,嘗試階段性的做個總結,下麵我們來介紹下js異常處理的一些經驗。 ...
寫在前面
為了提升應用穩定性,我們對前端項目開展了腳本異常治理的工作,對生產上報的js error進行了整體排查,試圖通過降低腳本異常的發生頻次來提升相關告警的準確率,結合最近在這方面閱讀的相關資料,嘗試階段性的做個總結,下麵我們來介紹下js異常處理的一些經驗。
先說概念
什麼是異常
先來看一下官方的定義:
Error objects are thrown when runtime errors occur. The Error object can also be used as a base object for user-defined exceptions.
描述的很簡單,我們總結一下就是代碼在執行過程中遇到了問題,程式已經無法正常運行了,Error對象會被拋出,這一點它不同於大部分編程語言里使用的異常對象Exception,甚至更適合稱之為錯誤,應該說事實也確實如此,Error對象在未被拋出時候和js里其他的普通對象沒有任何差別是不會引發異常的,同時Error 對象也可用於用戶自定義錯誤的基礎對象。
看下麵兩個例子:
try { const 123variable = 2; } catch(e) { console.log('捕獲到了:', e) }
↓↓↓執行結果↓↓↓
結論:只有在執行過程中的異常可以被捕獲,語法解析階段的異常或者不在當前同步任務中的異常都無法被捕獲。
<script> function throwSomeError() { throw new Error('拋個異常玩玩'); console.log('我估計是涼了,不會執行我了!'); } throwSomeError(); console.log('那麼我呢?') </script> <script> console.log('大家猜猜我會執行嗎?'); </script>
↓↓↓執行結果↓↓↓
以上紅色信息里包含了異常信息(message)和棧跟蹤(stack trace)信息,對於定位代碼中的問題起到重要作用,可以看到棧跟蹤是從底部文件位置21:15到頂部25:7位置的;前兩個console在遇到異常時候未被執行,第二個script標簽內的代碼被正常執行。
結論:當任務執行過程中出現未處理的異常,會一直沿著調用棧一層層向外拋出(有點像事件冒泡),最終會導致當前任務被終止執行。當前任務終止後JS 線程會繼續從任務隊列中提取下一個任務繼續執行。
異常的類型
錯誤名 |
描述 |
示例 |
EvalError |
關於 eval [1]函數的錯誤,已不在當前ECMAScript規範中使用,不再會被運行時拋出。 |
throw new EvalError('EvalError', 'file.js', 10); // 可以由業務代碼主動拋出 |
RangeError |
值不在允許的範圍內,典型的是試圖傳遞一個數值給一個範圍內不包含該數值的函數,此時應該引發RangeError。 |
const numObj = 123; numObj.toFixed(-1); // Uncaught RangeError: toFixed() digits argument must be between 0 and 100 at Number.toFixed |
ReferenceError |
當一個不存在(或尚未初始化)的變數被引用時發生的錯誤。 |
const a = undefinedVariable; // Uncaught ReferenceError: undefinedVariable is not defined |
SyntaxError |
解析代碼階段,發現了不符合語法規範的代碼。 |
const 111variable = 1; // Uncaught SyntaxError: Invalid or unexpected token |
TypeError |
類型錯誤,用來表示值的類型是非預期類型。 |
const a = null; a.doSomeThing(); // Uncaught TypeError: Cannot read properties of null (reading 'doSomeThing') |
URIError |
使用URI處理函數產生的錯誤 |
decodeURIComponent('%') // Uncaught URIError: URI malformed |
1.以上這些異常很多都來會由Javascript引擎拋出,但異常類型都是實際的構造函數,旨在生成一個新的異常實例,所以你可以:
// 獲取分頁數據 const getPagedData = (pageIndex, pageSize) => { if(pageIndex < 0 || pageSize < 0 || pageSize > 1000) { throw new RangeError(`pageIndex 必須大於0, pageSize必須在0和1000之間`); } return []; } // 轉換時間格式 const dateFormat = (dateObj) => { if(dateObj instanceof Date) { return 'formated date string'; } throw new TypeError('傳入的日期類型錯誤'); }
2.Error實例被創建時不能被稱之為異常,只有在使用throw關鍵字將其拋出時才會引發異常;
new Error('出錯了!'); console.log('我吃嘛嘛香,喝嘛嘛棒!'); // 正常輸出 '我吃嘛嘛香,喝嘛嘛棒!'
3.技術上來講,你可以拋出任何類型的異常,而不僅僅是Error的實例,但請不要這麼做,總是拋出正確的錯誤對象會讓我們更容易定位問題,同時可以保持錯誤處理的一致性,捕獲異常時候也總能夠拿到Error實例上的message和stack;
// bad throw '出錯了'; throw 123; throw []; throw null;
異常捕獲
前面有提到如果引發異常後不做任何處理會冒泡似的在你的調用棧中向頂部傳播,直到導致當前任務崩潰。有時候發生致命錯誤時候我們確實希望安全的停止程式的運行,如果希望程式得以恢復一般我們會用到try...catch...finally代碼結構,它是js中處理異常的標準方式;
try { // 要運行的代碼,可能引發異常 doSomethingMightThrowError(); } catch (error) { // 處理異常的代碼塊,當發生異常時將會被捕獲,如果不繼續throw則不會再向上傳播 // error為捕獲的異常對象 // 這裡一般能讓程式恢復的代碼 doRecovery(); } finally { // 無論是否出現異常,始終都會執行的代碼 doFinally(); }
被忽略的finally:此語句塊會在try和catch語句結束之後執行,無論結果是否報錯。
同時要註意,非同步中的發生的異常無法被上層捕獲,比如:
// Timeout try { setTimeout(() => { throw Error("定時器出錯了!"); }, 1000); } catch (error) { console.error(error.message); } // Events try { window.addEventListener("click", function() { throw Error("點擊事件出錯了!"); }); } catch (error) { console.error(error.message); }
Promise本身是就可以捕獲異常,語法上也類似於try catch,一旦發生異常,程式跳過promise內的代碼繼續執行;可以使用了catch方法捕獲後進行處理,也可以使用then方法中的第二個參數處理異常。promise的異常對象同樣是冒泡的,前者捕獲了就不會拋給後者,參見示例:
const promiseA = new Promise((resolve,reject)=>{ throw new Error('Promise出錯了!'); }); const doSomethingWhenResolve = () => {}; const doSomethingWhenReject = (error) => { logger.log(error) } // 使用catch捕獲 const promiseB = promiseA.then(doSomethingWhenResolve).catch(doSomethingWhenReject); // 等價於 const promiseB = promise.then(doSomethingWhenResolve, doSomethingWhenResolve); promiseB.then(() => { console.log('我又可以正常進到then方法了!'); }).catch(()=>{ console.log('不會來這裡!'); })
如何處理異常
異常的發生不可避免,所以在軟體開發中,合理的異常處理就成為了高質量代碼不可或缺的一部分,只有處理好了異常我們才能對程式中的意外情況進行有效的控制。我們最容易容易犯的一個問題就是將異常處理和業務的流程混為一談。
根據Clean Code的建議,面對異常我們可以遵循以下一些原則,提高代碼質量:
Prefer Exceptions to Returning Error Codes
優先選擇異常而不是錯誤碼。
要理解這句話還是得結合例子,下麵的第一段代碼定義了一個Laptop類,在它的sendShutDown方法實現中,用if語句去檢查了getID的返回值中是否存在無效的deviceID,錯誤檢查會使調用者的代碼變得複雜不易閱讀業務邏輯,同時如果這個錯誤檢查被遺漏也會導致代碼出現問題,這個錯誤的處理可以交給語言讓整個過程更加優雅。第二段代碼中則將異常處理隔離了兩個不同的邏輯,這樣做會帶來一些優勢:
1.業務流程更加清晰易讀,我們把異常和業務流程理解為兩個不同的問題,可以分開去處理;
2.分開來的兩個邏輯都更加聚焦,代碼更簡潔;
3.將處理程式異常的職責交給了編程語言,明確了邊界;
// Dirty class Laptop { sendShutDown() { const deviceID = getID(DEVICE_LAPTOP); if (deviceID !== DEVICE_STATUS.INVALID) { pauseDevice(deviceID); clearDeviceWorkQueue(deviceID); closeDevice(deviceID); } else { logger.log('Invalid handle for: ' + DEVICE_LAPTOP.toString()); } } getID(status) { ... // 總是會返回deviceID,無論是不是合法有效的 return deviceID; } } // Clean class Laptop { sendShutDown() { try { tryToShutDown(); } catch (error) { logger.log(error); } } tryToShutDown() { const deviceID = getID(DEVICE_LAPTOP); pauseDevice(deviceID); clearDeviceWorkQueue(deviceID); closeDevice(deviceID); } getID(status) { ... throw new DeviceShutDownError('Invalid handle for: ' + deviceID.toString()); ... return deviceID; } }
Don't ignore caught error!
捕獲到異常後不要忽略異常處理!
在之前的代碼評審中就經常有看到我們同學會在catch塊中什麼都不做,或者迫於eslint的檢查會寫一個console.log(error),這同樣意味著什麼都沒有做。屬於眼睜睜看到異常發生了不採取任何措施,這樣的處理方式非常危險,因為這些異常通常由我們沒有考慮到的意外情況引起,從中能發現業務邏輯中不易發現的問題,一旦我們捕獲了這些異常,頂層的錯誤監控也不能主動捕獲到這些問題,程式也許沒有崩潰但如果沒有用戶告知我們,我們就無法發現用戶的哪些功能無法正常使用了,因此最起碼也要對這些異常做日誌上報;
// bad try { doSomethingMightThrowError(); } catch (error) { console.log(error); } // good try { doSomethingMightThrowError(); } catch (error){ console.error(error); message.error(error.message); logger.log(error); }
Don't ignore rejected promises!
不要輕易忽略Promise的異常,除非你確定它已經被處理了!
這一塊我們還是有血淚教訓的,在接入AEM的項目中曾經在腳本異常的上報里將disable_unhandled_rejection開啟,禁止捕獲了所有Promise異常,當時是基於我們線上應用大部分的promise異常都是umi-request請求介面出錯和antd表單驗證錯誤,且未帶來什麼線上問題,於是就天真的認為未捕獲的promise異常毫無危害;這個想法同樣危險,因為深入跟蹤發現介面請求出錯請求庫捕獲了異常並使用了message.error進行處理,表單驗證錯誤的異常同樣是antd在處理完之後選擇繼續向上拋出,這兩者確實沒什麼危害,可當我們面對這些更多未做處理的Promise異常時候(比如介面返回成功但約定的數據格式錯誤)同時又不做上報,我們就損失了很多線上問題的案發現場,只能抓瞎去盲猜復現,依賴用戶反饋。
查看以下案例:
// bad fetchData().then(doSomethingMightThrowError).catch(console.log); // good fetchData() .then(doSomethingMightThrowError) .catch(error => { console.error(error); message.error(error.message); logger.log(error); });
Exceptions Hierarchy
使用自定義異常,讓異常層次結構分明。
管理好業務代碼中的異常是非常酷的一件事,上面章節有介紹到Javascript給我們提供的一些基礎的異常類型,這些異常類型並不與我們的業務相關。所以使用這些異常來控制代碼中的錯誤也顯得不那麼恰當,我們的代碼正是對我們業務的建模。同樣的,我們也要將與業務相關的這些異常建模管理,對異常進行語義化,併在業務邏輯發生特定情況時觸發。否則就算調用方捕獲了異常也不知道該如何去處理。
這樣做往往會帶來一些好處:
1.使用error instanceof CustomBizError更容易識別異常,會讓判斷邏輯更簡潔且已讀,更容易處理捕獲到的異常並恢復程式。
2.通過標準化我們的自定義錯誤類,讓我們更容易做上層處理,比如上面有提到的介面異常我可以選擇不作為腳本異常全局上報,因為通常在介面異常里就已經上報了該信息;
參考以下例子
export class RequestException extends Error { constructor(message) { super(`RequestException: ${mesage}`); } } export class AccountException extends Error { constructor(message) { super(`AccountException: ${message}`); } } const AccountController = { getAccount: (id) => { ... throw new RequestException('請求賬戶信息失敗!'); ... } } // 客戶端代碼,創建賬戶 const id = 1; const account = AccountController.getAccount(id); if(account){ throw new AccountException('賬戶已存在!'); }
Provide context with exceptions
提供異常上下文
異常一旦發生了,一般都會有異常信息(message)和棧跟蹤(stack trace)信息還有文件名之類的來定位發生錯誤的現場,但哪怕是這樣在定位起來還是比較困難,所以一般建議去豐富異常信息讓我們定位問題更加的快速。可以是在捕獲到異常的地方解釋我們的意圖,同時這些額外的信息也都應該只是面向我們開發者用以定位問題,不需要讓使用者去感知這些異常上下文,不在用戶界面中進行體現。
結合上一條的自定義錯誤,我們還要為這些自定義錯誤提供更加豐富個上下文。
React 中的建議
局部UI的JS Error不應該導致整個應用崩潰白屏,我們應該把他的影響範圍控制在最小,這是一個容易形成共識的結論,於是React 16引入了錯誤邊界(Error Boundaries)的概念。
React Error Boundaries 官方文檔[2] 里提到:
錯誤邊界是一種 React 組件,這種組件可以捕獲發生在其子組件樹任何位置的 JavaScript 錯誤,並列印這些錯誤,同時展示降級 UI,而並不會渲染那些發生崩潰的子組件樹。錯誤邊界可以捕獲發生在整個子組件樹的渲染期間、生命周期方法以及構造函數中的錯誤。
ProComponents[3]的很多組件應該都有使用Error Boundaries比如ProTable,用以異常發生時只對局部UI產生影響,查看@ant-design/pro-utils中的源碼可以看到和官網的處理別無二致,更多的信息查看官網有非常詳細的介紹:
import { Result } from 'antd'; import type { ErrorInfo } from 'react'; import React from 'react'; // eslint-disable-next-line @typescript-eslint/ban-types class ErrorBoundary extends React.Component< { children?: React.ReactNode }, { hasError: boolean; errorInfo: string } > { state = { hasError: false, errorInfo: '' }; static getDerivedStateFromError(error: Error) { return { hasError: true, errorInfo: error.message }; } componentDidCatch(error: any, errorInfo: ErrorInfo) { // You can also log the error to an error reporting service // eslint-disable-next-line no-console console.log(error, errorInfo); } render() { if (this.state.hasError) { // You can render any custom fallback UI return <Result status="error" title="Something went wrong." extra={this.state.errorInfo} />; } return this.props.children; } } export { ErrorBoundary };
所以給我們的啟示是組件庫或者業務系統中的塊級的一些東西(spm模型中的c位)一定要考慮好組件級別的異常處理。
異常的全局上報
基本上這是對付不可預知異常的終極解法,自動收集錯誤報告併在達到閾值時做出告警,屬於在理想情況下異常發生後能讓研發同學們能第一時間發現並定位解決問題,主要會使用2個全局事件:
window.onerror事件
JS運行中的大部分異常(包括語法錯誤),都會觸發window上的error事件執行註冊的函數,不同於try catch,onerror既可以感知同步異常也可以感知非同步任務的異常(除了promise異常),使用方法如下:
// message:錯誤信息(字元串)。 // source:發生錯誤的腳本URL(字元串) // lineno:發生錯誤的行號(數字) // colno:發生錯誤的列號(數字) // error:Error對象(對象) window.onerror = function(message, source, lineno, colno, error) { logger.log('捕獲到異常:',{ message, source, lineno, colno, error }); }
unhandledrejection事件
作為以上方案的補充版,promise異常的捕獲依賴於全局註冊unhandledrejection,使用方法如下
window.addEventListener('unhandledrejection', (e) => { console.error('catch', e) }, true)
寫在最後
其實總結下來我們的異常處理主要也只是乾兩件事情:
1.將面向開發的異常信息轉換成更友好的用戶界面提示;
2.將異常信息上報到服務端讓研發同學去解決這些異常;
希望大家看了本篇文章有所收穫!
參考鏈接:
[1]https://developer.mozilla.org/zh-CN/Core_JavaScript_1.5_Reference/Global_Functions/eval
[2]https://reactjs.org/docs/error-boundaries.html
[3]https://procomponents.ant.design/
作 者 | 肖榮強(路遷)
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Some-experience-with-javascript-exception-handling.html