一个页面崩了,整站都打不开:React 错误边界为什么需要会"复位"

前言:一个看似正常的 ErrorBoundary

我们项目里有一个很常规的 React 错误边界组件,大概长这样:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <Empty description='页面渲染出错' />;
    }
    return this.props.children;
  }
}

它被放在最外层布局里,包裹了整个 <App />

<ErrorBoundary>
  <App />
</ErrorBoundary>

<App /> 内部是 React Router 的 <Routes>,根据 URL 渲染不同页面。

看起来很合理,对不对?哪个页面崩了,就显示一个友好的兜底 UI,不会白屏。

现象:一旦崩一次,整站就废了

实际跑起来后,用户反馈了一个很奇怪的 bug:

我去 A 页面报错了,看到一个"刷新页面"的提示。然后我点导航去 B 页面,URL 变了,但页面还是那个错误提示。所有页面都打不开了,必须 F5 才能恢复。

为什么会这样?

要理解这个问题,得先想清楚两件事。

第一件:错误边界是个"开关"

ErrorBoundary 本质上是一个状态机:

  • hasError = false → 渲染子组件
  • hasError = true → 渲染兜底 UI

而把它从 true 拨回 false 的操作,必须由组件自己做。React 不会帮你做。

原始代码里压根没人写这个"拨回去"的逻辑。所以 hasError 一旦变成 true,就再也不会变回来了。

第二件:路由切换不会重新挂载父组件

很多人会以为"切换路由 = 整个页面重新加载",但 React Router 不是这样工作的。

  • 它是单页应用,URL 变了只是 React 重新渲染。
  • <Routes> 内部的页面组件会换,但 <Routes> 外面的所有组件(包括我们的 ErrorBoundary只是 re-render,不会 unmount/mount
  • 不重新挂载 = 不会走 constructor = state 不会被重置。

把这两件事放一起,bug 就很清晰了:

A 页面崩了 → hasError = true → 切到 B → ErrorBoundary 没重新挂载,hasError 还是 true → B 也显示错误页 → 所有页面都"中招"。

这是一个挺典型的"状态没跟着上下文一起重置"的坑。

怎么修?

修复思路其实很朴素:告诉 ErrorBoundary,什么时候应该把自己重置

业界有个成熟方案,来自 react-error-boundary 这个库,叫 resetKeys

父组件传一组"复位钥匙"进来。每次 render,ErrorBoundary 拿新旧 key 比一下。只要有任何一个变了,就把 hasError 清掉。

对我们来说,最自然的"key"就是 location.pathname:路径变了,意味着用户已经切到别的页面,旧错误就不该再卡着了。

改造后的 ErrorBoundary

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      prevResetKeys: props.resetKeys || [],
    };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  // 关键:在渲染前比对 resetKeys,发现变化就重置
  static getDerivedStateFromProps(nextProps, prevState) {
    const nextKeys = nextProps.resetKeys || [];
    const prevKeys = prevState.prevResetKeys || [];

    const changed =
      nextKeys.length !== prevKeys.length ||
      nextKeys.some((k, i) => !Object.is(k, prevKeys[i]));

    if (changed) {
      return { hasError: false, prevResetKeys: nextKeys };
    }
    return null;
  }

  // ... render 部分,再加一个"重试"按钮,避免每次都得整页刷新
}

在外层把 pathname 喂进去

const location = useLocation();

<ErrorBoundary resetKeys={[location.pathname]}>
  <App />
</ErrorBoundary>

就这两行改动,bug 就修好了。用户切路由 → pathname 变 → ErrorBoundary 自动重置 → 新页面正常渲染。

几个细节,值得多说两句

为什么用 getDerivedStateFromProps 而不是 componentDidUpdate

如果你在 componentDidUpdatesetState({ hasError: false }),会经历:

  1. 路由变了,组件先用 hasError=true 渲染了一次错误页
  2. componentDidUpdate 触发,setState
  3. 再渲染一次,才显示真正的页面

也就是会有一帧的错误页闪烁

getDerivedStateFromProps 在渲染前就能把 state 改好,下一次渲染直接是正常页面,没有闪烁。这是 react-error-boundary 库一直在用的标准做法。

为什么用 pathname 而不是整个 location?

location 对象每次都是新的引用,用它做 key 会导致每次 render 都触发重置——一旦 ErrorBoundary 想兜底,会被立刻"原谅",跟没有兜底差不多。

只看 pathname 这种字符串,能精确表达"用户换了一个页面"的语义。

顺手加了个"重试"按钮

原版只有"刷新页面",很重。现在加了"重试",调用 setStatehasError 拨回来,子树会重新尝试渲染。如果是临时性错误(比如某个接口慢了一下导致渲染异常),点一下就能恢复,不必整页刷新丢掉所有状态。

最后

这个 bug 修起来代码很短,但背后藏着两个常被忽略的概念:

  1. 错误边界是有状态的,状态需要被显式管理。React 不会替你判断"什么时候该重新尝试渲染"。
  2. SPA 里组件的生命周期 ≠ 用户感知的页面生命周期。用户觉得自己"换了一个页面",但对 React 来说,外层组件可能根本没动过。任何"跟随路由的状态"都需要你自己去同步。

下次写错误边界的时候,记得问一句:它什么时候应该重置?

如果答不上来,那它大概率就藏着这个 bug。

posted @ 2026-05-28 09:37  牛奔  阅读(5)  评论(0)    收藏  举报