本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
这是多个feature组合使用后实现的神奇效果,在React源码中被广泛使用。
当我读源码看到这里时,心情经历了:
懵逼 -- 困惑 -- 沉思 -- 查文档 -- 豁然开朗
看完此文,相信你也会发出感叹:
还能这么玩?
起源
我们知道,React中有个特性Error Boundary,帮助我们在组件发生错误时显示 “错误状态” 的 UI。
为了实现这个特性,就一定需要捕获到错误。
所以在React源码中,所有用户代码都被包裹在一个方法中执行。
类似如下:
function wrapper(func) { try { func(); } catch(e) { // ...处理错误 }}比如触发componentDidMount时:
wrapper(componentDidMount);本来一切都很完美,但是React作为世界级前端框架,受众广泛,凡事都讲究做到极致。
这不,有人提 issue:
你们这样在
try catch中执行用户代码会让浏览器调试工具的Pause on exceptions失效。
Pause on exceptions 失效的来龙去脉
Pause on exceptions是什么?
他是浏览器调试工具source面板的一个功能。
开启该功能后,在运行时遇到会抛出错误的代码,代码的执行会自动停在该行,就像在该行打了断点一样。
比如,执行如下代码,并开启该功能:
let a = c;代码的执行会在该行暂停。
这个功能可以很方便的帮我们发现未捕获的错误发生的位置。
但是,当React将用户代码包裹在try catch后,即使代码抛出错误,也会被catch。
Pause on exceptions无法在抛出错误的用户代码处暂停,因为error已经被React catch了。
除非我们进一步开启Pause on caught exceptions。
开启该功能,使代码在捕获的错误发生的位置暂停。
如何解决
对用户来说,我写在componentDidMount中的代码明明未捕获错误,可是错误发生时Pause on exceptions却失效了,确实有些让人困惑。
所以,在生产环境,React继续使用try catch实现wrapper。
而在开发环境,为了更好的调试体验,需要重新实现一套try catch机制,包含如下功能:
捕获
用户代码抛出的错误,使Error Boundary功能正常运行不捕获
用户代码抛出的错误,使Pause on exceptions不失效
这看似矛盾的功能,React如何机智的实现呢?
如何 “捕获” 错误
让我们先实现第一点:捕获用户代码抛出的错误。
但是不能使用try catch,因为这会让Pause on exceptions失效。
解决办法是:监听window的error事件。
根据 GlobalEventHandlers.onerror MDN[1],该事件可以监听到两类错误:
js 运行时错误(包括语法错误)。
window会触发ErrorEvent接口的error事件资源(如
<img>或<script>)加载失败错误。加载资源的元素会触发Event接口的error事件,可以在window上捕获该错误
实现开发环境使用的wrapperDev:
// 开发环境wrapperfunction wrapperDev(func) { function handleWindowError(error) { // 收集错误交给Error Boundary处理 } window.addEventListener('error', handleWindowError); func(); window.removeEventListener('error', handleWindowError);}当func执行时抛出错误,会被handleWindowError处理。
但是,对比生产环境wrapperPrd内func抛出的错误会被catch,不会影响后续代码执行。
function wrapperPrd(func) { try { func(); } catch(e) { // ...处理错误 }}开发环境func内如果抛出错误,代码的执行会中断。
比如执行如下代码,finish会被打印。
wrapperPrd(() => {throw Error(123)})console.log('finish');但是执行如下代码,代码执行中断,finish不会被打印。
wrapperDev(() => {throw Error(123)})console.log('finish');如何在不捕获用户代码抛出错误的前提下,又能让后续代码的执行不中断呢?
如何让代码执行不中断
答案是:通过dispatchEvent触发事件回调,在回调中调用用户代码。
根据 EventTarget.dispatchEvent MDN[2]:
不同于DOM节点触发的事件(比如click事件)回调是由event loop异步触发。
通过dispatchEvent触发的事件是同步触发,并且在事件回调中抛出的错误不会影响dispatchEvent的调用者(caller)。
让我们继续改造wrapperDev。
首先创建虚构的DOM节点、事件对象、虚构的事件类型:
// 创建虚构的DOM节点const fakeNode = document.createElement('fake');// 创建eventconst event = document.createEvent('Event');// 创建虚构的event类型const evtType = 'fake-event';初始化事件对象,监听事件。在事件回调中调用用户代码。触发事件:
function callCallback() { fakeNode.removeEventListener(evtType, callCallback, false); func();}// 监听虚构的事件类型fakeNode.addEventListener(evtType, callCallback, false);// 初始化事件event.initEvent(evtType, false, false);// 触发事件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)})会依次执行:
dispatchEvent触发事件回调callCallback在
callCallback内执行到throw Error(123),抛出错误callCallback执行中断,但调用他的函数会继续执行。Error(123)被window error handler捕获用于Error Boundary
其中步骤 2 使Pause on exceptions不会失效。
步骤 3、4 使得错误被捕获,且不会阻止后续代码执行,模拟了try catch的效果。
总结
不得不说,React这波操作真细啊。
我们实现的迷你wrapper还有很多不足,比如:
没有针对不同浏览器的兼容
没有考虑其他代码也触发
window error handler
React源码的完整版wrapper,见这里 [3]
参考资料
[1]
GlobalEventHandlers.onerror MDN: _https://developer.mozilla.org/zh-CN/docs/Web/API/GlobalEventHandlers/onerror_
[2]
EventTarget.dispatchEvent MDN: _https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/dispatchEvent_
[3]
这里: _https://github.com/facebook/react/blob/master/packages/shared/invokeGuardedCallbackImpl.js#L63-L237_
关于奇舞周刊
《奇舞周刊》是 360 公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。