You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
functionhandleError(root,thrownValue){do{varerroredWork=workInProgress;try{throwException(root,erroredWork.return,erroredWork,thrownValue);completeUnitOfWork(erroredWork);}catch(yetAnotherThrownValue){// Something in the return path also threw.continue;}return;}while(true);}
// 主要两个工作// 调用unwinkWork重置context,然后往上找到最近的能够处理异常的ErrorBoundary,找不到的话,那就是root节点functioncompleteUnitOfWork(unitOfWork){varcompletedWork=unitOfWork;do{varcurrent=completedWork.alternate;varreturnFiber=completedWork.return;if((completedWork.flags&Incomplete)===NoFlags){}else{// 当前fiber没有完成,因为有异常抛出,因此需要从栈恢复var_next=unwindWork(completedWork);if(_next!==null){_next.flags&=HostEffectMask;workInProgress=_next;return;}if(returnFiber!==null){returnFiber.firstEffect=returnFiber.lastEffect=null;returnFiber.flags|=Incomplete;}}completedWork=returnFiber;// Update the next thing we're working on in case something throws.workInProgress=completedWork;}while(completedWork!==null);}
大纲
前置基础知识
如果还不熟悉 JS 异常捕获,比如全局异常捕获,Promise 异常捕获,异步代码异常捕获。自定义事件,以及 dispatchEvent 的用法。React 错误边界等基础知识的,可以参考以下几篇短文。如果已经熟悉了,可以跳过。
为什么 Dev 模式下, React 不直接使用 try catch,而是自己模拟 try catch 机制实现异常捕获?
开发环境的目标:保持 Pause on exceptions 的预期行为
要回答这个问题,我们先看下 React 源码中一段关于异常捕获机制的描述:
同时结合这个issue可以知道,React 异常处理最重要的目标之一就是保持浏览器的
Pause on exceptions
行为。如果对Pause on exceptions
不熟悉的,可以看这篇文章React 将用户的所有业务代码包装在
invokeGuardedCallback
函数中执行,比如构造函数,生命周期方法等。这些方法内部的逻辑是用户自己实现的,并且大部分在 React 的 render 阶段调用,理论上这些方法内部所抛出的任何异常,都应该让用户自行捕获,比如下面的代码中
useLayoutEffect
内部的逻辑是用户自己实现的,由于用户没有自己实现 try catch 捕获异常,那么理论上useLayoutEffect
内部抛出的异常应该可以被浏览器的Pause on exceptions
自动定位到。在生产环境中,
invokeGuardedCallback
使用 try catch,因此所有的用户代码异常都被视为已经捕获的异常,不会被Pause on exceptions
自动定位到,当然用户也可以通过开启Pause On Caught Exceptions
自动定位到被捕获的异常代码位置。但是这并不直观,因为即使 React 已经捕获了错误,从开发者的角度来说,错误是没有捕获的(毕竟用户没有自行捕获这个异常,而 React 作为库,不应该吞没异常),因此为了保持预期的
Pause on exceptions
行为,React 不会在 Dev 中使用 try catch,而是使用 custom event以及dispatchEvent模拟 try catch 的行为。防止用户业务代码被第三方库吞没
根据这个issue可以知道,React 异常捕获还有一个目标就是防止用户业务代码被其他第三方库的异步代码吞没。比如 react redux,redux saga 等。例如在 redux saga 中这么调用了 setState:
如果 React 不经过 invokeguardcallback 处理,那么 setState 的触发的 render 的异常将会被 promise.catch 捕获,在用户的角度看来,这个异常被吞没了。
React16 以后由于有了 invokeguardcallback 处理异常,在异步代码中调用 setState 触发的 render 的异常不会被任何 try catch 或者 promise catch 吞没。比如:
Promise 的 catch 虽然可以捕获异常,但是 React 还是可以照样抛出异常,控制台还是会打印 Error 信息
使用 dispatchEvent 模拟 try catch,同时又能保持浏览器开发者工具 Pause on exceptions 的预期行为
dispatchEvent 能够模拟 try catch,是基于下面的特性:
这么说有点抽象,我们再来复习一个简单的例子:
这个例子首先注册一个全局异常监听器,然后创建自定义的事件,给 btn、root 添加监听自定义事件的监听器,其中 btn 的第一个监听器抛出一个异常。最后通过
dispatchEvent
触发自定义事件监听器的执行。执行结果如下所示:从图中的执行结果可以看出,btn 的第一个事件监听器抛出的异常会立即被全局异常监听器捕获到,并立即执行。 这个效果和 try catch 完全一致!!!同时,即使自定义事件监听器的异常被全局异常监听器捕获到了,仍然可以被
Pause on exceptions
自动定位到,这就是 React 想要的效果!!!在开发环境中,React 将自定义事件(fake event)同步派发到自定义 dom(fake dom noe)上,并在自定义事件监听器内调用用户的回调函数,如果用户的回调函数抛出错误,则使用全局异常监听器捕获错误。这为我们提供了 try catch 的行为,而无需实际使用 try catch,又能保持浏览器
Pause on exceptions
的预期行为。Dev 模式下,React 如何实现模拟 try catch 的行为
在 dev 环境下,invokeGuardedCallback 的实现如下所示,这里是精简后的代码,func 是用户提供的回调函数,比如在 render 阶段,func 就是 beginWork 函数。
dev 环境下在自定义事件监听器中执行用户的回调函数,如果用户的回调函数抛出异常,则被全局的异常监听器捕获,并且立即执行全局异常监听器
在生产环境下,invokeGuardedCallback 的实现如下,使用普通的 try catch 捕获用户提供的函数 func 里面的异常
React Dev 模式异常捕获及处理
在 Dev 环境下,React 使用
invokeGuardedCallback
包裹几乎所有的用户业务代码,我全局搜索了一下invokeGuardedCallback
函数的调用,总共有以下几个地方调用了invokeGuardedCallback
函数捕获异常,涵盖了所有的用户业务代码:invokeGuardedCallback
包括执行。然后 ref 中的异常会在 captureCommitPhaseError 中处理可以看出,在 dev 环境中,我们所有的业务代码都被
invokeGuardedCallback
包裹并且执行,我们业务代码中的异常都会被invokeGuardedCallback
捕获。除了合成事件中的异常特殊处理外,在 render 阶段调用的方法,比如构造函数,一些生命周期方法中的异常,都在handleError
中处理。在 commit 阶段调用的方法,比如 useEffect 的监听函数等方法的异常,都在captureCommitPhaseError
中处理。总的来说,React 使用 invokeGuardedCallback 捕获我们业务代码中的异常,然后在
handleError
或者captureCommitPhaseError
处理异常但是,我们也需要明白一点,并不是所有的用户业务代码中的异常都会被错误边界处理
并不是用户的所有业务代码都能被 React 错误边界处理!!!
并不是用户的所有业务代码都能被 React 错误边界处理!!!
并不是用户的所有业务代码都能被 React 错误边界处理!!!
一般情况下,React 错误边界能够处理大部分的用户业务代码的异常,包括 render 阶段以及 commit 阶段执行的业务代码,但是并不能捕获并处理以下的用户业务代码异常:
下面,逐一介绍合成事件异常捕获及处理、
handleError
异常处理、captureCommitPhaseError
异常处理合成事件回调函数中的异常捕获及处理
合成事件中的异常不会被 React 错误边界处理
React 会捕获合成事件中的错误,但只会将第一个重新抛出,同时并不会在控制台打印 fiber 栈信息,举个例子:
当我们点击 'click me' 时,React 会沿着冒泡阶段调用所有的监听函数,并捕获这些错误打印出来。但是,React 只会将第一个错误重新抛出(rethrowCaughtError)。可以发现下图中 React 捕获了这两个监听函数中的错误并打印了出来,但 React 只会将第一个监听函数中的错误重新抛出。
handleError 如何处理异常
handleError 只用于处理 render 阶段在
beginWork
函数中执行的用户业务代码抛出的异常,比如构造函数,类组件的 render 方法、函数组件、生命周期方法等为了方便演示,我将
renderRootSync
的主要逻辑简化如下,这也是 React render 阶段的主要逻辑,以下代码可以直接复制在浏览器控制台运行:从上面代码可以看出,如果
beginWork
函数发生了异常,那么会被 try catch 捕获,并且 React 会在 catch 里面重新将 beginWork 包裹进invokeGuardedCallback
函数中重复执行!!!。前面说过,使用 try catch 捕获异常,会破坏浏览器的Pause on exceptions
预期的行为,因此如果 beginWork 抛出了异常,则需要将 beginWork 包裹进Pause on exceptions
重复执行,在invokeGuardedCallback
抛出的异常不会被吞没第二次执行
beginWork
时,如果抛出异常,则会被handleError
捕获并处理,下面我们详细了解下handleError
如何处理异常以下面的代码为例:
renderRootSync
也是一个循环,这里需要注意,循环结束的条件是要么hanleError
重新抛出异常终止函数执行,要么workLoopSync
正常执行完成,到 break 语句退出。当
workLoopSync
执行的过程中发生异常时,会被handleError
捕获。handleError
会从当前抛出异常的 fiber 节点开始(这里是 div#counter 对应的 fiber 节点)往上找到最近的错误边界组件,即 ErrorBoundary,如果不存在 ErrorBoundary 组件,则会找到 root fiber。然后 handleError 执行完成。循环继续,此时workLoopSync
重新执行,workLoopSync
又会从 root fiber 重新执行,这里有两种情况workLoopSync
会从 ErrorBoundary 开始执行,并渲染 ErrorBoundary 的备用 UIworkLoopSync
会从 root 节点开始执行,React 会直接卸载整个组件树,页面崩溃白屏。然后在 commit 阶段执行完成后将异常重新抛出,这次抛出的异常会被浏览器的Pause on exceptions
捕获到因此,
workLoopSync
的重复执行,要么会让页面崩溃,要么显示我们的备用 UI。而往上查找 ErrorBoundary 的任务就由
throwException
函数完成。throwException 主要做两件事:createCapturedValue
从当前抛出异常的 fiber 节点开始往上找出所有的 fiber 节点并收集起来,用于在控制台打印 fiber 栈,如下:root 节点。同时,类组件需要满足实现
getDerivedStateFromError
或者componentDidCatch
方法才能成为 ErrorBoundary注意,
throwException
执行完成后,会调用completeUnitOfWork
继续完成工作。此时的 completeUnitOfWork 会走 else 的逻辑,主要做几件事:看到这里,需要注意一点,workLoopSync 第二次重复执行时,从哪个节点开始,也是分情况的:
handleError 总结
总的来说,handleError 主要是处理 render 阶段抛出的异常。 从当前抛出异常的节点开始,往上找,直到找到 ErrorBoundary 组件或者 root 节点。并将 cotext 恢复到 ErrorBoundary 或者 root 节点,然后重复执行 workLoopSync,第二次执行的 workLoopSync 从 ErrorBoundary 或者 root 节点开始执行 render 的过程
captureCommitPhaseError 如何处理异常
还是以上面的代码为例,这次修改一下 Couter 组件,在 useEffect 中抛出异常:
captureCommitPhaseError
用来处理 commit 阶段抛出的异常。主要是做了以下几件事:ensureRootIsScheduled
从 root 节点开始执行。The text was updated successfully, but these errors were encountered: