模块四 · 性能、调度与架构
重渲染控制:先问为什么渲染,再问怎么阻止
大部分"性能问题"其实是结构问题。这一节按"状态下放 → 结构拆分 → memo → 虚拟列表"的顺序给出优化手段与判断标准。
读完这一节,你能做到
- 说清一次重渲染的触发链。
- 用状态下放与结构拆分减少渲染范围。
- 判断 memo/useMemo/useCallback 什么时候真的有用。
- 用虚拟列表解决长列表卡顿。
一、先看渲染为什么发生
React 的默认行为是:父组件渲染,所有子组件都渲染。子组件渲染的原因通常不是自己的 state 变了,而是"父亲重新执行了"。
三个问题要分开:
| 问题 | 判断 |
|---|---|
| 渲染次数多 | 是否由父组件引起 |
| 单次渲染慢 | 组件本身计算量大不大 |
| 渲染后卡顿 | 是不是 DOM 操作或布局抖动 |
大部分"卡"不是渲染次数,而是单次渲染做了太多事,或者长列表一次渲染了几千行。
二、状态下放
把状态放到真正需要它的组件里,而不是放在页面顶层:
// 差:输入框每次输入,整个页面重渲染
function Page() {
const [text, setText] = useState("");
return (
<>
<Input value={text} onChange={setText} />
<ExpensiveList />
</>
);
}
// 好:状态下沉到 Input 内部,列表不受影响
function Input() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
状态下放的收益,通常大于任何 memo。
三、结构拆分与 children
把"变化的部分"和"不变的部分"拆开,用 children 传递不变的部分:
function Layout({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}
children 是父组件已经创建好的元素,Layout 重渲染时不会重新创建它,因此子树不会跟着渲染。这是比 memo 更稳的优化。
四、memo / useMemo / useCallback
| API | 作用 | 生效条件 |
|---|---|---|
memo | 跳过子组件渲染 | props 浅比较相等 |
useMemo | 缓存计算结果 | 依赖不变 |
useCallback | 缓存函数引用 | 依赖不变 |
三个常见误区:
- memo 不是越多越好:浅比较本身有成本,简单组件包 memo 反而更慢。
- useMemo 不是语义保证:React 可以在内存压力下丢弃缓存,不能拿它保证"只计算一次"。
- useCallback 只在需要稳定引用时用:比如传给 memo 子组件、作为 effect 依赖。
五、Context 的分割
Context 的值变化会让所有消费者重渲染。两个做法:
- 按领域拆成多个 Context,避免"一个值变,全体更新"。
- 把 value 用
useMemo包住,避免父组件每次渲染都创建新对象。
如果 Context 里既有频繁变化的值,又有稳定的方法,拆成两个 Context 更合适。
六、长列表:虚拟滚动
一次渲染 2,000 行,DOM 节点与布局计算都会爆。虚拟滚动只渲染可视区域:
- 固定高度:直接按
scrollTop计算区间。 - 不定高度:先估算,渲染后测量并修正。
- 回收节点时要清理副作用(图片懒加载、订阅)。
七、什么时候不该优化
- 组件很小、渲染很快,memo 的收益抵不过比较成本。
- 数据量不大,虚拟列表增加复杂度却没有体感提升。
- 瓶颈在网络或图片,不在渲染。
先测量,再优化;优化后要能说出指标变化。
八、常见坑
- 给所有组件包 memo,结果代码更难读、性能没变。
- useMemo 依赖写错,缓存了旧值。
- Context value 每次新建对象,导致所有消费者重渲染。
- 用下标 key 导致列表项状态错位。
- 虚拟列表没处理不定高度,滚动跳动。
九、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 把一个页面顶层的输入框状态下放,观察列表渲染次数。
- 用 children 拆分布局与内容,验证子树不再重渲染。
- 给一个 1,000 行列表加虚拟滚动,对比滚动帧率。
② 让 AI 写同一题
把第 1 题交给 AI,补一句 先判断能不能缩小状态影响范围,再谈 memo。
③ 对比要盯的点
- 它是不是直接给你
memo+useCallback+useMemo三件套?那是“看起来优化”。 - 它有没有先问“这个状态到底该放在哪一层”?没问就动手,顺序就反了。
- 它给的数字是真的测过,还是一句“这样会更快”?
④ 一句话取舍
它给的三件套里,在你这个页面上有几件是无效的?用 Profiler 数出来。
十、自测
- 子组件为什么会重渲染?
- 状态下放为什么比 memo 更有效?
- useMemo 能保证"只计算一次"吗?
- Context 值变化影响谁?
- 什么时候不该做渲染优化?
参考答案要点:父组件渲染;缩小状态影响范围;不能,只是缓存提示;所有消费者;收益小于成本时。
附:memo 的浅比较细节
memo 默认用 Object.is 逐项比较 props:基本类型比值,对象/数组/函数比引用。所以下面三种情况一定会重渲染:
- 父组件传了对象字面量
style={{ color: 'red' }}。 - 传了内联函数
onClick={() => ...}。 - 传了
children里新建的元素。
修法不是无脑包 memo,而是稳定引用:对象提到组件外或用 useMemo,函数用 useCallback,children 用组合。
附:Context 拆分的案例
一个页面同时提供“用户信息”和“主题色”。用户信息变化不频繁,主题色可能频繁切换。如果放在同一个 Context,主题切换会让所有用户信息的消费者也重渲染。拆成两个 Context 后,各自变化只影响各自的消费者。
如果拆分后仍然重渲染,检查 value 是否是每次新建的对象:用 useMemo 包住 value,依赖写真正会用到的字段。
附:性能优化的决策树
卡顿?
├─ 输入卡 → 看渲染范围(状态下放、memo、children)
├─ 滚动卡 → 看 DOM 数量(虚拟列表)
├─ 首屏慢 → 看资源(分包、图片、懒加载)
└─ 切换页卡 → 看数据与计算(缓存、Worker)
先定位类型,再选手段;不要把所有优化手段一起上。
附:React.memo 与 PureComponent
两者都是浅比较 props,区别是 memo 用于函数组件,PureComponent 用于类组件。它们都只比较第一层:嵌套对象内部变化不会触发重渲染,除非你传入新引用。
需要自定义比较时,给 memo 传第二个参数:返回 true 表示“相等,跳过渲染”。但自定义比较容易写错,优先稳定引用。
附:什么时候该用虚拟列表
- 行数超过 200 且滚动明显掉帧。
- 行高相对固定,或可以估算。
- 列表项没有复杂的全局副作用。
行数少、行高差异大、需要打印完整列表时,不要用虚拟滚动,复杂度不划算。
附:children 与 memo 的组合
把不变的子树作为 children 传给会频繁更新的组件,子树不会因为父组件渲染而重新创建。这比 memo 更稳定:它不依赖 props 比较,而是利用“元素已经在父组件创建好”这一事实。先缩小范围,再谈缓存;结构优化优先于 API 优化,测量先于手段。先算,再改。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ React 中如何避免不必要的重渲染?
- ★★★★☆ 长列表渲染卡顿,你会怎么优化?
- ★★★★☆ React 组件复用:组合和继承怎么选?
- ★★★★☆ Suspense 和 lazy 解决什么问题?
提示:读完可以顺手做几道对应题目,检验是否真的理解