一句话定义
React 性能问题几乎都是两类:渲染太多(不必要的重渲染传播)与渲染太贵(单棵子树计算量大);方法论是固定三步——测量 → 定位原因 → 按原因选手段,绝不跳步。
为什么重要
这是把 kp-008 的模型、kp-018 的工具串成实战能力的一篇。性能优化做反了(盲目 memo)会让代码更难维护且不解决问题;做对了(虚拟化、懒加载、转移优先级)收益是数量级的。
前置知识
核心概念
- 四种重渲染触发:自身 state 变化;父组件重渲染;Context 值变化;(由前两者引起的)订阅外部 store 变化。
- 渲染贵 ≠ 提交贵:Profiler 里 Render Duration 是渲染阶段(JS 计算),Commit Duration 才是 DOM 写入。
- 大列表第一杠杆是虚拟化:只渲染视口内 ~20 行 DOM,而不是 5000 行;记忆化只解决「重复计算」,不解决「DOM 太多」。
- 代码分割:
React.lazy(() => import(...))+ Suspense,按路由/重组件分包,首屏只载必需代码。 - 优化清单的顺序:先「不做」(不渲染),再「少做」(记忆化),最后「晚做」(分割、转移优先级)。
原理 / 机制
测量是第一步,工具链三件:
- React DevTools Profiler:录制一次交互 → 看 Flamegraph/Barchart:谁的 Render Duration 长、谁是「因父组件渲染而渲染」(灰色高亮 did not render)。
- why-did-you-render(或 DevTools 的 "Why did this render?"):直接打印某组件重渲染原因(props 引用变化/state/context)。
- Lighthouse / Performance 面板:看 INP、长任务,判断是 React 的问题还是网络/图片问题。
按原因选手段的对照表(本库知识点的总集成):
| 症状(Profiler 证据) | 根因 | 手段 | 对应 KP |
|---|---|---|---|
| 子组件渲染原因 = "parent rendered" | 引用不稳定 props | useCallback/useMemo + memo | kp-018 |
| 渲染原因 = "context changed" | context 值内联/拆分不当 | useMemo value / 拆 Provider | kp-012 |
| 列表行状态错乱且卡 | key 用 index | 数据 id 作 key | kp-009 |
| 长列表 Commit Duration 高 | DOM 节点过多 | 虚拟化(react-window/virtua) | 本篇 |
| 首屏 JS 大、交互延迟 | 打包未分割 | lazy + Suspense 按路由分割 | kp-020 |
| 输入卡顿(渲染被高频触发) | 每键触发全页重渲染 | 受控输入下沉子组件 / useDeferredValue | kp-023 |
| 慢更新阻塞点击响应 | 同一优先级排队 | useTransition 把更新标记为可中断 | kp-022/023 |
代码分割最小示例:
jsximport { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart')); // 图表库 ~300KB 不进首包
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
虚拟化的直觉实现(真实项目用库):
jsx// 理念演示:只渲染可视区
function VirtualList({ items, rowH = 40, height = 400 }) {
const [scrollTop, setScrollTop] = useState(0);
const start = Math.floor(scrollTop / rowH);
const visible = Math.ceil(height / rowH) + 2;
const slice = items.slice(start, start + visible);
return (
<div style={{ height, overflow: 'auto' }}
onScroll={e => setDeferredScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: items.length * rowH, position: 'relative' }}>
{slice.map((it, i) => (
<div key={it.id}
style={{ position: 'absolute', top: (start + i) * rowH, height: rowH }}>
{it.name}
</div>
))}
</div>
</div>
);
}
5000 行 → 常驻 DOM 22 行,Commit Duration 直接塌缩——这是任何 memo 都做不到的。
直观类比
性能优化像厨房出餐慢的排查:先看订单积压在哪(测量),再对症——菜谱太繁(渲染贵 → 简化/预制 = useMemo)、同一道菜反复做(重复渲染 → 记住成品 = memo)、灶台只有两个却开了十口锅(DOM 太多 → 关火 = 虚拟化)、高峰期把所有客人都排一队(优先级不分 → 贵宾优先 = useTransition)。没看订单先猛买锅,是新手最常见的浪费。
实例 / 案例
一次完整排查的记录模板(面试与工作中都好用):
text问题:搜索页输入时明显卡顿
1. Profiler 录制:输入 "react" 的 5 次键入
2. 发现:App → SearchResults 整棵树每次键入都重渲染
Render Duration 12ms × 60fps = 明显卡顿
3. 根因:query state 放在 App 顶层,SearchResults 树大
4. 手段(组合拳):
a. query 下沉到 SearchBox 组件(受控输入局部化)
b. SearchResults 用 useDeferredValue(query) 接收
c. 结果列表虚拟化
5. 复测:Render Duration 12ms → 1.2ms,无掉帧
常见误区
- 凭感觉优化:没有 Profiler 证据就 memo 全家;代码复杂度上去了,帧率没动。
- 把「重渲染」当原罪:重渲染本身廉价(虚拟 DOM diff),只有渲染贵或渲染频繁到挤掉交互才需要处理。
- 先 memo 后虚拟化:长列表的正确第一杠杆是虚拟化;memo 是第二层的精修。
- 忽略网络与资源:卡顿可能是图片未懒加载、脚本阻塞;React 之外的 Performance 面板先看一眼。
- 优化一次就一劳永逸:性能是回归项;关键交互路径写 Profiler 断言或 CI 性能预算(kp-027)。
自测题
- 列出四种重渲染触发源,各举一个例子。
- 「5000 行列表卡」为什么 memo 不是首选?虚拟化省掉的是什么量级的工作?
- 现场排查一个「输入卡顿」页面,写出你的前三个动作。
React.lazy解决的是渲染慢还是加载慢?配合什么组件使用?- Render Duration 与 Commit Duration 的区别是什么?各自高说明什么问题?
与其他知识点的关系
- 向前:kp-008 提供模型,kp-018 提供工具。
- 向后:kp-022/023 提供「转移优先级」这条与记忆化正交的路径;kp-020 的 lazy/Suspense 是首屏性能主力;kp-027 把性能回归纳入测试体系;kp-025 的 React Compiler 若普及,将自动覆盖表中第一行的多数场景。
延伸阅读
- React DevTools Profiler:<https://react.dev/learn/react-developer-tools>
- Optimizing Performance(官方清单式总览):<https://legacy.reactjs.org/docs/optimizing-performance.html>
- INP 与响应性(web.dev):<https://web.dev/articles/inp>