
一句话:父组件重渲染时,子组件默认也会重渲染(因为父组件重新执行时会重新调用子组件函数/创建新元素)——想阻断必须用
React.memo(且 props 引用稳定)、children透传或状态下移。
一、什么会触发重渲染
| 触发原因 | 说明 |
|---|---|
setState(本组件) |
state 变化 → 该组件重新执行 |
| 父组件重渲染 | ⭐ 父组件重新执行时,会重新调用子组件(见下节) |
| Context 变化 | 该 Context 的所有消费者都会重渲染(memo 拦不住) |
useReducer 的 dispatch |
同 state 变化 |
forceUpdate(类组件) |
强制更新 |
外部的 useSyncExternalStore 订阅值变化 |
订阅的外部数据变化 |
// 最常见的触发:setState
function Counter() {
const [count, setCount] = useState(0);
console.log('Counter 渲染');
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// 点击 → setCount → Counter 重新执行(打印一次)
二、什么不会触发重渲染
// ① 直接修改 state(引用未变)
const [user, setUser] = useState({ name: 'a' });
user.name = 'b'; // ❌ 不重渲染(React 用引用比较)
// ② 修改 ref.current
const ref = useRef(0);
ref.current++; // ❌ 不重渲染(这是设计如此)
// ③ 修改「组件外的变量」
let external = 0;
function Comp() {
external++; // ❌ 不重渲染(React 不知道)
return <div>{external}</div>;
}
// ④ 调用 setState 但「值相同」(可能 bailout)
setCount(count); // ⚠️ React 用 Object.is 比较,可能跳过
setItems(items); // ⚠️ 引用相同 → 跳过
// ⑤ 在事件处理里改「已经渲染出来的 props 对象」
props.list.push(x); // ❌ 引用未变 + 违反不可变
| 操作 | 会重渲染吗 |
|---|---|
setState(新值) |
✅ |
setState(相同原始值) |
⚠️ 可能跳过(Object.is 相等) |
setState(相同引用的对象) |
⚠️ 跳过 |
setState(内容相同但新引用的对象) |
✅(引用不同) |
修改 ref.current |
❌ |
| 修改组件外变量 | ❌ |
| 直接改 state 对象的属性 | ❌(且是错误用法) |
| 父组件重渲染 | ✅ |
| 订阅的 Context/外部 store 变化 | ✅ |
三、渲染的「传播」(核心认知)
function Parent() {
const [count, setCount] = useState(0);
console.log('Parent 渲染');
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<Child /> {/* ⚠️ Child 也会重新执行 */}
</>
);
}
function Child() {
console.log('Child 渲染'); // ⭐ 点按钮时会打印
return <div>我没有 props</div>;
}
为什么会这样?
① 父组件重新执行 → 返回一棵新的元素树
② 新的元素树里包含 <Child />(一个新的 React Element 对象)
③ React 需要用「新的元素」和「旧的 fiber」比较,看 Child 是否需要更新
④ 但比较之前,React 必须【先渲染 Child】——即调用 Child() 拿到它的元素树
所以:父组件重渲染 → 子组件函数【会被调用】→ 打印日志、执行计算
⚠️ 注意用词:「渲染」= 函数被调用(render 阶段),
不等于「DOM 被更新」(commit 阶段可能发现没有差异,DOM 不动)。
所以「重渲染」不一定是问题,只有当渲染成本高时才需要优化。
一句话记忆:
默认情况下,React 的更新是「自顶向下、层层传播」的——
只要父组件重渲染,整棵未被 memo 拦住的子树都会重新渲染。
四、如何阻断(四种手段)
1) React.memo
const Child = React.memo(function Child({ value }: { value: number }) {
console.log('Child 渲染');
return <div>{value}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<Child value={1} /> {/* ✅ props 没变 → memo 跳过 → 不渲染 */}
</>
);
}
memo 的生效条件(缺一不可):
① 包裹了 React.memo
② 所有 props 的引用都稳定(浅比较「相等」)
⚠️ 只要有一个 props 是「每次渲染新建的对象/数组/函数」,memo 就失效:
<Child value={1} onClick={() => {}} options={{ a: 1 }} />
↑ 新函数 ↑ 新对象
2) children 透传(结构技巧)
// ❌ Provider/容器组件重渲染时,内部子树也会重渲染
function Container() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ExpensiveTree /> {/* ⚠️ 会跟着重渲染 */}
</div>
);
}
// ✅ 把「不变的子树」作为 children 传入
function Container({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children} {/* ⭐ 元素由外部创建 → 不会重建 */}
</div>
);
}
function App() {
return (
<Container>
<ExpensiveTree /> {/* ⭐ 这个元素对象在 App 里创建,引用不变 */}
</Container>
);
}
原理:React Element 是不可变对象,由「谁创建」决定它的身份。
· 写在 Container 内部 → Container 每次渲染都创建新元素 → 重渲染
· 由 App 创建并传入 → Container 渲染时 children 是「同一个对象」
→ React 发现元素相同(reference equal)→ 跳过整棵子树
3) 状态下移
// ❌ 状态在高层 → 每次输入都让整棵树重渲染
function PageBad() {
const [query, setQuery] = useState('');
return (
<div>
<SearchBar query={query} onChange={setQuery} />
<ExpensiveChart /> {/* ⚠️ 被无谓地重渲染 */}
</div>
);
}
// ✅ 状态下移到真正用它的地方
function PageGood() {
return (
<div>
<SearchSection /> {/* ⭐ 状态在内部,只影响自己 */}
<ExpensiveChart /> {/* ✅ 不受影响 */}
</div>
);
}
function SearchSection() {
const [query, setQuery] = useState('');
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultList query={query} />
</>
);
}
4) 把「变化的部分」与「不变的部分」拆开(技术手段)
// 场景:父组件有一个「只影响自身」的状态,但需要包裹一个静态子树
function Layout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="layout">
<aside className={sidebarOpen ? 'open' : ''}>...</aside>
<main>
<button onClick={() => setSidebarOpen((v) => !v)}>切换侧栏</button>
{children} {/* ⭐ children 不受 sidebarOpen 影响 */}
</main>
</div>
);
}
五、React.memo 的浅比较
// memo 默认用 Object.is 逐个比较每个 prop(浅比较)
const Child = React.memo(ChildComponent);
// 自定义比较函数(返回 true 表示「相等 → 跳过渲染」)
const Child2 = React.memo(ChildComponent, (prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id; // ⚠️ 只比 id
// ⚠️ 注意:返回 true 意味着「props 相等,不要重渲染」
});
| props 类型 | 浅比较结果 |
|---|---|
原始值相同(1 === 1、'a' === 'a') |
✅ 相等 → 跳过渲染 |
| 同一个对象引用 | ✅ 相等 |
| 内容相同但新对象 | ❌ 不等 → 重渲染 |
| 内联箭头函数 | ❌ 不等 → 重渲染 |
children(外部创建且父未重渲染) |
✅ 相等 |
children(父重渲染时新建的元素) |
❌ 不等 |
// memo + 稳定引用的完整正确用法
function Parent({ items, onSelect }: { items: Item[]; onSelect: (id: string) => void }) {
// ① 稳定函数引用
const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
// ② 稳定对象引用
const options = useMemo(() => ({ sort: 'asc' }), []);
// ③ 稳定数组引用(如果 items 来自 props,父组件自己稳定它)
return <Child items={items} onSelect={handleSelect} options={options} />;
}
六、验证实验(建议自己跑一遍)
// 用一个简单的组件树观察渲染范围
function App() {
const [count, setCount] = useState(0);
console.log('App 渲染');
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
<Static /> {/* 无 props */}
<MemoChild value={1} /> {/* memo + 原始值 */}
<UnstableChild onClick={() => {}} /> {/* 内联函数 props */}
<ChildrenContainer>
<Static /> {/* children 透传 */}
</ChildrenContainer>
</div>
);
}
| 组件 | 点按钮时是否重渲染 | 原因 |
|---|---|---|
App |
✅ | state 变化 |
Static(函数组件,无 props) |
✅ | 父组件重渲染 → 子组件函数被调用 |
MemoChild(memo + 原始值 props) |
❌ | props 浅比较相等 |
UnstableChild(内联函数 props) |
✅ | 函数引用每次不同 → memo 失效 |
ChildrenContainer 的 children |
❌ | 元素由 App 创建,App 重渲染时… ⚠️ 实际会重渲染(见下) |
⚠️ 一个细节纠正:
App 自己重渲染 → 它会【重新创建】<Static /> 元素
→ children 的元素对象变了 → ChildrenContainer 的 children prop 变了
→ children 里的 Static 会重渲染
所以「children 透传」生效的前提是:
【创建 children 的那个组件本身没有重渲染】
正确的用法是「三层」:
App(不重渲染)
└── Container(有 state,会重渲染)
└── children = <ExpensiveTree />(App 创建,引用不变 → 跳过)
七、常见坑
// ① 以为「子组件没有 props 就不会重渲染」
<Child /> // ⚠️ 父组件重渲染时它依然会重渲染
// ② 加了 memo 但 props 里有内联函数/对象
<Child onClick={() => {}} /> // ⚠️ memo 失效
// ③ 用 memo 包裹「每次都要更新」的组件(memo 的成本 > 收益)
// ④ 以为「重渲染 = DOM 更新」
// → 重渲染只是「函数被调用」;diff 后可能没有任何 DOM 操作
// ⑤ 把所有状态放在顶层(导致大范围重渲染)
// ⑥ 用 Context 传高频数据(所有消费者重渲染)
// ⑦ 在渲染中创建组件(每次都是新 type → 整个子树重建)
function Parent() {
function Child() { return <div />; } // ❌
return <Child />;
}
// ⑧ 认为「用 useMemo 包住 JSX 能避免重渲染」
const el = useMemo(() => <Child />, []);
return <div>{el}</div>; // ✅ 这个技巧确实有效(元素引用稳定)
// ⚠️ 但它等价于「把元素提到组件外」的效果,不如直接用 children/拆分
// ⑨ 用 memo + 自定义比较函数却比较错(返回 true 但实际需要更新)
// ⑩ 认为「状态提升后性能更好」(恰恰相反,传播范围更大)
// ⑪ 忘记「Context 更新会穿透 memo」
// ⑫ 用「渲染计数」判断性能问题(应该用 Profiler 看耗时与次数)
// ⑧ 的说明:useMemo 包裹 JSX 是一种「局部 children 透传」
function Parent() {
const [count, setCount] = useState(0);
const staticTree = useMemo(() => <ExpensiveTree />, []); // ⭐ 引用稳定
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{staticTree} {/* ✅ 元素对象没变 → React 跳过子树 */}
</div>
);
}
// ⚠️ 更好的做法通常是:把 <ExpensiveTree /> 作为 children 传进来(更清晰)
八、面试延伸
- 「什么会触发 React 的重渲染?」
六种:① 本组件的setState/dispatch;② 父组件重渲染(父组件重新执行时会重新调用子组件);③ 订阅的 Context 或外部 store 变化;④forceUpdate(类组件);⑤useSyncExternalStore订阅的值变化。不会触发的情况包括:修改ref.current、修改组件外变量、直接修改 state 对象的属性(引用不变)、setState传入「引用相同的值」(可能 bailout)。 -
「为什么父组件重渲染会让子组件也重渲染?」
因为 React 的更新是「自顶向下」的:父组件重新执行 → 产生一棵新的元素树(其中包含新的<Child />元素对象)→ React 需要「先用新元素重新渲染 Child(调用 Child 函数)」才能得到它的子树,然后再与旧的 fiber 比较。所以「比较之前必须先渲染」。这也是为什么「重渲染」不等于「DOM 更新」——diff 后可能发现没有差异,DOM 就不会动。 -
「怎么避免子组件不必要的重渲染?」
四种手段:①React.memo——浅比较 props,相等就跳过(前提是 props 引用稳定);②children透传——把不变的子树由「上层」创建并传入,容器组件重渲染时它不会被重建;③ 状态下移——把状态放到真正使用它的组件里,缩小重渲染范围;④useMemo包裹静态 JSX——让元素引用稳定(等价于局部的 children 技巧)。此外还有「拆分为多个更小的组件」与「用外部 store + 选择器订阅」。 -
「
React.memo什么情况下会失效?」
当任何一个 prop 的引用发生变化时:① 内联箭头函数(onClick={() => {}});② 对象/数组字面量(options={{ a: 1 }});③ 每次渲染新建的数组(items={list.filter(...)});④ 上游传下来的不稳定引用(父组件没用useCallback/useMemo)。因为memo用的是浅比较(Object.is),引用不同就认为「变了」。所以正确用法是「memo + 稳定引用」——两者缺一不可。 -
「为什么
children透传能减少重渲染?」
因为「React Element 是不可变对象,它的身份由「谁创建」决定」。如果子树写在容器组件内部,容器每次重渲染都会创建新的元素对象 → 子树重渲染;如果子树由上层组件创建并作为children传入,只要上层没有重渲染,children就是同一个元素对象 → React 发现元素引用相同 → 跳过整棵子树。注意前提:创建children的那个组件本身不能重渲染。 -
「重渲染一定是坏事吗?」
不是。「渲染」只是函数被调用(render 阶段),之后 React 会 diff,如果没差异就不会有任何 DOM 操作。所以:① 大多数组件的渲染成本很低(几个函数调用 + 对象创建),不需要优化;② 只有「渲染成本高」(大列表、复杂计算)或「渲染次数极频繁」(每秒几十次)时才值得优化;③ 盲目加memo反而会增加「比较 props」的开销与代码复杂度。判断依据应该是 Profiler 测量的耗时,而不是渲染次数。 -
「状态提升会让性能变差吗?」
可能会。状态提升到高层后,状态变化会让更多组件重渲染(因为传播范围更大)。比如把「搜索框的输入值」提升到 App 层,那么每次输入都会让 App 及其整棵子树重渲染。所以原则是「状态放在需要它的最近的公共祖先」——既不要过高(传播过大),也不要过低(无法共享)。如果共享范围确实很大,考虑用「状态下移 + Context 拆分 + 选择器订阅」的方式优化。 -
「怎么定位「哪些组件在不必要地渲染」?」
用 React DevTools 的 Profiler:① 录制一次交互;② 看 Flamegraph(每个组件的渲染耗时)、Ranked(按耗时排序);③ 打开「Highlight updates when components render」功能,会直接在页面上高亮渲染的组件——哪个区域闪得多,哪里的渲染就频繁。另外可以在组件里临时加console.log或用useEffect记录渲染次数来快速验证。
一句话速记
触发重渲染的是「setState / 父组件重渲染 / Context 变化 / forceUpdate / 外部订阅」;改
ref.current、改组件外变量、直接改 state 属性都不会触发;父组件重渲染会让整棵未被 memo 拦住的子树也重渲染(因为「比较之前必须先渲染」),所以「重渲染 ≠ DOM 更新」;阻断四招是「React.memo(必须 props 引用稳定)、children透传(元素由上层创建且上层未重渲染)、状态下移、useMemo包裹静态 JSX」;优化前先用 Profiler 测量,别只看渲染次数。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!