发布于 2026-01-06 0 阅读
0

React 中的错误边界是如何实现的?

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);
}
Enter fullscreen mode Exit fullscreen mode

让我们在浏览器的控制台中运行这段代码。我们会看到一条错误信息和一个错误堆栈。这是一个非常简单的概念,早在 1995 年就出现了。这里的一切都很容易理解。

现在,我们来聊聊 React。它的核心思想很简单:React 就像一个函数,它接受任何数据作为参数,并返回其可视化表示。类似这样:

function React(data) {
  return UI;
}

const UI = React({ name: 'John' });
Enter fullscreen mode Exit fullscreen mode

我知道,这看起来有点抽象,但目前来说足够了。看来我们可以把同样的错误处理方法应用到这里,这种方法在 JavaScript 代码中随处可见:

try {
  const UI = React({ name: 'John' });
} catch (error) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

一切看起来都没问题。我们来尝试在实际代码中实现它。

用尝试/捕捉法包裹世界

每个 React 应用都有一个“入口点”。我指的是 ReactDOM.render 方法。这个方法允许我们将应用渲染到特定的 DOM 节点中:

ReactDOM.render(
  <App />,
  document.getElementById("app")
);
Enter fullscreen mode Exit fullscreen mode

对应用及其所有组件进行传统的同步渲染<App />。嗯,最好把我们的应用用 try/catch 语句包裹起来:

try {
 ReactDOM.render(
  <App />,
  document.getElementById("app")
 );
} catch (error) {
 console.error("React render error: ", error);
}
Enter fullscreen mode Exit fullscreen mode

第一次渲染期间抛出的所有错误都将由该 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/>;
 }
}
Enter fullscreen mode Exit fullscreen mode

这里还有另一个演示,展示了这种概念的实现。打开它,点击“增加值”按钮。当其中的值为<ChildWithError/>4 时,该组件会在渲染过程中抛出错误。但是控制台不会显示任何消息,也没有任何回退 UI。等等,什么?我们都知道:

<div>
 <ChildWithError />
</div>
Enter fullscreen mode Exit fullscreen mode

将成为

React.createElement(
  'div', 
  null, 
  React.createElement(ChildWithError, null)
)
Enter fullscreen mode Exit fullscreen mode

经过 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,
};
Enter fullscreen mode Exit fullscreen mode

所以,会有一些对象嵌套在其他对象中。例如,我们得到一个描述对象<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);
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

以上代码提供了一个演示。首次点击“添加项目”按钮后,会出现钩子顺序错误。此示例取自 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 的执行结果,你会看到很多信息(这里只展示一部分):

React 组件实例内部结构

这对我们意味着什么?除了组件的类型、属性等数据之外,还有来自 Fiber 节点的信息。这个节点与 React 组件相连,包含许多对 React 有用的组件信息:新旧属性、应该执行的 effect、组件是否应该立即重新渲染等等。您可以在inDepth.devacdlite(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!
}
Enter fullscreen mode Exit fullscreen mode

信息

我认为,这类问题、奇特的实验等等,可以帮助你深入了解你所使用的技术。它可以帮助你真正理解如何运用它。或许你还能从中发现一些新的东西。我坚信,这样的探索之旅总会带来回报。

实用链接列表

文章来源:https://dev.to/artemmalko/error-boundaries-in-react-how-its-made-3lam