不用try catch,如何機智的捕獲錯誤
起源
我們知道,React中有個特性Error Boundary,幫助我們在組件發(fā)生錯誤時顯示“錯誤狀態(tài)”的UI。
為了實現這個特性,就一定需要捕獲到錯誤。
所以在React源碼中,所有用戶代碼都被包裹在一個方法中執(zhí)行。
類似如下:
- function wrapper(func) {
- try {
- func();
- } catch(e) {
- // ...處理錯誤
- }
- }
比如觸發(fā)componentDidMount時:
- wrapper(componentDidMount);
本來一切都很完美,但是React作為世界級前端框架,受眾廣泛,凡事都講究做到極致。
這不,有人提issue:
- 你們這樣在try catch中執(zhí)行用戶代碼會讓瀏覽器調試工具的Pause on exceptions失效。
Pause on exceptions失效的來龍去脈
Pause on exceptions是什么?
他是瀏覽器調試工具source面板的一個功能。
開啟該功能后,在運行時遇到會拋出錯誤的代碼,代碼的執(zhí)行會自動停在該行,就像在該行打了斷點一樣。
比如,執(zhí)行如下代碼,并開啟該功能:
- let a = c;
代碼的執(zhí)行會在該行暫停。
這個功能可以很方便的幫我們發(fā)現未捕獲的錯誤發(fā)生的位置。
但是,當React將用戶代碼包裹在try catch后,即使代碼拋出錯誤,也會被catch。
Pause on exceptions無法在拋出錯誤的用戶代碼處暫停,因為error已經被React catch了。
除非我們進一步開啟Pause on caught exceptions。
開啟該功能,使代碼在捕獲的錯誤發(fā)生的位置暫停。
如何解決
對用戶來說,我寫在componentDidMount中的代碼明明未捕獲錯誤,可是錯誤發(fā)生時Pause on exceptions卻失效了,確實有些讓人困惑。
所以,在生產環(huán)境,React繼續(xù)使用try catch實現wrapper。
而在開發(fā)環(huán)境,為了更好的調試體驗,需要重新實現一套try catch機制,包含如下功能:
- 捕獲用戶代碼拋出的錯誤,使Error Boundary功能正常運行
- 不捕獲用戶代碼拋出的錯誤,使Pause on exceptions不失效
這看似矛盾的功能,React如何機智的實現呢?
如何“捕獲”錯誤
讓我們先實現第一點:捕獲用戶代碼拋出的錯誤。
但是不能使用try catch,因為這會讓Pause on exceptions失效。
解決辦法是:監(jiān)聽window的error事件。
根據GlobalEventHandlers.onerror MDN[1],該事件可以監(jiān)聽到兩類錯誤:
- js運行時錯誤(包括語法錯誤)。window會觸發(fā)ErrorEvent接口的error事件
- 資源(如<img>或<script>)加載失敗錯誤。加載資源的元素會觸發(fā)Event接口的error事件,可以在window上捕獲該錯誤
實現開發(fā)環(huán)境使用的wrapperDev:
- // 開發(fā)環(huán)境wrapper
- function wrapperDev(func) {
- function handleWindowError(error) {
- // 收集錯誤交給Error Boundary處理
- }
- window.addEventListener('error', handleWindowError);
- func();
- window.removeEventListener('error', handleWindowError);
- }
當func執(zhí)行時拋出錯誤,會被handleWindowError處理。
但是,對比生產環(huán)境wrapperPrd內func拋出的錯誤會被catch,不會影響后續(xù)代碼執(zhí)行。
- function wrapperPrd(func) {
- try {
- func();
- } catch(e) {
- // ...處理錯誤
- }
- }
開發(fā)環(huán)境func內如果拋出錯誤,代碼的執(zhí)行會中斷。
比如執(zhí)行如下代碼,finish會被打印。
- wrapperPrd(() => {throw Error(123)})
- console.log('finish');
但是執(zhí)行如下代碼,代碼執(zhí)行中斷,finish不會被打印。
- wrapperDev(() => {throw Error(123)})
- console.log('finish');
如何在不捕獲用戶代碼拋出錯誤的前提下,又能讓后續(xù)代碼的執(zhí)行不中斷呢?
如何讓代碼執(zhí)行不中斷
答案是:通過dispatchEvent觸發(fā)事件回調,在回調中調用用戶代碼。
根據EventTarget.dispatchEvent MDN[2]:
不同于DOM節(jié)點觸發(fā)的事件(比如click事件)回調是由event loop異步觸發(fā)。
通過dispatchEvent觸發(fā)的事件是同步觸發(fā),并且在事件回調中拋出的錯誤不會影響dispatchEvent的調用者(caller)。
讓我們繼續(xù)改造wrapperDev。
首先創(chuàng)建虛構的DOM節(jié)點、事件對象、虛構的事件類型:
- // 創(chuàng)建虛構的DOM節(jié)點
- const fakeNode = document.createElement('fake');
- // 創(chuàng)建event
- const event = document.createEvent('Event');
- // 創(chuàng)建虛構的event類型
- const evtType = 'fake-event';
初始化事件對象,監(jiān)聽事件。在事件回調中調用用戶代碼。觸發(fā)事件:
- function callCallback() {
- fakeNode.removeEventListener(evtType, callCallback, false);
- func();
- }
- // 監(jiān)聽虛構的事件類型
- fakeNode.addEventListener(evtType, callCallback, false);
- // 初始化事件
- event.initEvent(evtType, false, false);
- // 觸發(fā)事件
- fakeNode.dispatchEvent(event);
完整流程如下:
- function wrapperDev(func) {
- function handleWindowError(error) {
- // 收集錯誤交給Error Boundary處理
- }
- function callCallback() {
- fakeNode.removeEventListener(evtType, callCallback, false);
- func();
- }
- const event = document.createEvent('Event');
- const fakeNode = document.createElement('fake');
- const evtType = 'fake-event';
- window.addEventListener('error', handleWindowError);
- fakeNode.addEventListener(evtType, callCallback, false);
- event.initEvent(evtType, false, false);
- fakeNode.dispatchEvent(event);
- window.removeEventListener('error', handleWindowError);
- }
當我們調用:
- wrapperDev(() => {throw Error(123)})
會依次執(zhí)行:
- dispatchEvent觸發(fā)事件回調callCallback
- 在callCallback內執(zhí)行到throw Error(123),拋出錯誤
- callCallback執(zhí)行中斷,但調用他的函數會繼續(xù)執(zhí)行。
- Error(123)被window error handler捕獲用于Error Boundary
其中步驟2使Pause on exceptions不會失效。
步驟3、4使得錯誤被捕獲,且不會阻止后續(xù)代碼執(zhí)行,模擬了try catch的效果。
總結
不得不說,React這波操作真細啊。
我們實現的迷你wrapper還有很多不足,比如:
- 沒有針對不同瀏覽器的兼容
- 沒有考慮其他代碼也觸發(fā)window error handler
React源碼的完整版wrapper,見這里[3]參考資料
GlobalEventHandlers.onerror MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/GlobalEventHandlers/onerror EventTarget.dispatchEvent MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/dispatchEvent 這里: https://github.com/facebook/react/blob/master/packages/shared/invokeGuardedCallbackImpl.js#L63-L237