知

模块二 · Hooks 与数据

第 9 / 18 节19 分钟阅读型

错误边界、lazy 与 Suspense:让失败可控

渲染错误和接口错误不是一类问题。这一节讲清错误边界能捕获什么、代码分割的加载态怎么不闪烁,以及 Suspense 的边界该放在哪一层。

读完这一节,你能做到

  1. 区分渲染错误、事件错误与异步错误,分别用对应手段处理。
  2. 写一个可恢复的错误边界,而不是整页白屏。
  3. 用 lazy + Suspense 做代码分割,并处理加载与失败两种状态。
  4. 判断 Suspense 边界该放页面级还是区块级。

一、三类错误,三套处理

错误来源例子能否被错误边界捕获正确做法
渲染期组件里读 undefined.id能错误边界 + 降级 UI
事件回调点击后 JSON.parse 失败不能try/catch
异步 Promise请求 reject、setTimeout 抛错不能catch 或全局监听

很多人以为加了错误边界就"全覆盖",实际上它只包住渲染、生命周期与子组件构造这三类同步错误。事件与异步必须自己处理。

另外两类也捕获不到:

  • 服务端渲染中的错误(需要框架层处理)。
  • 错误边界自身抛出的错误(会向上找下一个边界,找不到就白屏)。

二、错误边界的实现

截至 React 19,错误边界仍然只能是类组件,因为需要 getDerivedStateFromError 与 componentDidCatch 两个静态/生命周期方法。

import { Component, type ErrorInfo, type ReactNode } from "react";

type Props = {
  fallback: (reset: () => void, error: Error) => ReactNode;
  children: ReactNode;
};
type State = { error: Error | null };

export class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    report(error, info.componentStack); // 上报,不要在这里 setState
  }

  reset = () => this.setState({ error: null });

  render() {
    if (this.state.error)
      return this.props.fallback(this.reset, this.state.error);
    return this.props.children;
  }
}

两个方法的职责不同:

  • getDerivedStateFromError:纯函数,只负责把错误写进 state,触发降级 UI。
  • componentDidCatch:副作用位置,适合上报与日志,不能在这里调 setState 做降级(会多一次渲染,且可能循环)。

实际项目可以直接用 react-error-boundary,它封装了 resetKeys、onReset 等能力,省掉手写类组件。

三、边界放在哪一层

粒度位置效果代价
应用级根组件外不白屏,但整页降级一处出错全站不可用
路由级每个路由外层单页失败不影响导航需要路由配合
区块级图表、评论区等独立模块局部降级,其余可用边界多了要管理

实践建议:应用级保底 + 区块级降级。核心业务区块(支付、下单)不要静默降级成"加载失败",要让用户明确知道操作没完成。

一个常见误区:给每个按钮都包错误边界。边界本身有成本,且按钮的错误应该被 try/catch 处理掉,而不是靠边界兜底。

四、lazy 与 Suspense:代码分割

import { lazy, Suspense } from "react";

const HeavyChart = lazy(() => import("./HeavyChart"));

<Suspense fallback={<ChartSkeleton />}>
  <HeavyChart data={data} />
</Suspense>;

三个要点:

  1. lazy 只接受返回 Promise 的函数,且导出必须是默认导出(或 then 转成默认)。
  2. 没有 Suspense 包裹会直接报错,不会自动降级。
  3. 加载失败时 lazy 组件会抛错,需要外层错误边界接住。
const Chart = lazy(() =>
  import("./HeavyChart").catch(() => ({ default: () => <ErrorBox /> })),
);

这种写法能降级,但会吞掉错误上报。更稳的是交给错误边界,同时保留重试入口。

五、加载态不闪烁的两个技巧

技巧一:延迟显示 fallback。快速加载时不要闪一下骨架屏。Suspense 本身没有 delay 参数,可以用 CSS 动画延迟:

.skeleton {
  animation: fade-in 0.2s ease 0.3s both;
}

技巧二:预加载。用户 hover 或进入视口时提前触发 import:

const preload = () => import("./HeavyChart");
<button onMouseEnter={preload} onClick={() => setOpen(true)}>
  打开图表
</button>;

这样点击时模块大概率已在缓存里,fallback 根本不会出现。

六、Suspense 的数据获取边界

<Suspense> 捕获的是"子树抛出的 Promise"。普通 useEffect 里的请求不会抛 Promise,所以 fallback 永远不会出现——这是最常见的误解。

目前能让 Suspense 真正生效的方式有三种:

方式说明适用
框架数据加载Next.js 的 Server Component / loader使用元框架
支持 Suspense 的请求库如 TanStack Query 的 useSuspenseQuerySPA 也要声明式加载态
React 19 的 use()在组件里读取 Promise实验/新项目
const { data } = useSuspenseQuery({
  queryKey: ["orders"],
  queryFn: fetchOrders,
});

用 useSuspenseQuery 后,加载态交给 Suspense,错误交给 ErrorBoundary,组件里不再出现 if (isPending) 分支。这是"声明式加载态"的核心收益。

七、边界的粒度怎么定

