我不知道的 React(11)— ErrorBoundary 的工作原理与局限性
React 应用中,一个子组件的渲染错误如果没有被捕获,会导致整棵组件树卸载——也就是白屏。从 React 16 开始,这个行为是刻意设计的:React 团队认为,显示一个损坏的 UI 比完全不显示更危险,因为损坏的 UI 可能导致用户做出错误操作(比如支付页面上金额显示错误)。
React 应用中,一个子组件的渲染错误如果没有被捕获,会导致整棵组件树卸载——也就是白屏。从 React 16 开始,这个行为是刻意设计的:React 团队认为,显示一个损坏的 UI 比完全不显示更危险,因为损坏的 UI 可能导致用户做出错误操作(比如支付页面上金额显示错误)。
ErrorBoundary 是 React 提供的错误隔离机制。但很多人对它的理解停留在”捕获错误、显示兜底 UI”这个层面,实际上它有一条非常明确的能力边界——有些错误它能捕获,有些它根本捕获不了,而这条边界是由 React 的渲染机制决定的。
一、ErrorBoundary 的实现:两个生命周期方法
ErrorBoundary 不是一个特殊的 API 或组件类型,它就是一个普通的类组件,只要实现了 static getDerivedStateFromError 或 componentDidCatch(或两者都有),就成为了一个 ErrorBoundary。
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('捕获到错误:', error);
console.error('组件栈:', errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>页面出了点问题</h2>;
}
return this.props.children;
}
}
这两个方法的分工有一个很多人会忽略的细节。
getDerivedStateFromError 在 React 的渲染阶段(Render Phase)调用。它是一个静态方法、纯函数,唯一的作用是根据错误返回一个新的 state,触发 re-render 来显示兜底 UI。它不能包含副作用——不能调 API、不能打日志、不能操作 DOM。
componentDidCatch 在提交阶段(Commit Phase)调用,此时兜底 UI 已经渲染到了 DOM 上。这里才是执行副作用的地方——上报错误到监控平台、打印日志、记录用户上下文信息。
说白了,getDerivedStateFromError 负责”决定渲染什么”,componentDidCatch 负责”渲染完之后做什么”。这个分工和 React Fiber 架构的两阶段设计是一致的(还记得第 02 篇讲的渲染阶段和提交阶段吗?)。渲染阶段可能被暂停和恢复(并发模式下),所以不能有副作用;提交阶段是一次性完成的,适合执行副作用。
二、错误冒泡的路径
当一个子组件在渲染期间抛出错误时,React 的 Fiber 协调器会捕获这个错误,然后沿着 Fiber 树的 return 指针(指向父 Fiber 节点)向上查找,找到最近的 ErrorBoundary。如果找到了,调用它的 getDerivedStateFromError 和 componentDidCatch,让它渲染兜底 UI。
如果一直向上找都没有找到 ErrorBoundary,整个 React 应用会被卸载。 所以在生产环境中,至少应该在应用的最外层包一个 ErrorBoundary,作为最后的安全网。
下面这个嵌套 ErrorBoundary 的例子可以看出错误隔离的效果:
function App() {
return (
<ErrorBoundary>
<Header />
<ErrorBoundary>
<MainContent />
</ErrorBoundary>
<Footer />
</ErrorBoundary>
);
}
如果 MainContent 里的某个组件出错了,内层的 ErrorBoundary 会捕获它,只有 MainContent 区域显示兜底 UI,Header 和 Footer 不受影响。如果 Header 出错了,错误冒泡到外层 ErrorBoundary,整个页面显示兜底 UI。
ErrorBoundary 的粒度决定了错误隔离的范围。 粒度太粗(只有一个根级 ErrorBoundary),任何错误都会导致整页兜底。粒度太细(每个组件都包一个),代码冗余且维护成本高。实际项目中,按页面区域划分 ErrorBoundary(侧边栏、主内容区、导航栏)是一个比较平衡的策略。
三、ErrorBoundary 的盲区
ErrorBoundary 不是万能的。它有四种错误类型是捕获不了的,而且每一种捕获不了的原因都和 React 的渲染机制有关。
事件处理函数中的错误
function Button() {
function handleClick() {
throw new Error('点击出错了');
}
return <button onClick={handleClick}>点击</button>;
}
这个错误不会被 ErrorBoundary 捕获。原因在于:事件处理函数不在 React 的渲染流程中执行。 handleClick 是在用户点击后、浏览器的事件调用栈中执行的,此时 React 的渲染阶段早已结束。ErrorBoundary 监听的是渲染阶段(render、构造函数、生命周期方法)中抛出的同步错误,事件回调不在这个范围内。
处理方式是在事件处理函数内部用 try...catch:
function Button() {
const [error, setError] = useState(null);
function handleClick() {
try {
riskyOperation();
} catch (err) {
setError(err);
}
}
if (error) {
return <p>操作失败: {error.message}</p>;
}
return <button onClick={handleClick}>点击</button>;
}
异步代码中的错误
setTimeout、Promise.then、async/await 中的错误同样不会被捕获。原因相同——异步回调的执行时机脱离了 React 的渲染调用栈。当 Promise reject 时,React 的渲染阶段已经结束了,ErrorBoundary 无从介入。
useEffect(() => {
fetchData()
.then((data) => setData(data))
.catch((err) => setError(err));
}, []);
异步错误的处理只能靠 try...catch 或 .catch(),配合 state 来驱动错误 UI 的显示。
ErrorBoundary 自身的错误
如果 ErrorBoundary 的 render 方法或 getDerivedStateFromError 本身抛出了错误,这个错误不会被自己捕获——否则会陷入无限循环。错误会继续向上冒泡,由更上层的 ErrorBoundary 处理。这也是为什么 ErrorBoundary 的代码应该尽量简单——兜底 UI 本身不应该再出错。
服务端渲染期间的错误
ErrorBoundary 是客户端的机制。SSR 过程中的错误需要在服务端的 renderToString / renderToPipeableStream 外层用 try...catch 捕获。
四、为什么 ErrorBoundary 只能用类组件
这是一个高频问题:React 都 Hooks 时代了,为什么 ErrorBoundary 还只能用类组件?
答案是:getDerivedStateFromError 和 componentDidCatch 没有对应的 Hook 版本。 React 团队目前没有提供 useErrorBoundary 这样的内置 Hook。
原因可能和 Hooks 的执行模型有关。Hooks 在函数组件的函数体中执行,而错误捕获需要在子组件的渲染过程中介入。类组件的生命周期方法是由 React 的 Fiber 协调器在特定阶段调用的,错误捕获可以自然地嵌入到这个流程中。函数组件的 Hook 模型要实现相同的功能,需要不同的设计思路,React 团队可能还在寻找最优方案。
实际项目中的解决办法是使用 react-error-boundary 这个库。它在内部仍然用类组件实现错误捕获,但对外暴露了函数式的 API:
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>出错了: {error.message}</p>
<button onClick={resetErrorBoundary}>重试</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// 重置应用状态
}}
>
<MainContent />
</ErrorBoundary>
);
}
resetErrorBoundary 可以清除错误状态,让 ErrorBoundary 重新渲染子组件树,实现”重试”功能。这比手写类组件的错误恢复逻辑简洁得多。
五、错误上报:从捕获到监控
在生产环境中,仅仅显示兜底 UI 是不够的。componentDidCatch 提供了 errorInfo.componentStack——一个包含错误发生处组件调用栈的字符串,这对于定位问题极其有价值。
componentDidCatch(error, errorInfo) {
reportToSentry({
error,
componentStack: errorInfo.componentStack,
userContext: getCurrentUserInfo(),
url: window.location.href,
});
}
componentStack 长这样:
in BrokenComponent (at App.js:12)
in ErrorBoundary (at App.js:8)
in div (at App.js:7)
in App (at index.js:6)
它展示了从出错组件到根组件的完整路径,和 JavaScript 的 Error stack 互补——Error stack 告诉你哪行代码抛了异常,componentStack 告诉你这行代码在哪个组件层级的上下文中。两者结合,才能快速定位生产环境中的渲染错误。
六、总结
ErrorBoundary 是 React 的错误隔离机制,通过 getDerivedStateFromError(渲染阶段,决定渲染什么)和 componentDidCatch(提交阶段,执行副作用)两个生命周期方法实现。错误沿着 Fiber 树向上冒泡,由最近的 ErrorBoundary 捕获。
它的能力边界很明确:只能捕获子组件树在渲染阶段抛出的同步错误。事件处理函数、异步代码、ErrorBoundary 自身的错误,以及 SSR 期间的错误,都不在它的捕获范围内。理解这条边界,才能设计出完善的错误处理策略——ErrorBoundary 负责渲染错误,try...catch 负责其余的,两者配合才是完整的方案。
本系列其他文章:
- 上一篇:受控与非受控组件的选择之道
- 下一篇:Redux 核心原理与中间件机制
相关主题:
- Fiber 架构的两阶段设计:可中断渲染与优先级调度
- 生命周期与 Render 触发机制:React-04