模块二 · Hooks 与数据
错误边界、lazy 与 Suspense:让失败可控
渲染错误和接口错误不是一类问题。这一节讲清错误边界能捕获什么、代码分割的加载态怎么不闪烁,以及 Suspense 的边界该放在哪一层。
读完这一节,你能做到
- 区分渲染错误、事件错误与异步错误,分别用对应手段处理。
- 写一个可恢复的错误边界,而不是整页白屏。
- 用
lazy+Suspense做代码分割,并处理加载与失败两种状态。 - 判断 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>;
三个要点:
lazy只接受返回 Promise 的函数,且导出必须是默认导出(或then转成默认)。- 没有
Suspense包裹会直接报错,不会自动降级。 - 加载失败时
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 的 useSuspenseQuery | SPA 也要声明式加载态 |
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,
);
}
三个要点
- DOM 位置变了,React 树没变:事件仍按组件树冒泡,父组件的 Context 也照样能拿到。
- 样式隔离要自己做:Portal 出来的节点不在原父级里,父级的 CSS 变量、字体与作用域样式都不再生效。
- 主要用途是绕开层叠上下文:父级有
transform、filter时会创建新的层叠上下文,z-index再大也盖不住,挂到document.body才能解决。
什么时候不该用:只是想让元素脱离文档流,position: fixed 就够了;Portal 解决的是"父级环境不允许"的问题。
八、常见坑
- 以为错误边界能捕获事件回调里的错误。
- 在
componentDidCatch里 setState 做降级,触发额外渲染甚至循环。 lazy的模块用了具名导出,运行时报"没有默认导出"。lazy外层没有Suspense,直接崩溃。- 用
<Suspense>包普通useEffect请求,fallback 从不出现。 - 每个组件都包边界,代码噪音大于收益。
- 加载失败没有重试入口,用户只能刷新整页。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个
ErrorBoundary,支持reset按钮,并验证点击后能恢复。 - 把一个重型组件改成
lazy,加Suspense与错误边界,断网验证降级 UI。 - 用
onMouseEnter预加载,对比首次打开时 fallback 是否出现。 - 给一个列表页设计边界层级:页面级、筛选区、列表区,写出各自降级后的表现。
② 让 AI 写同一题
把第 1 题交给 AI,补一句 只用类组件实现,不引第三方库。
③ 对比要盯的点
- reset 它是靠换
key,还是把 state 置回初始值?后者必须确认子树真的重建了。 - 它有没有暗示“能捕获请求里的错误”?捕获不到,异步错误必须自己
try/catch。 componentDidCatch里它记日志了吗?只写getDerivedStateFromError会丢掉错误详情。
④ 一句话取舍
它给的边界能兜住“按钮点击时抛错”吗?兜不住的部分该由谁负责?
十、自测
- 错误边界能捕获哪三类错误,捕获不到哪三类?
getDerivedStateFromError与componentDidCatch的分工是什么?lazy为什么要求默认导出?- 为什么
Suspense捕获不到useEffect里的请求? - 边界粒度按什么标准定?
- Portal 改变了什么、没改变什么?为什么弹窗需要它?
参考答案要点:渲染/生命周期/构造;事件、异步、自身错误;默认导出才能直接作为组件类型;没有抛 Promise;加载慢或出错时其他区域是否还应可用;改变了 DOM 挂载位置,没改变 React 组件树与事件冒泡,弹窗需要它绕开父级的 overflow 与层叠上下文。
附:错误上报与边界的配合
错误边界负责“界面不白屏”,上报负责“下次能修”。两者要配合,不能互相替代。
| 错误类型 | 边界处理 | 上报方式 |
|---|---|---|
| 渲染错误 | 降级 UI + 重试按钮 | componentDidCatch 上报 componentStack |
| 事件错误 | 局部提示,不降级整页 | try/catch 后手动上报 |
| 请求错误 | 错误态 + 重试 | 请求层统一上报 |
| 资源加载失败 | 占位图/骨架 | window 捕获阶段监听 |
| 未处理 Promise | 不影响渲染 | unhandledrejection |
上报内容至少包含五项:错误信息、堆栈、组件栈、用户操作路径、版本号。只有 message 的上报在线上几乎无法定位,因为压缩后的代码全是单字母变量。
三条实践:
- 上报要限流。一个渲染错误可能在循环里触发上百次,去重键用“错误信息 + 首行堆栈”。
- 降级 UI 要给出路。只显示“出错了”等于让用户刷新;提供“重试”与“返回列表”两个动作,转化率差别很大。
- 不要把错误边界当兜底逻辑。已知的失败分支(接口 404、无权限)应该在业务层处理,边界只处理“没预料到的崩溃”。
还有一个容易踩的细节:componentDidCatch 里调 setState 会再触发一轮渲染,可能引发循环。需要“重试”时,用边界自身的 reset 方法重置 state,而不是在上报回调里改业务状态。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解