React 中的错误边界是如何实现的?
来自西伯利亚的大家好❄!
TLDR这篇文章不是关于如何使用错误边界,而是关于为什么我们需要在 React 应用中使用它。
假设你正在编写一个基于 React 的评论应用。当用户打开评论列表并点击“撰写评论”按钮时(会弹出一个“输入邮箱”的窗口),但用于验证邮箱的代码存在 bug!结果,页面显示一片空白。由于弹出窗口中的某个地方存在 bug,React 无法渲染任何内容。
首先想到的可能是“我们可以让列表一直显示在屏幕上!”。列表本身并没有错误。所以,在 React 中,你必须使用错误边界来捕获和处理渲染阶段的任何错误,以防止错误传播。然而,最关键的问题是——为什么只能用这种方式?这篇文章是为那些求知欲旺盛的开发者准备的。让我们一起来探究一下。
try/catch 即将发挥作用
好的,我们先从简单的开始。如果有人问你如何在 JavaScript 中捕获和处理错误,你会毫不犹豫地回答说,可以使用 try/catch 代码块:
try {
throw new Error('Hello, World! My name is error!');
} catch (error) {
console.error(error);
}
让我们在浏览器的控制台中运行这段代码。我们会看到一条错误信息和一个错误堆栈。这是一个非常简单的概念,早在 1995 年就出现了。这里的一切都很容易理解。
现在,我们来聊聊 React。它的核心思想很简单:React 就像一个函数,它接受任何数据作为参数,并返回其可视化表示。类似这样:
function React(data) {
return UI;
}
const UI = React({ name: 'John' });
我知道,这看起来有点抽象,但目前来说足够了。看来我们可以把同样的错误处理方法应用到这里,这种方法在 JavaScript 代码中随处可见:
try {
const UI = React({ name: 'John' });
} catch (error) {
console.error(error);
}
一切看起来都没问题。我们来尝试在实际代码中实现它。
用尝试/捕捉法包裹世界
每个 React 应用都有一个“入口点”。我指的是 ReactDOM.render 方法。这个方法允许我们将应用渲染到特定的 DOM 节点中:
ReactDOM.render(
<App />,
document.getElementById("app")
);
对应用及其所有组件进行传统的同步渲染<App />。嗯,最好把我们的应用用 try/catch 语句包裹起来:
try {
ReactDOM.render(
<App />,
document.getElementById("app")
);
} catch (error) {
console.error("React render error: ", error);
}
第一次渲染期间抛出的所有错误都将由该 try/catch 语句处理。
但是,如果错误是在组件内部某个位置的状态改变过程中抛出的,那么 try/catch 语句就毫无用处了。`ReactDOM.render` 会被执行,它的工作已经完成——首次渲染到<App />DOM 中。其他一切都与 `ReactDOM.render` 无关。
这里有一个演示,您可以尝试这种方法。AppWithImmediateError.js 包含一个组件,该组件会在首次渲染时抛出错误。另一方面,AppWithDeferredError.js 包含一个组件,该组件会在内部状态改变时抛出错误。如您所见,我们实现的“全局 try/catch”只会处理来自 AppWithImmediateError.js 的错误。请查看控制台输出。
然而,这似乎并非一种主流方法。这只是第一个渲染示例。接下来会出现一些比较奇怪的例子。但它们对我们来说非常有用,因为它们会揭示 React 的一些特性及其内部机制。
顺便一提,从 React 18 开始,新的 ReactDom 的渲染方法不再是同步的了。所以,我们的方法即使是第一次渲染也行不通了。
组件内部的 try/catch
“全局 try/catch”是个很有趣的想法,但它行不通。因此,下一个概念是在每个组件内部使用 try/catch。这样做没有任何禁忌。让我们暂时抛开声明式编程、纯函数等等。JSX 语法允许我们在 render 方法内部使用 try/catch:
// We can use a class here too
// Just wrap an inner of the render method
const App = () => {
try {
return (
<div>
<ChildWithError />
</div>
);
} catch (error) {
console.error('App error handler: ', error);
return <FallbackUI/>;
}
}
这里还有另一个演示,展示了这种概念的实现。打开它,点击“增加值”按钮。当其中的值为<ChildWithError/>4 时,该组件会在渲染过程中抛出错误。但是控制台不会显示任何消息,也没有任何回退 UI。等等,什么?我们都知道:
<div>
<ChildWithError />
</div>
将成为
React.createElement(
'div',
null,
React.createElement(ChildWithError, null)
)
经过 Babel/TypeScript/其他工具处理后,所有 JSX 代码都会被转换为 React.createElement 执行。但这也就意味着,try/catch 必须处理所有错误。这是怎么回事?React 能否阻止 JS 函数的执行?
渲染内部发生了什么?
仔细观察你会发现,`React.createElement(ChildWithError, null)` 中并没有执行 `ChildWithError` 组件的渲染操作。但是等等,`React.createElement` 的执行结果是什么呢?如果你想查看源代码,可以点击这个链接。通常情况下,它会返回以下对象:
// The source: https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js#L148
const element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE, // Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props, // Record the component responsible for creating this element.
_owner: owner,
};
所以,会有一些对象嵌套在其他对象中。例如,我们得到一个描述对象<App />。该对象又包含一个嵌套对象,该嵌套对象<ChildWithError />位于该对象的 props.children 属性中<App />。你可以自己验证一下,只需尝试使用 console.log 输出结果即可。
这里没有 ChildWithError 的渲染函数执行。我们只是创建了一个 scheme,也就是一堆 React 指令。渲染操作从父组件到子组件执行。看起来我们是在和 React 对话:如果<App />组件被渲染,那么<ChildWithError />它也应该被渲染,就在组件内部<App />。
这就是 React 中声明式视图的主要思想。
现在你可能会说,我们需要执行 ChildWithError 的 render 函数来创建这样的对象。你说得完全正确!但是 ChildWithError 的 render 函数不会在 React 内部执行<App />。目前我可以这样说,React 会在它自己的上下文中调用所有 render 函数。稍后我会详细解释这个概念。
这里有个类比:componentDidUpdate 是在 React 渲染完成后执行的。或者还有另一个例子:
try {
Promise.resolve().then(() => {
throw new Error('wow!');
});
} catch (error) {
console.log('Error from catch: ', error);
}
Promise 抛出的错误不会被 try/catch 语句捕获,因为它会被抛入微任务队列。catch 语句处理的是同步调用栈队列。
对了,你可以自己验证一下。只需将 `<head>` 替换<ChildWithError />为 `{ChildWithError()}` 即可<App />。这意味着我们将手动调用 `ChildWithError` 的渲染函数。瞧!你会在控制台中看到错误信息,并在浏览器中看到备用界面!
为什么不到处都这样写呢?直接调用所有的渲染函数?这样速度应该更快,我们不需要等待 React 渲染完所有组件。
如果你有这样的想法,一定要读读 Dan Abaramov 的精彩文章—— 《React as a UI Runtime》。它或许能帮助你更深入地理解 React 的编程模型。强烈建议你仔细阅读文章中关于控制反转和惰性求值的部分。
有趣的是,不久前,手动执行组件曾被推荐为提升 React 应用性能的一种模式。但实际上,这种方法有时反而会破坏我们的应用:
function Counter() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return <button onClick={increment}>{count}</button>
}
function App() {
const [items, setItems] = React.useState([])
const addItem = () => setItems(i => [...i, {id: i.length}])
return (
<div>
<button onClick={addItem}>Add Item</button>
<div>{items.map(Counter)}</div>
</div>
)
}
以上代码提供了一个演示。首次点击“添加项目”按钮后,会出现钩子顺序错误。此示例取自 Kent C. Dodds 的文章《不要调用 React 函数组件》。
让我们回到 React 应用中的错误处理。我们知道,在 `render() {}` 中使用 `try/catch` 是不够的。在使用类组件时,我们还必须在所有生命周期方法中处理错误。这看起来并不明智。那么,结论是什么呢?是的,我们应该只使用函数式组件,因为在函数式组件中使用 `try/catch` 要容易得多 =)
“现实生活”示例
我这里有一个关于错误边界和经典 try/catch 的小演示。
这里我们看到的是一个函数式组件<App />,它有内部状态(通过 useState)。该状态的值通过 React.context 共享。<App />渲染<Child />。<Child />被 HOC memo 包裹。<Child />渲染<GrandChild />。
这里最有趣的地方在于内部的 try/catch 语句<Child />。我的想法是,这个 try/catch 语句必须处理来自 `.` 的所有错误<GrandChild />。并且<GrandChild />它有一套特定的逻辑,当上下文中的值大于 3 时抛出错误。这里有一个方案:
我在 `getDerivedStateFromError` 和 `componentDidCatch` 中都使用了 ` .` 方法<App />。这意味着<App />它被用作错误边界。
我们点击一个按钮。第一次点击后<App />,组件<GrandChild />会重新渲染。——<App />这是状态改变的原因, ——这是上下文值改变的原因。看起来组件之间<GrandChild />没有任何关联。这是因为高阶组件备忘录(HOC memo)的缘故。我们来高亮显示所有重新渲染的组件:<Child /><App /><GrandChild />
所以,如果我们继续将计数器增加<App />两倍,就会抛出一个错误<GrandChild />。但<Child />我们对 try/catch 语句周围的情况一无所知。
这个演示只是一个简单的模型,它说明了 React 如何决定渲染什么内容以及何时渲染。
顺便一提,我们刚才已经了解了如何使用错误边界。但我强烈建议您阅读文档。此外,这并不意味着 try/catch 完全没用。我们必须在以下情况下使用它:
- 事件处理程序
- 异步代码
- 错误边界本身抛出的错误
好的,接下来是最有趣的部分——我们来了解一下错误边界是如何工作的。它是一种特殊的 try/catch 语句吗?
React 的 try/catch
来认识一下神奇的 React Fiber 吧!它既是一种架构的名称,也是 React 内部一个实体的名称。顺便一提,你可以在React 文档中找到它,它是在 React 第 16 版发布之后推出的。
如果你记录 React.createElement 的执行结果,你会看到很多信息(这里只展示一部分):
这对我们意味着什么?除了组件的类型、属性等数据之外,还有来自 Fiber 节点的信息。这个节点与 React 组件相连,包含许多对 React 有用的组件信息:新旧属性、应该执行的 effect、组件是否应该立即重新渲染等等。您可以在inDepth.dev或acdlite(React 核心团队成员)的文章《React Fiber 架构》中了解更多关于 Fiber 架构的信息。
好的,React 知道每个组件的内部数据。这意味着,如果渲染阶段抛出任何错误,React 都知道该如何处理。React 可以停止当前树(而非某个组件!)的渲染。之后,React 会尝试找到抛出错误的组件的最近父组件,该父组件必须定义了 `getDerivedStateFromError` 或 `componentDidCatch` 方法(二者选其一)。这并不复杂,因为每个 Fiber 节点都链接到它的父 Fiber 节点。以下是其工作原理的源代码。
React 中的渲染过程用一段非常简单的代码——`workLoop`——来表示。正如你所看到的,并没有什么神奇之处,`workLoop` 被包裹在 `try/catch` 语句中。如果捕获到任何错误,React 会尝试查找错误边界内的组件。如果找到了这样的组件,意味着 React 可以只丢弃边界之前的组件树。
如果我们试着把使用 React 进行的工作想象成与真人对话,它看起来会像这样(“用五岁小孩都能听懂的方式解释”)。
Hi. My name is React.
Thanks for the instructions from JSX about what to render.
You can have a coffee, while I am doing my job.
try {
*React is working*
} catch (error) {
Oops, a new error.
Ok, I will try to find a parent of the component
with that error.
Maybe that parent can do something with it.
All other work will be saved!
}
信息
我认为,这类问题、奇特的实验等等,可以帮助你深入了解你所使用的技术。它可以帮助你真正理解如何运用它。或许你还能从中发现一些新的东西。我坚信,这样的探索之旅总会带来回报。
实用链接列表
- React 文档中的错误边界。
- React 作为 UI 运行时环境。本文将帮助您更深入地了解 React 编程模型。
- 不要将 React 函数称为组件。Kent C. Dodds 谈到手动执行组件。
- Facebook 开源
- Fiber 内幕:深入剖析 React 中新的协调算法。还有一篇:React 在 Fiber 中使用链表遍历组件树的原理及方法。这些文章专为资深爱好者而写。
- React Fiber架构。



