Re-render 原理:React 何时重新渲染以及如何阻止
第15章:Re-render 原理:React 何时重新渲染以及如何阻止
理解'渲染不等于 DOM 更新'是把握 React 性能的起点。频繁渲染消耗 CPU,但并不总是需要阻止。
本章核心问题:React 重渲染由哪四个条件触发?如何区分'渲染'和'DOM 更新'? 读完本章你将理解:
- 渲染不等于绘制——渲染发生在内存中,只有 diff 出差异才更新 DOM
- 父组件重渲时,所有子组件默认无条件跟着渲染
- 预防策略按优先级:状态下移 → 内容提升 → React.memo → useMemo/useCallback
Level 1 · 你需要知道的(1-3年经验)
渲染的本质
很多开发者把"渲染"和"DOM 更新"混为一谈。这是理解 React 性能的最大误区。
React 的"渲染"是指调用你的组件函数,得到一棵描述 UI 的虚拟 DOM 树(React Element 树)。这个过程发生在内存里,不涉及任何真实 DOM 操作。只有当 React 对比新旧两棵虚拟树(Diffing),发现真正有差异的节点,才会把差异批量提交到真实 DOM。
这意味着:渲染不等于绘制。一次渲染可能完全不产生 DOM 变化,但它仍然消耗 CPU 时间——执行函数体、分配对象、遍历子树。当这个消耗积累到帧时间(16.67ms @ 60fps)以上,页面就会掉帧。
理解这个区别,才能正确定位性能问题:不是"DOM 太慢",而是"组件函数被调用太频繁或执行太慢"。
触发重新渲染的四种条件
条件一:setState 调用
最直觉的触发源。无论是 useState 的 setter、useReducer 的 dispatch,还是类组件的 this.setState,只要状态值变化,组件就会重新渲染。
function Counter() {
const [count, setCount] = useState(0);
console.log('Counter rendered'); // 每次 setCount 都会打印
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
关键细节:React 会做一次 Object.is 比较。如果新值和旧值相同,React 会跳过渲染(bailout)。
const [user, setUser] = useState({ name: 'Alice' });
// ❌ 即使内容相同,引用不同,依然触发渲染
setUser({ name: 'Alice' });
// ✅ 引用相同,React bailout,不渲染
setUser(prev => prev);
条件二:父组件重新渲染
这是最容易被忽略的触发源,也是"不必要渲染"的主要来源。
默认行为:父渲染,所有子组件无条件跟着渲染。
function Parent() {
const [tick, setTick] = useState(0);
return (
<div>
<button onClick={() => setTick(t => t + 1)}>Tick</button>
<ExpensiveChild /> {/* tick 变化时,它也会重新渲染 */}
</div>
);
}
function ExpensiveChild() {
console.log('ExpensiveChild rendered'); // 每次 tick 变化都打印
return <div>我从不变化</div>;
}
原因:每次 Parent 渲染,<ExpensiveChild /> 这个 JSX 表达式都创建了一个新的 React Element 对象({ type: ExpensiveChild, props: {} }),React 看到 props 对象是全新的引用,默认走渲染流程。
条件三:Context 变化
当 Context.Provider 的 value 发生变化时,所有订阅了该 Context 的组件(通过 useContext)都会重新渲染,不管它们在组件树的哪个位置,也不管中间组件有没有用 React.memo。
const ThemeContext = createContext({ color: 'blue' });
function App() {
const [theme, setTheme] = useState({ color: 'blue' });
return (
// ❌ 每次 App 渲染,value 是新对象,所有消费者都重渲
<ThemeContext.Provider value={theme}>
<DeepChild />
</ThemeContext.Provider>
);
}
这里有一个陷阱:value={{ color: 'blue' }} 和 value={theme} 行为不同。前者每次渲染都创建新对象,后者只在 setTheme 时才变。
条件四:forceUpdate(类组件)
类组件专有。调用 this.forceUpdate() 会跳过 shouldComponentUpdate,强制渲染。函数组件没有对应 API,但可以用 useReducer 模拟:
const [, forceUpdate] = useReducer(x => x + 1, 0);
// forceUpdate() 触发渲染
实际工程中极少需要,通常是响应外部(非 React 管理的)数据变化时的兜底方案。
Level 2 · 它是怎么运行的(3-5年经验)
渲染树的传播规则
React 的渲染传播遵循自上而下单向传播原则:父组件渲染时,其所有子组件默认也会渲染。这条规则不是 Bug,而是 React 的保守设计——它无法预知子组件是否依赖父组件的某个隐式状态。
传播会沿着组件树一直向下,直到:
- 遇到叶子节点(没有子组件)
- 遇到 bailout 条件(组件决定跳过渲染)
React 的 Bailout 优化
Bailout 是 React 内部的术语,指"跳过当前子树的渲染"。触发 bailout 的条件:
同引用 = 跳过子树。当 React 在新的渲染中发现某个子树的根节点 Element 与上次渲染的引用完全相同(===),它会跳过整个子树。
function Parent() {
const [count, setCount] = useState(0);
// 把 element 提取到变量,引用在渲染间保持稳定
const child = useMemo(() => <ExpensiveChild />, []);
return (
<div>
{count}
{child} {/* 引用不变,bailout! */}
</div>
);
}
这就是 React.memo、useMemo、useCallback 的底层逻辑基础——它们都在想办法保持引用稳定,让 React 有机会做 bailout。
Level 3 · 规范怎么定义的(资深)
用数字量化"不必要渲染"的代价
理论上"不必要渲染不好",但实际代价有多大?用 React Profiler 测量一个真实场景:
场景:一个包含 50 个 <ListItem> 的列表,父组件每秒刷新一次(计时器)
每个 ListItem:渲染耗时约 0.3ms,无任何副作用
无优化:
每秒触发 50 次不必要渲染
每秒额外 CPU 时间:50 × 0.3ms = 15ms
占 60fps 帧时间(16.67ms)的 90%
→ 明显感知到的卡顿
加 React.memo 后:
只有数据变化的 ListItem 重渲(假设每秒 2 个)
每秒额外 CPU 时间:2 × 0.3ms = 0.6ms
→ 完全流畅
单个组件 0.3ms 看似微小,但在大型列表、高频更新的场景下,积累效应非常显著。这就是为什么"不必要渲染"值得关注,但也不是每个组件都需要 memo——优化有成本,草率 memo 可能让代码复杂度上升而收益为零。
有针对性的预防策略
明确了问题的根源,才能选择正确的工具。
策略一:状态下移(State Colocation)
最被低估的优化手段。如果一个状态只被某个子树使用,把它移进那个子树,避免污染父级。
// ❌ 状态在父级,SearchInput 的每次输入都导致 HeavyList 重渲
function Page() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<HeavyList /> {/* 被无辜拖累 */}
</>
);
}
// ✅ 状态内聚,HeavyList 完全不受影响
function SearchSection() {
const [query, setQuery] = useState('');
return <SearchInput value={query} onChange={setQuery} />;
}
function Page() {
return (
<>
<SearchSection />
<HeavyList /> {/* 安然无恙 */}
</>
);
}
策略二:内容提升(Children as Props)
通过 children 传递,可以让 React 的 bailout 机制发挥作用:
// ✅ HeavyChild 作为 children 传入,父级状态变化不触发它重渲
function AnimatedWrapper({ children }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const id = setInterval(() => setFrame(f => f + 1), 16);
return () => clearInterval(id);
}, []);
return <div style={{ opacity: frame % 2 === 0 ? 1 : 0.9 }}>{children}</div>;
}
// 使用时:
<AnimatedWrapper>
<HeavyChild /> {/* AnimatedWrapper 的状态变化不触发 HeavyChild 重渲 */}
</AnimatedWrapper>
原因:<HeavyChild /> 这个 Element 在 App 组件中创建,App 不重渲,这个 Element 的引用就保持稳定,React bailout。
策略三:React.memo 包裹
当组件接受 props 且父组件频繁重渲时,React.memo 可以阻断传播:
const MemoizedItem = React.memo(function Item({ id, name }) {
return <li>{id}: {name}</li>;
});
注意:React.memo 只做浅比较(每个 prop 用 Object.is)。如果 prop 是对象或函数且每次渲染都是新引用,memo 无效。
策略四:避免过早优化
并非所有重渲都需要阻止。以下情况通常不值得优化:
- 组件本身渲染极快(< 0.1ms),即使频繁渲染也无感知
- 组件树很浅,bailout 带来的收益小于 memo 的比较开销
- 组件的 props 本来就会变化,memo 的浅比较每次都 miss
优化的正确顺序:测量 → 确认瓶颈 → 选择最简单的解法 → 再次测量验证。跳过测量步骤的"优化"往往是在制造技术债务。
Level 4 · 边界与陷阱(所有人)
诊断:如何发现真正的问题
在开始优化之前,先用工具确认问题存在。
方法一:React DevTools 高亮
在 React DevTools 的 Settings 中开启 "Highlight updates when components render"。每次渲染,组件会有彩色边框闪烁。颜色越深(从蓝到红),渲染越频繁。
方法二:console.log 标记法
function SuspectedComponent({ data }) {
// 开发环境快速验证
if (process.env.NODE_ENV === 'development') {
console.count('SuspectedComponent render');
}
return <div>{data.name}</div>;
}
方法三:React Profiler API
import { Profiler } from 'react';
<Profiler id="MyList" onRender={(id, phase, actualDuration) => {
console.log(`${id} [${phase}]: ${actualDuration.toFixed(2)}ms`);
}}>
<MyList />
</Profiler>
actualDuration 是本次渲染及其子树花费的时间(毫秒)。phase 是 "mount" 或 "update"。