欢迎来到 React 的“崩溃”现场:深度解析全局错误处理与协调器的同步链路
各位同学,大家好!欢迎来到今天的讲座。我是你们的讲师,一个在 React 内部深渊里摸爬滚打多年的资深“救火队员”。
今天我们要聊的东西,听起来有点吓人,但实际上非常迷人。我们的话题是:React 全局错误处理器集成。具体点说,我们要像剥洋葱一样,一层层剥开 React 的皮,看看当你的应用突然变成一个令人绝望的空白屏幕时,到底发生了什么。我们要追踪那个神秘的“协调器”,看它如何像猫捉老鼠一样,把渲染过程中冒出来的异常,同步地扔到浏览器的控制台上。
准备好了吗?让我们开始吧。
第一部分:是谁在控制你的应用?—— 协调器(The Coordinator)
首先,我们得搞清楚 React 到底是个什么东西。如果你只把它当成一个“写标签的库”,那你永远无法理解错误处理。React 是一个调度系统。
在 React 16 之前,渲染是同步的。这意味着如果你写了一个特别复杂的计算,或者一个无限循环的组件,整个浏览器界面就会卡死,直到渲染结束。这就像是你让一个厨师在厨房里只做一道菜,如果这道菜太难做,整个餐厅(浏览器)都得等着他,没人能点菜,没人能上菜,甚至没人能上厕所。
然后 React 16 来了,带来了 Fiber 架构。这玩意儿就像是引入了一个超级调度员。这个调度员手里拿了一个“时间切片”的计时器。他不会一口气把所有活儿干完,他会把工作切成一小块一小块,做完一块,就看看时间够不够,不够就暂停,去处理其他高优先级的事情(比如用户的鼠标点击、滚动)。
核心概念:渲染阶段 vs 提交阶段
这是理解错误处理的关键。React 把工作分成了两个阶段:
- 渲染阶段:这是协调器的工作。它的特点是同步的。它必须一口气算出最新的 DOM 状态。为什么?因为下一阶段(提交)需要基于这个结果。如果这里出错了,整个协调工作都得停下,不能留到下一个时间片。
- 提交阶段:这是把计算好的结果真正应用到 DOM 上。这个阶段通常是异步的(利用
requestIdleCallback或setTimeout)。
注意:我们今天要讨论的“渲染异常”,绝大多数发生在渲染阶段。因为那是同步的,一旦出错,整个流程就会断。
第二部分:当协调器崩溃时——同步异常的捕获
想象一下,协调器正在遍历你的组件树,就像一个导游带着游客参观景点。突然,在某个景点(比如 render() 函数里),导游踩到了地雷——抛出了一个异常。
因为渲染是同步的,这个异常会直接抛向调用栈。如果不加处理,浏览器会直接报错,页面白屏。React 怎么办?它必须立刻拦截这个异常,把游客(UI)撤回来,换上一块“安全区域”(错误边界),然后把这个异常告诉全局。
这个过程必须同步。你不能让协调器说:“哎呀,出错了,我等下一个时间片再处理吧。” 那时候用户已经看到空白了。
链路追踪:从 Fiber 到全局
让我们看看这根链条是怎么连接的。
1. 协调器的内部捕获
在 React 源码中,协调器(也就是 ReactFiberBeginWork 或 ReactFiberCommitWork 中的逻辑)在执行渲染时,会包裹在一个 try-catch 块中。或者更高级一点,它使用了一种基于优先级的调度机制。
当异常发生时,React 会做以下几件事:
- 中断调度:立即停止当前正在进行的渲染任务。
- 丢弃结果:因为渲染是同步的,刚才算出来的结果直接扔掉,不能用了。
- 调用错误处理回调:React 内部会调用一个类似于
onError的钩子函数。
2. unstable_runWithPriority 的魔法
你可能在 React 源码里见过这个函数:unstable_runWithPriority。这可是个狠角色。
当协调器捕获到一个渲染阶段的错误时,它会把这个错误处理的优先级提升到高。为什么?因为这是致命错误,必须马上处理!
// 模拟 React 内部逻辑(伪代码)
function performUnitOfWork(workInProgress) {
try {
// 尝试执行组件渲染
return renderComponent(workInProgress);
} catch (error) {
// 哎哟,出错了!
// 关键点:强制把当前执行上下文的优先级提升为高
// 这意味着,哪怕此时后台有个低优先级的任务(比如更新日志),也要暂停它
// 优先处理这个错误
unstable_runWithPriority(ImmediatePriority, () => {
// 调用全局错误处理器
onUncaughtError(error);
});
// 协调器会根据错误类型决定是报错还是显示 UI
// 如果有错误边界,就找最近的边界
return completeUnitOfWork(workInProgress, error);
}
}
3. 同步传播到 window.onerror
现在,异常已经到了 onUncaughtError 这个回调里。React 会尝试把这个异常同步地传递给全局的监听器。
在浏览器端,这通常意味着 React 会尝试调用 window.onerror。
// React 内部伪代码
function onUncaughtError(error) {
// 1. 获取堆栈信息(如果有的话)
const stack = error.stack;
// 2. 构造错误消息
const message = error.toString();
// 3. 同步调用全局监听器
// 注意:这里的调用是同步的,不经过事件循环
if (window.onerror) {
window.onerror(message, '', 0, 0, error);
}
// 4. 触发自定义事件,方便开发者监听
const event = new ErrorEvent('error', {
message: message,
error: error,
filename: '',
lineno: 0,
colno: 0
});
window.dispatchEvent(event);
}
这就是为什么有时候你明明在 componentDidCatch 里没捕获到,但控制台却报了错的原因。 window.onerror 捕获的是渲染阶段的同步错误,而 componentDidCatch 只能捕获提交阶段(异步)的错误。它们是两个不同的管道。
第三部分:异步错误的陷阱——Promise 和 setTimeout
好了,渲染阶段的同步错误我们已经搞定了。但是 React 的世界不仅仅有同步代码。
如果你在组件里写了这样的代码:
class MyComponent extends React.Component {
componentDidMount() {
// 这是一个异步操作!
setTimeout(() => {
// 1秒后,这里抛出错误
throw new Error("我来自异步地狱!");
}, 1000);
}
render() {
return <div>Hello</div>;
}
}
当你点击这个组件,1秒钟后,你的控制台会报错:Uncaught Error: 我来自异步地狱!。
React 的协调器(渲染阶段)完全不知道这件事。因为 setTimeout 把执行推迟到了下一个事件循环。此时,协调器早就完成了它的任务,离开现场了。
如何捕获异步错误?
这里有两个层级:
- Promise 错误:如果你在渲染中使用了
fetch或者async/await,Promise 拒绝了,React 不会自动捕获。你需要window.addEventListener('unhandledrejection', ...)。 - 定时器/事件监听器错误:同样,React 也管不着。
React 提供了一个机制叫 getDerivedStateFromError,但这通常用于错误边界,而错误边界本身也是一个组件,它的渲染也是同步的。所以,如果 getDerivedStateFromError 调用的函数(比如 fetch)抛出了错误,错误边界也救不了它,因为那个错误已经逃逸到了全局。
第四部分:实战集成——打造你的全局“防弹衣”
现在,让我们把上面的理论变成代码。作为资深专家,我建议你不要只依赖 React 内置的错误边界,因为它们有时候太“安静”了,或者只能捕获子树错误,捕获不到全局的未处理 Promise 错误。
我们需要构建一个全能型的全局错误处理器。
步骤 1:监听同步错误(渲染阶段)
这是最关键的,用来防止白屏。
// global-error-handler.js
/**
* 捕获渲染阶段的同步错误
* 这些错误通常会导致白屏,所以必须优先处理
*/
function setupSyncErrorCapturing() {
// 方法 A:直接覆盖 window.onerror
// 注意:这种方式在某些极端情况下可能会有兼容性问题,但最直接
window.onerror = function(message, source, lineno, colno, error) {
console.group('🔴 [全局同步错误捕获] Render Phase Error');
console.error('Message:', message);
console.error('Stack:', error?.stack);
console.error('Line:', lineno, 'Col:', colno);
console.groupEnd();
// 这里可以发送到你的错误监控服务,比如 Sentry
// reportErrorToSentry(error, { type: 'sync_error' });
// 返回 true 表示已经处理了,防止浏览器控制台再重复报一次(视情况而定)
return false;
};
// 方法 B:使用 addEventListener (更现代,推荐)
window.addEventListener('error', (event) => {
// event.error 指向抛出的 Error 对象
console.group('🔴 [全局同步错误捕获] Event Error Listener');
console.error('Error Object:', event.error);
console.groupEnd();
// 我们可以阻止默认行为吗?对于 render phase 的错误,通常不需要,
// 因为浏览器控制台本来就是看报错的地方。
}, true); // 使用捕获阶段,确保最早拦截
}
// 步骤 2:监听异步错误(Promise rejection)
function setupAsyncErrorCapturing() {
window.addEventListener('unhandledrejection', (event) => {
console.group('🟠 [全局异步错误捕获] Unhandled Promise Rejection');
console.error('Reason:', event.reason);
console.error('Promise:', event.promise);
console.groupEnd();
// event.preventDefault() 可以阻止浏览器控制台的默认报错
event.preventDefault();
});
}
// 步骤 3:集成 React 的错误边界逻辑
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
// 静态方法:当子组件抛出错误时调用
static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能显示降级后的 UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.group('🟢 [组件级错误捕获] componentDidCatch');
console.error('Error:', error);
console.error('Error Info:', errorInfo);
console.groupEnd();
// 这里可以上报给 Sentry
// Sentry.withScope(scope => {
// scope.setExtras(errorInfo);
// Sentry.captureException(error);
// });
}
render() {
if (this.state.hasError) {
// 返回你的错误 UI,而不是让页面崩溃
return <div className="error-boundary">哎呀,出错了!请刷新页面。</div>;
}
return this.props.children;
}
}
// 初始化
setupSyncErrorCapturing();
setupAsyncErrorCapturing();
// 使用
ReactDOM.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
document.getElementById('root')
);
第五部分:深入源码——协调器如何处理“致命”错误
光写代码还不够,我们要懂原理。让我们看看 React 源码(简化版)中,协调器是如何处理错误的。
在 ReactFiberBeginWork 中,每个 Fiber 节点的工作单元都有一个标志位:DidCapture。
// ReactFiberBeginWork.js (概念性伪代码)
function beginWork(current, workInProgress, renderLanes) {
try {
// 尝试渲染当前节点
switch (workInProgress.tag) {
case HostComponent:
// ... 处理 DOM 节点
break;
case FunctionComponent:
// 这是核心!函数组件的渲染
workInProgress.memoizedProps = renderWithHooks(
workInProgress,
workInProgress.type,
workInProgress.pendingProps,
workInProgress.ref,
workInProgress.context
);
break;
// ... 其他类型
}
} catch (thrownValue) {
// 抛出异常了!
// 1. 处理 Throw 路径
// 如果错误是 Error 对象,处理堆栈;如果是普通对象,包装一下
const error = thrownValue instanceof Error ? thrownValue : new Error(thrownValue);
// 2. 标记当前 Fiber 为捕获状态
workInProgress.flags |= DidCapture;
// 3. 更新状态
workInProgress.updateQueue = null;
// 4. 调用处理错误的逻辑
// 这一步非常关键,它会决定是报错还是走错误边界
return unwindWork(workInProgress, error);
}
return null;
}
function unwindWork(workInProgress, error) {
// 如果当前组件有错误边界,就往上找
const returnFiber = workInProgress.return;
if (returnFiber !== null) {
// 如果找到了错误边界,React 会把错误传递给边界组件
// 边界组件的 getDerivedStateFromError 会被调用
if (returnFiber.tag === ClassComponent && returnFiber.type.prototype.isReactComponent) {
// 触发错误边界逻辑
return propagateError(returnFiber, error);
}
// 如果没找到边界,或者边界处理失败(比如边界内部又抛错),就上报全局
// 这时候就会触发我们前面说的 window.onerror
if (returnFiber.flags & ShouldCapture) {
// 抛出错误,让 React 内部的错误处理机制去捕获并同步到全局
throw error;
}
}
// 如果到了根节点还没处理完,那就是严重的系统级错误了
throw error;
}
重点来了:为什么是 throw error?
注意最后一步。协调器捕获了错误,处理了逻辑,然后再次抛出。这看起来很奇怪,不是吗?明明已经捕获了,为什么还要再扔出去?
因为 React 需要中断整个协调流程。throw 语句在 JavaScript 中是同步的,它会立即停止当前函数的执行,并沿着调用栈向上查找是否有 try-catch 块。如果到了根节点(rootFiber)还没有被 try-catch 捕获,那么这个错误就会“逃逸”出 React 的逻辑,变成一个普通的未捕获异常。
这时候,React 的顶层调度器会介入。它会调用 onUncaughtError,进而触发我们设置的 window.onerror。
第六部分:为什么你的错误边界有时候不工作?
这是面试中常考的坑。为什么有时候你写了 ErrorBoundary,页面还是白屏了?
原因很简单:错误边界只能捕获子组件树中的渲染错误。
- 自身错误:如果你在
ErrorBoundary的render方法里抛出错误,或者getDerivedStateFromError抛出错误,边界组件自己救不了自己。它变成了一个崩溃的边界。 - 异步错误:如前所述,
setTimeout、Promise、fetch里的错误,是捕获不到的。 - 事件监听器错误:你在
onClick里写的throw new Error,React 捕获不到,因为那是事件循环里的代码,不是渲染阶段。 - 服务端渲染 (SSR):错误边界只在客户端工作。SSR 渲染时的错误会直接导致 HTML 输出失败。
代码演示:无能为力的边界
class MyComponent extends React.Component {
state = { count: 0 };
render() {
// 这里抛出错误
if (this.state.count === 0) {
throw new Error("我在 render 里崩溃了!");
}
return <div>Safe</div>;
}
}
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
console.log("捕获到子组件错误:", error);
return { hasError: true };
}
render() {
// 如果这里也崩溃了,边界就失效了
if (this.state.hasError) {
throw new Error("我试图恢复页面,但我自己也挂了!");
}
return <MyComponent />;
}
}
// 结果:控制台会报两个错,页面白屏。
// 第一个错来自 MyComponent
// 第二个错来自 ErrorBoundary 自己
第七部分:终极集成方案——监听一切
为了真正成为“资深专家”,你不能只依赖 React 的默认行为。你需要构建一个“监听一切”的守护神。
这个守护神需要监听三个事件:
error(Synchronous): 捕获渲染阶段的同步错误。unhandledrejection(Async): 捕获未处理的 Promise 拒绝。uncaughtException(Node.js): 如果你在 Node.js 环境下运行(SSR),还要监听这个。
// advanced-error-handler.js
const setupGlobalErrorHandler = () => {
// 1. 监听同步错误
window.addEventListener('error', (event) => {
// 只有当 error 对象存在时才处理
if (event.error) {
const { message, stack } = event.error;
console.error('🔥 [Sync Error]', message, stack);
// 上报
// captureException(event.error, { level: 'error' });
}
}, true); // capture phase
// 2. 监听异步错误
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
console.error('🚀 [Async Error]', reason);
// 阻止浏览器默认行为(控制台报红)
event.preventDefault();
// 上报
// captureException(reason, { level: 'error' });
});
// 3. 监听全局未捕获异常 (Node.js 或某些特定环境)
// 注意:在浏览器中,这通常被 error 事件覆盖,但保留是好的习惯
process.on('uncaughtException', (err) => {
console.error('💀 [Uncaught Exception]', err);
// 在 Node.js 中,这通常会导致进程退出,所以通常需要重启或做特殊处理
});
process.on('unhandledRejection', (reason, promise) => {
console.error('💥 [Unhandled Rejection]', reason);
});
};
export default setupGlobalErrorHandler;
第八部分:性能与调试——不要让错误处理器拖慢你的应用
最后,作为一个负责任的专家,我要提醒你:不要在错误处理器里做重计算。
想象一下,你的代码里有个逻辑错误,导致渲染失败,然后你抛出错误,错误处理器被触发,错误处理器里又去 JSON.parse 一个巨大的 JSON 文件,或者发起了一个昂贵的网络请求。
这会怎样?这会导致错误处理器本身也崩溃,或者导致页面彻底卡死。因为渲染阶段的错误处理是高优先级的,它不能阻塞。
最佳实践:
- 只做日志和上报:记录堆栈,发送到 Sentry/LogRocket。
- 不要做 UI 更新:不要在
window.onerror里试图动态修改 DOM(比如显示一个弹窗),因为这时候 DOM 可能还没挂载好,或者处于不稳定状态。 - 避免同步操作:不要在错误处理器里写同步的
while(true)或者复杂的数学计算。
结语:掌控混乱的艺术
好了,同学们。我们今天走了一条很长的路。
我们从 React 协调器的 Fiber 架构开始,理解了为什么渲染是同步的;我们追踪了错误从组件抛出,经过协调器的捕获、中断、优先级提升,最终同步传播到 window.onerror 的全过程;我们区分了同步错误和异步错误的区别,并构建了全链路的全局错误处理器。
React 的错误处理并不简单。它不是简单的“try-catch”,而是一个复杂的调度系统。当你看到控制台里那一行红色的错误信息时,不要只觉得它是“报错”,你要看到它背后那个忙碌的协调器,那个试图把应用从崩溃边缘拉回来的挣扎。
掌握这些知识,你就能写出更健壮的代码,也能更精准地定位那些藏在代码深处的 Bug。现在,去把你的 ErrorBoundary 换成我上面写的那个全能型版本吧,然后去拥抱那些可能会报错的代码!
下课!