「组件为什么又渲染了」「为什么这个列表卡」——这类问题的根源都是没搞清 React 的渲染时机。这篇把「什么时候渲染、什么时候重渲染、重渲染有多贵」讲透。
一、render 与 re-render 是两个概念
| 阶段 | 触发条件 | 做什么 |
|---|---|---|
| 首次渲染(mount) | 组件第一次出现在树里 | 执行函数体,生成元素树 |
| 重渲染(re-render) | state 改变 / props 改变 / context 改变 / 父组件重渲染 | 再执行一次函数体 |
| 提交(commit) | 渲染结果与上次不同 | 更新真实 DOM |
关键:重渲染不等于重新渲染 DOM。React 会先 diff,只把真正变化的节点提交到 DOM。所以「重渲染 1000 次」不一定慢,慢的是「每次都要做大量 diff 或大量 DOM 更新」。
二、重渲染的四个触发源
// 1. state 改变
const [n, setN] = useState(0)
setN(1) // 触发
// 2. props 改变(父组件传新值)
<Child value={newValue} />
// 3. context 改变
const theme = useContext(ThemeContext) // Provider 的 value 变了就触发
// 4. 父组件重渲染(即使 props 没变!)
function Parent() {
const [x, setX] = useState(0)
return <Child /> // Parent 重渲染时,Child 也会重渲染
}第四条最容易被忽略:父组件重渲染会带动所有子组件重渲染,即使子组件的 props 完全没变。这是「列表卡顿」的最常见原因。
三、memo 能挡住什么,挡不住什么
// React.memo:props 浅比较相等时跳过重渲染
const Child = React.memo(function Child({ value }) {
return <div>{value}</div>
})
// 但这样写 memo 就失效了:
<Child onClick={() => {}} /> // 每次都是新函数引用
// 必须配 useCallback:
const handler = useCallback(() => {}, [])
<Child onClick={handler} />| memo 能挡 | memo 挡不住 |
|---|---|
| 父组件重渲染带动子组件重渲染(props 没变) | 自身 state 改变导致的重渲染 |
| props 浅比较相等时跳过 | props 里嵌套对象变了(浅比较只看引用) |
| — | context 变化导致的重渲染(memo 无效) |
四、浅比较的具体规则
// 这些会「相等」(跳过重渲染)
<Child n={1} /> // 原始值
<Child s="abc" />
<Child obj={sameRef} /> // 同一引用
// 这些会「不等」(重渲染)
<Child obj={{ a: 1 }} /> // 每次新对象
<Child arr={[1, 2]} /> // 每次新数组
<Child fn={() => {}} /> // 每次新函数
<Child obj={{ ...prev, x: 1 }} /> // 新对象,即使内容相同所以避免无效重渲染的核心是:让传给子组件的非原始值保持引用稳定。
五、context 的重渲染问题
// 问题:Provider value 是对象字面量,每次渲染都是新引用
<ThemeContext.Provider value={{ theme, setTheme }}>
<App />
</ThemeContext.Provider>
// 结果:任何 context 消费者都重渲染,即使只用了 theme
// 解法 1:把 value 稳定住
const ctx = useMemo(() => ({ theme, setTheme }), [theme])
<ThemeContext.Provider value={ctx}>
// 解法 2:拆成多个 context(按更新频率分)
<ThemeContext.Provider value={theme}>
<ThemeSetterContext.Provider value={setTheme}>context 的粒度设计很重要:一个大的 context 里混了「高频变化的」和「低频变化的」值,会导致所有消费者跟着高频重渲染。按更新频率拆开,或用状态库的 selector 订阅。
六、怎么测出哪里在重渲染
// 1. 最简单的:渲染时打日志(带组件名)
function Comp() {
console.log('render Comp') // 看它被执行了几次
return <div />
}
// 2. React DevTools Profiler
// Profiler 标签 → 录制 → 看每个组件的渲染耗时与次数
// 3. why-did-you-render(开发期跑)
// npm i @welldone-software/why-did-you-render
import whyDidYouRender from '@welldone-software/why-did-you-render'
whyDidYouRender(React, { trackAllPureComponents: true })七、性能问题的判断顺序
| 现象 | 先查 | 常见根因 |
|---|---|---|
| 整个页面卡 | Profiler 里有哪个组件耗时最长 | 大列表未虚拟化 / 昂贵计算未 memo |
| 列表滚动卡 | 每行是否都在重渲染 | key 用 index / 行组件未 memo |
| 输入框打字卡 | onChange 是否触发全局重渲染 | 受控组件带动大范围更新 |
| 只有某个组件慢 | 它自身计算量 | useMemo 缺失或依赖过宽 |
| 内存持续增长 | 组件是否泄漏 | Effect 清理缺失 / 定时器未清 |
八、不要过度优化
- React 的重渲染比你想的便宜:不做 DOM 更新的重渲染通常几毫秒
- 先测再加 memo:React 官方的建议,滥用 memo 会让代码难读且收益为零
- 真正的性能问题通常在别处:大列表没虚拟化、同步阻塞主线程、过度请求
- memo 有成本:每次都要做浅比较,props 多时比较开销可观
总结
- 重渲染 ≠ 重画 DOM:React 会 diff,只有真实变化才提交
- 父组件重渲染会带动子组件,这是列表卡顿的头号原因
- memo 只能挡「props 没变的父级带动」,挡不住自身 state 和 context 变化
- 非原始值要保引用稳定:对象/数组/函数每次新建都会让 memo 失效
- 先 Profiler 后优化:没有测量数据的优化都是猜