<App>
  <ErrorBoundary>            ← 应用级保底
    <Suspense fallback={页面骨架}>
      <Layout>
        <ErrorBoundary>      ← 区块级
          <Suspense fallback={图表骨架}>
            <HeavyChart />
          </Suspense>
        </ErrorBoundary>
      </Layout>
    </Suspense>
  </ErrorBoundary>
</App>

判断标准:这个区域加载慢/出错时,其他区域是否还应该可用。

  • 页面主体:整页骨架 + 页面级边界。
  • 侧边图表、评论区:局部骨架 + 局部边界。
  • 按钮级:不要包,用 try/catch。

边界太细会让加载态频繁闪烁;太粗会让一个慢模块拖住整页。

Portal:把弹窗渲染到父组件之外

弹窗、下拉菜单、全屏遮罩都有一个共同问题:父级有 overflow: hidden 或 transform 时,子元素会被裁切或定位错乱。Portal 让 React 把子节点渲染到 DOM 树的另一个位置,但组件树关系保持不变。

import { createPortal } from "react-dom";

function Modal({ open, children }) {
  if (!open) return null;
  return createPortal(
    <div className="fixed inset-0 z-50 grid place-items-center bg-black/40">
      {children}
    </div>,
    document.body,
  );
}

三个要点

  1. DOM 位置变了,React 树没变:事件仍按组件树冒泡,父组件的 Context 也照样能拿到。
  2. 样式隔离要自己做:Portal 出来的节点不在原父级里,父级的 CSS 变量、字体与作用域样式都不再生效。
  3. 主要用途是绕开层叠上下文:父级有 transform、filter 时会创建新的层叠上下文,z-index 再大也盖不住,挂到 document.body 才能解决。

什么时候不该用:只是想让元素脱离文档流,position: fixed 就够了;Portal 解决的是"父级环境不允许"的问题。

八、常见坑

  1. 以为错误边界能捕获事件回调里的错误。
  2. 在 componentDidCatch 里 setState 做降级,触发额外渲染甚至循环。
  3. lazy 的模块用了具名导出,运行时报"没有默认导出"。
  4. lazy 外层没有 Suspense,直接崩溃。
  5. 用 <Suspense> 包普通 useEffect 请求,fallback 从不出现。
  6. 每个组件都包边界,代码噪音大于收益。
  7. 加载失败没有重试入口,用户只能刷新整页。

九、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 ErrorBoundary,支持 reset 按钮,并验证点击后能恢复。
  2. 把一个重型组件改成 lazy,加 Suspense 与错误边界,断网验证降级 UI。
  3. 用 onMouseEnter 预加载,对比首次打开时 fallback 是否出现。
  4. 给一个列表页设计边界层级:页面级、筛选区、列表区,写出各自降级后的表现。

② 让 AI 写同一题

把第 1 题交给 AI,补一句 只用类组件实现,不引第三方库。

③ 对比要盯的点

  • reset 它是靠换 key,还是把 state 置回初始值?后者必须确认子树真的重建了。
  • 它有没有暗示“能捕获请求里的错误”?捕获不到,异步错误必须自己 try/catch。
  • componentDidCatch 里它记日志了吗?只写 getDerivedStateFromError 会丢掉错误详情。

④ 一句话取舍

它给的边界能兜住“按钮点击时抛错”吗?兜不住的部分该由谁负责?

十、自测

  1. 错误边界能捕获哪三类错误,捕获不到哪三类?
  2. getDerivedStateFromError 与 componentDidCatch 的分工是什么?
  3. lazy 为什么要求默认导出?
  4. 为什么 Suspense 捕获不到 useEffect 里的请求?
  5. 边界粒度按什么标准定?
  6. Portal 改变了什么、没改变什么?为什么弹窗需要它?

参考答案要点:渲染/生命周期/构造;事件、异步、自身错误;默认导出才能直接作为组件类型;没有抛 Promise;加载慢或出错时其他区域是否还应可用;改变了 DOM 挂载位置,没改变 React 组件树与事件冒泡,弹窗需要它绕开父级的 overflow 与层叠上下文。

附:错误上报与边界的配合

错误边界负责“界面不白屏”,上报负责“下次能修”。两者要配合,不能互相替代。

错误类型边界处理上报方式
渲染错误降级 UI + 重试按钮componentDidCatch 上报 componentStack
事件错误局部提示,不降级整页try/catch 后手动上报
请求错误错误态 + 重试请求层统一上报
资源加载失败占位图/骨架window 捕获阶段监听
未处理 Promise不影响渲染unhandledrejection

上报内容至少包含五项:错误信息、堆栈、组件栈、用户操作路径、版本号。只有 message 的上报在线上几乎无法定位,因为压缩后的代码全是单字母变量。

三条实践:

  1. 上报要限流。一个渲染错误可能在循环里触发上百次,去重键用“错误信息 + 首行堆栈”。
  2. 降级 UI 要给出路。只显示“出错了”等于让用户刷新;提供“重试”与“返回列表”两个动作,转化率差别很大。
  3. 不要把错误边界当兜底逻辑。已知的失败分支(接口 404、无权限)应该在业务层处理,边界只处理“没预料到的崩溃”。

还有一个容易踩的细节:componentDidCatch 里调 setState 会再触发一轮渲染,可能引发循环。需要“重试”时,用边界自身的 reset 方法重置 state,而不是在上报回调里改业务状态。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解