可以使用自定义事件,结合全局错误事件监听(window 的 error 事件)实现 try catch 的模拟。
try catch 的作用
try catch 的作用是捕获代码中的错误,当 try 代码块中的代码发生错误时,该代码会被捕获,然后代码会跳到 catch 语句块中,我们就可以在 catch 中处理捕获到的错误。从而避免因代码错误导致页面崩溃。
js
try {
// 代码...
} catch (err) {
// 错误捕获
}
不过需要注意的是,try catch 无法捕获异步代码中的错误。因为异步代码执行的时候,try catch 已经执行完了,try catch 自然是无法捕获异步代码中的错误的。更多相关的内容可见这篇文章 因为一道try...catch的题,我的面试挂掉了
认识 JS 的自定义事件与 dispatchEvent
在 JS 中可以使用 document.createEvent 方法创建自定义事件,并且需要使用 initEvent 方法初始化,该事件才可使用。更多可见 Document.createEvent() 、Event.initEvent()
js
// 创建事件
var event = document.createEvent("Event");
// 定义事件名为'build'.
event.initEvent("build", true, true);
// 监听事件
elem.addEventListener(
"build",
function (e) {
// e.target matches elem
},
false,
);
// 触发对象可以是任何元素或其他事件目标
elem.dispatchEvent(event);
通过 JS 触发(dispatchEvent 触发、click() 方法触发的点击事件等)的事件是同步的。如果是用户手动触发的事件,那该事件是异步的。比如用户通过手动点击按钮触发的点击事件,该点击事件的回调函数会压入事件队列中,等到 JS 的执行栈中的任务执行完毕后才触发。
当自定义事件抛出异常,该异常可被全局错误事件监听器捕获到,全局错误事件监听器的回调函数会立即执行。但是抛出异常的事件监听的回调函数不会影响到其他事件监听回调函数的执行,这与 try catch 的行为是一致的。
dispatchEvent 的例子
由 dispatchEvent 触发的事件回调是同步执行的。
html
<button id="btn">click me</button>
js
const event = document.createEvent('Event')
const evtType = 'MyCustomEvent'
event.initEvent(evtType, true, true)
btn.onclick = function() {
console.log(1)
btn.dispatchEvent(event);
console.log(2)
}
document.addEventListener(evtType, () => {
console.log('nested')
})
输出顺序为:1 -> nested -> 2
当用户点击 btn 后,会触发 btn 的 click 事件,输出 1
,然后 btn 调用 dispatchEvent 方法,触发自定义事件(MyCustomEvent),我们知道由 JS 触发的事件是同步执行的,因此 JS 会跳出 onclick 回调函数,执行 document 上的自定义事件(MyCustomEvent)的回调函数,输出 nested
,执行完毕后会回到 btn 的 onclick 回调函数,输出 2
。因此最终的输出顺序为:1 -> nested -> 2 。
浏览器的原生事件也一样,由 JS 触发的事件是同步执行的,由用户手动触发的事件是异步执行的。
JS 触发(同步)的例子
由 JS 触发的事件回调(比如 click() 方法)是同步执行的。
html
<button id="btn">click me</button>
js
btn.onclick = function() {
console.log('click')
}
console.log('start')
for (var i = 0; i < 2000000000; i++) {
if (i === 1000000000) {
btn.click()
}
}
console.log('end')
输出顺序:start -> click -> end
for 循环是同步执行的,如果 JS 触发的事件是异步的,那么 click
应该在 start
和 end
后面输出,但是现在是 click
在 start
后输出,end
在最后输出,则说明由 JS 触发的事件是同步执行的。
JS 触发(异步)的例子
由用户在浏览器页面上手动操作触发的事件回调是异步执行的。
html
<button id="btn">click me</button>
js
btn.onclick = function() {
console.log('click')
}
console.log('start');
for (var i = 0; i < 2000000000; i++);
console.log('end');
输出顺序:start -> end -> click
当用户点击 btn 后,click
在 start
和 end
输出后才输出,说明用户手动触发的事件回调是异步的,因为如果是同步的,click
应该在 start
输出后输出,最后才输出 end
。
自定义事件监听回调抛出异常的例子
html
<div id="root">
<button id="btn">click me</button>
</div>
js
window.addEventListener('error', (e) => {
console.log("全局捕获异常...", e)
})
const event = document.createEvent('Event')
const evtType = 'MyCustomEvent'
root.addEventListener(evtType, function (e) {
console.log("root..监听自定义事件-1", e)
})
btn.addEventListener(evtType, function (e) {
console.log("btn..监听自定义事件-1", e)
throw Error("btn事件监听器抛出的异常")
console.log("btn异常后面的代码不会执行")
})
btn.addEventListener(evtType, function (e) {
console.log("btn..监听自定义事件-2", e)
})
root.addEventListener(evtType, function (e) {
console.log("root..监听自定义事件-2", e)
})
console.log("开始触发自定义事件")
event.initEvent(evtType, true, true)
btn.dispatchEvent(event)
console.log("自定义事件监听函数执行完毕")
当一个 DOM 节点注册了多个相同的事件监听器后,如果其中一个事件监听器函数抛出错误,不会影响另外一个事件监听器函数的执行以及其父元素的事件监听器的执行。这一点与 try catch 的行为是一致的。
虽然自定义事件监听器里面的错误被全局捕获了,但控制台依然会打印 Uncaught Error 。这一点与 try catch 的行为不太一样。同时也是因为这个原因,我们可以认为 try catch 吞没了异常,在一些场景下,应该让用户的异常抛出,而不是吞没异常,因为被吞没的异常难以让用户发觉,让用户难以排查。
html
<div id="root">
<button id="btn">click me</button>
</div>
js
const event = document.createEvent('Event')
const evtType = 'MyCustomEvent'
root.addEventListener(evtType, function (e) {
console.log("root..监听自定义事件-1", e)
})
btn.addEventListener(evtType, function (e) {
try {
console.log("btn..监听自定义事件-1", e)
throw Error("btn事件监听器抛出的异常")
console.log("btn异常后面的代码不会执行")
} catch (e) {
console.log("try catch 捕获异常...", e)
}
})
btn.addEventListener(evtType, function (e) {
console.log("btn..监听自定义事件-2", e)
})
root.addEventListener(evtType, function (e) {
console.log("root..监听自定义事件-2", e)
})
console.log("开始触发自定义事件")
event.initEvent(evtType, true, true)
btn.dispatchEvent(event)
console.log("自定义事件监听函数执行完毕")
模拟 try catch 的具体实现
可以使用自定义事件结合全局错误事件监听来模拟 try catch 。
我们可以实现一个函数(invokeGuardedCallbackDev),该函数接收一个函数作为入参,同时会在函数内部创建一个自定义事件(fake event)和自定义的 DOM 节点(fake DOM node),传入的函数会在绑定的自定义的 DOM 节点的自定义事件监听的回调函数中执行。
同时在该函数内部注册全局错误事件的回调函数,因此当传入的函数发生错误时,会被全局错误事件监听器捕获到。
js
function invokeGuardedCallbackDev(func) {
function handleWindowError(error) {
console.log('handleWindowError ', error)
}
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)
}
invokeGuardedCallbackDev(() => {
console.log('invokeGuardedCallbackDev', a)
})
console.log('finish')
我们在函数(invokeGuardedCallbackDev)传入了一个会报错的函数,然后发现后面的代码不受影响也正常执行了(即上面的 finish 正常输出了),这样我们就模拟了 try catch 的行为。
为什么要模拟 try catch ?
为啥有原生的 try catch 不用,要自己模拟?其实这是有用的。
在我们提供给他人使用的框架中,不能吞没用户的异常,同时为了保证用户在 DEV (调试)模式下,在处理用户代码的错误时,不改变浏览器的 Pause on exceptions
的行为,从而方便用户排除业务代码中的错误,此时我们就不能直接使用 try catch 来处理错误(try catch 会改变浏览器的 Pause on exceptions
的行为)。这时我们就需要借助自定义事件和全局错误事件监听器来模拟 try catch 进行错误的处理了,这也是 React 在 DEV 模式下的错误处理的方式。
有关 React 错误处理更多细节可查看笔者写的另一篇文章 深入源码,剖析 React 是如何做错误处理的
浏览器的 Pause on exceptions
的行为是在浏览器的 DevTools 下的 source 面板勾选 Pause on uncaught exceptions
后,会自动在代码发生错误的地方停下来的特性。
总结
可以使用自定义事件,结合全局错误事件监听(window 的 error 事件)实现 try catch 的模拟。模拟 try catch 的目的是在我们提供给他人使用的框架中,不能吞没用户的异常,同时也要保证浏览器 Pause on exceptions
的行为不变,从而方便用户排查业务代码中的错误。React 在 DEV 模式下的就是通过模拟 try catch 来处理错误的,其模拟 try catch 的方式就是自定义事件,结合全局错误事件监听。