React 进阶:渲染模型、Hooks 边界与性能优化
本文目录
多数 React 性能问题不是"React 慢",而是把状态放错了位置、在错误的时机触发了不必要的渲染、或者用 memo 掩盖了本该修的数据流。这篇文章自底向上把渲染机制讲清楚:渲染模型的三个阶段、批处理、useEffect 的依赖与清理、useMemo/useCallback 的真实成本、列表 key、并发特性、错误边界与 Suspense,最后汇总常见反模式。所有规则都基于 react.dev 文档描述的行为,可以在任何 React 18+ 项目里直接验证。
渲染模型:触发、渲染、提交
一次状态更新经过三个阶段,理解每个阶段"允许做什么"是所有后续讨论的基础:
| 阶段 | 做什么 | 特点 |
|---|---|---|
| 触发(Trigger) | setState 调用让组件把更新排进队列 | 渲染的唯一入口是状态变化(或父组件渲染、Context 变化) |
| 渲染(Render) | 调用组件函数,得到一份"虚拟 DOM 描述" | 必须是纯的:不改外部状态、结果只由 props/state 决定;可中断、可重放 |
| 提交(Commit) | Diff 后把需要的最小变更应用到真实 DOM,然后运行 effect | 同步完成;layout effect 在 DOM 更新后、绘制前,useEffect 在绘制后 |
两个推论:
- "渲染"不等于"更新 DOM"。渲染是调用函数算差异,DOM 只在 diff 出差异时才动。所以"组件函数被调用了"本身不是性能问题,它可能一无所获。
- 不要在渲染阶段写副作用。并发模式下渲染函数可能被丢弃重放,在组件体里直接改外部变量(比如直接 push 到模块级数组)会得到重复数据。
function Bad({ items }: { items: string[] }) {
items.push("render"); // 渲染阶段改 props:纯度被破坏
return <List items={items} />;
}
function Good({ items }: { items: string[] }) {
const [extra, setExtra] = useState(false); // 状态变化是触发渲染的合法方式
return <List items={extra ? [...items, "extra"] : items} />;
}
状态更新批处理
React 18 起,所有更新自动批处理,包括 Promise 回调和 setTimeout 里的 setState——17 之前这些场景里每次 setState 都会同步触发一次渲染。
function Checkout() {
const [count, setCount] = useState(0);
const [total, setTotal] = useState(0);
async function pay() {
const res = await fetchOrder();
setCount(res.count);
setTotal(res.total);
// 两次 setState 合并为一次渲染(React 18 的自动批处理)
}
return null;
}
要点:
- 批处理按"同一事件循环任务"分组,多次更新合并成一次渲染,这是免费的性能红利,不需要手动优化。
- 需要"用户立刻看到中间状态"的例外场景(比如在大数据量更新前先同步刷新一个 loading 提示)用
flushSync,但它是逃生舱,正常业务一年用不到一次。 - 同一个 state 连续多次函数式更新是叠加的,对象式更新是覆盖的——函数式
setX(prev => ...)才是连续更新时的正确写法。
useEffect:依赖与清理陷阱
useEffect 是"和外部系统同步"的原语,不是"生命周期钩子"。三个高频事故来自对这句话的误解。
陷阱一:依赖数组撒谎
// 错误:依赖了 clickHandler,但每次渲染都是新函数,effect 每次都重新订阅
useEffect(() => {
socket.on("msg", clickHandler);
return () => socket.off("msg", clickHandler);
}, [clickHandler]);
// 正确之一:把 handler 放进 effect 内部,依赖列表如实声明用到的值
useEffect(() => {
const handler = (msg: string) => {
if (msg === lastRef.current) return; // 需要最新值又不想触发重订阅时用 ref
}
socket.on("msg", handler);
return () => socket.off("msg", handler);
}, []);
规则是依赖数组如实写,而不是"写少一点让它少跑"。省略依赖只会让闭包捕获旧值(stale closure),表现为"点击第二次才生效""分页后请求的还是第一页"这类诡异 bug。eslint 的 react-hooks/exhaustive-deps 应该保持开启。
陷阱二:忘了清理
计时器、订阅、IntersectionObserver、AbortController 不清理,在组件卸载后继续持有回调:
useEffect(() => {
const id = setInterval(() => setStatus(pollApi()), 3000);
return () => clearInterval(id); // 没有这行,离开页面后轮询永不停止
}, []);
陷阱三:异步竞态
快速切换时,先发的请求后到,旧响应覆盖新响应。用清理函数里的标志位或 abort:
useEffect(() => {
const ac = new AbortController();
fetch(`/api/items?q=${query}`, { signal: ac.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => { if (e.name !== "AbortError") setError(e); });
return () => ac.abort(); // query 变化时取消上一个请求
}, [query]);
最贵的误区:用 effect 同步派生数据
能用渲染期计算的东西不要放进 effect 再 setState——那会多一整轮渲染,还可能造成无限循环:
// 反模式:fullName 是派生值,不该是 state
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
// 正确:渲染时直接算,或数据昂贵时用 useMemo
const fullName = `${first} ${last}`;
判断标准:这个值能由现有 props/state 算出来,就是派生值;只有外部事件产生的才配是 state。
useMemo 与 useCallback 的真实成本
这两个 Hook 不是"性能优化开关",它们是用内存和比较成本换引用稳定。只有当引用稳定本身有收益时才值得:
| 场景 | 值得用 | 原因 |
|---|---|---|
| 传给 memo 过的子组件的回调/对象 props | 值得 | 子组件靠 props 浅比较跳过渲染,引用变化则失效 |
| 作为另一个 Hook 的依赖 | 值得 | 否则依赖每次变化导致 effect 反复执行 |
| 计算本身昂贵(大列表过滤、排序) | 值得 | 直接省 CPU |
| 传给普通子组件的函数 | 不值得 | 子组件反正会渲染,稳定引用没有收益 |
| "顺手都包一层" | 不值得 | 每次渲染多一次依赖比较和缓存条目,代码可读性下降 |
const MemoList = memo(function ItemList({ query, onSelect }: Props) {
// 大列表渲染
});
function Parent({ rows }: { rows: Row[] }) {
const query = useSearch();
// 值得:rows.filter 每次都是新数组,且产物是 memo 子组件的 props
const filtered = useMemo(
() => rows.filter((r) => r.name.includes(query)),
[rows, query]
);
// 值得:onSelect 是传给 memo 子组件的稳定引用
const handleSelect = useCallback((id: string) => logSelect(id), []);
return <MemoList query={query} rows={filtered} onSelect={handleSelect} />;
}
一个被忽略的事实:React.memo、useMemo、useCallback 只挡"props 没变"的子树,不挡自己。状态在自己身上的组件,更新时照样渲染。如果慢的根源是"每次输入都重渲染 10000 行表格",先考虑虚拟列表,再考虑 memo。
列表 key 与状态关联
key 的作用是"跨渲染维持元素身份"。用数组索引当 key,在列表发生插入、删除、排序时,React 会把"同一个位置"当成"同一个组件",状态跟着位置走而不是跟着数据走:
// 反模式:删除第一项后,原来第二项的输入框状态会"上移"到第一项
{rows.map((row, i) => (
<Row key={i} value={row.value} />
))}
// 正确:用数据自带的稳定 ID
{rows.map((row) => (
<Row key={row.id} value={row.value} />
))}
索引 key 是安全的唯二场景:列表永不重排且只在末尾追加且行内没有本地状态。key 也常被用来"强制重置"——故意用变化的 key 让 React 丢弃旧实例重建状态,这是合法技巧,但要写注释说明是故意的,否则后人会当成 bug 修掉。
并发特性:Transition 与 deferred value
把更新分成"紧急"和"非紧急"是并发渲染的核心应用。搜索框输入要立刻反馈,结果列表重渲染几万行可以慢一点:
function SearchableTable({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState("");
const [deferredQuery, setDeferredQuery] = useDeferredValue(query); // 非紧急副本
const filtered = useMemo(
() => rows.filter((r) => r.name.includes(deferredQuery)),
[rows, deferredQuery]
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Table rows={filtered} /> {/* 大列表用过期值先渲染,输入框不卡 */}
</>
);
}
startTransition(fn) 与之等价,区别是它包住的是更新调用本身。两条边界:
- 它们不减少总工作量,只是让紧急更新可以插队、列表先显示过期内容("stale-while-refresh"),输入延迟感来自这里。
- 只有确实存在"紧急输入 + 昂贵下游渲染"时才有意义;渲染本身 5ms 的页面加 Transition 是负优化。
错误边界与 Suspense
错误边界是类组件的专属能力(getDerivedStateFromError + componentDidCatch),React 至今没有等价的 Hook:
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError(error, info.componentStack); // 上报,让错误可见
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
必须知道的边界:错误边界只捕获渲染期错误。事件处理器里的错误、异步回调里的错误、effect 内部的错误都不会被它接住——这些要用 try/catch 或全局 error 事件处理。Suspense 配合 React.lazy 做代码分割是标准用法:
const AdminPanel = React.lazy(() => import("./AdminPanel"));
<Suspense fallback={<Spinner />}>
<AdminPanel />
</Suspense>
常见性能反模式清单
| 反模式 | 症状 | 修正方向 |
|---|---|---|
| 状态放得太高 | 顶部输入框打字,整页树渲染 | 把状态下移到真正使用它的最小子树 |
| Context value 内联对象 | 所有消费组件每次都"变了" | useMemo 包 value;把频繁变化与不常变化拆成两个 Context |
| 渲染期里定义组件 | 子树 state 每次被清空,diff 失效 | 组件定义移到模块顶层 |
| 全页套 memo | 构建产物变大,维护成本高,收益接近零 | 只 memo 渲染确实慢、props 稳定的叶子 |
| effect 里 setState 同步派生值 | 多一轮渲染、潜在死循环 | 派生值在渲染期计算 |
| 传整个大对象给深层子组件 | props 失效判定太粗 | 只传用到的字段,或拆组件 |
| 用 state 存服务端数据副本手动同步 | 双份数据漂移、竞态 | 数据请求交给专门的请求层,组件只订阅 |
注意事项与边界
- StrictMode 在开发环境故意双调用渲染和 effect,用于暴露不纯的代码;生产环境不发生。看到"effect 执行两次"先查纯度,不要急着去关 StrictMode。
useLayoutEffect会阻塞绘制,留给"读布局、再同步改布局"的场景(如测量后定位浮层),常规副作用一律 useEffect。- 并发特性下,渲染可能被丢弃重放,所以渲染函数必须幂等;依赖"渲染恰好执行一次"的代码在并发模式是不可靠的。
- React Profiler(开发工具)的"Highlight updates"是验证渲染范围的第一手段,肉眼猜测渲染原因通常是错的。
- 键盘/屏幕阅读器用户的交互路径不走 click,事件处理要在语义元素上(
<button>),这也是可访问性和测试稳定性的共同前提。
总结
React 性能优化的顺序应该是:先修数据流(状态位置、派生 vs 储存、effect 的使用),再看渲染范围(Profiler 验证),最后才是 memo/useMemo 这类兜底手段。渲染模型三阶段是判断一切问题归属的坐标系:渲染阶段的纯度约束、提交阶段的 effect 时机、触发阶段的批处理,分别对应三类典型 bug。并发特性和错误边界是覆盖"昂贵渲染"与"失败兜底"两块短板的工具,用不用都应该基于测量结果而不是潮流。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。