寫在前面
為了提升應用穩定性,我們對前端專案開展了腳本例外治理的作業,對生產上報的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 = https://www.cnblogs.com/88223100/archive/2022/11/02/(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
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/525058.html
標籤:其他
