
一句话:减少重渲染的优先级排序是——「状态下移 → 组件拆分 →
children透传 → 引用稳定 +memo→ 外部 store 选择器」;结构性手段优先于工具性手段。
一、先理解传播机制
默认规则(见 4.6):
状态变化 → 该组件重渲染 → 它的子组件【也会重渲染】
(因为父组件重新执行时会重新创建子元素)
所以要减少重渲染,有两条路:
① 让「状态变化的影响范围」更小(结构手段)
② 在「传播路径上」加拦截(工具手段)
⭐ 优先①:它不增加复杂度、不依赖引用稳定、也不容易踩坑
二、手段一:状态下移(最优先)
// ❌ 状态在高层 → 每次输入都让整页重渲染
function PageBad() {
const [query, setQuery] = useState('');
const [showSidebar, setShowSidebar] = useState(true);
console.log('Page 渲染');
return (
<div>
<Sidebar open={showSidebar} />
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultList query={query} />
<ExpensiveChart /> {/* ⚠️ 每次输入都跟着重渲染 */}
</div>
);
}
// ✅ 状态下移:把状态放到真正使用它的地方
function PageGood() {
const [showSidebar, setShowSidebar] = useState(true); // 只有它影响的范围小
console.log('Page 渲染(只在切换侧栏时)');
return (
<div>
<Sidebar open={showSidebar} />
<SearchSection /> {/* ⭐ 输入状态在内部 */}
<ExpensiveChart /> {/* ✅ 不受输入影响 */}
</div>
);
}
function SearchSection() {
const [query, setQuery] = useState('');
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultList query={query} />
</>
);
}
判断标准:
「这个状态需要被其它组件读取/修改吗?」
├─ 不需要 → 放到「最近的使用它的组件」里(下移)
└─ 需要 → 提到「最近的公共祖先」(但不要更高)
三、手段二:组件拆分
// ❌ 一个巨型组件:任何一个状态变化都让整个组件重渲染
function Dashboard() {
const [filter, setFilter] = useState('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
console.log('Dashboard 渲染'); // ⚠️ 任何一个状态变化都会打印
return (
<div>
<FilterBar value={filter} onChange={setFilter} />
<ChartArea />
<TableArea selectedId={selectedId} onSelect={setSelectedId} />
<Modal open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}
// ✅ 拆分:每个区域自己管理自己的状态
function DashboardGood() {
return (
<div>
<FilterSection /> {/* 自己管 filter */}
<ChartArea /> {/* 完全静态,永远不因外部状态重渲染 */}
<TableSection /> {/* 自己管 selectedId */}
<ModalSection /> {/* 自己管 modalOpen */}
</div>
);
}
拆分的原则:
① 按「状态的所有权」拆(谁的状态就放在谁的组件里)
② 把「静态子树」独立出来(它永远不需要因外部状态更新)
③ 把「昂贵子树」独立出来(让它可以被 memo 隔离)
④ 组件粒度别过细(拆太碎会增加层级与 props 传递)
四、手段三:children 透传
// ❌ 容器组件有状态 + 内部直接写子树 → 子树跟着重渲染
function ContainerBad({ data }: { data: Data }) {
const [count, setCount] = useState(0);
console.log('Container 渲染');
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ExpensiveTree data={data} /> {/* ⚠️ 会跟着重渲染 */}
</div>
);
}
// ✅ 把「不依赖状态」的子树作为 children 传入
function ContainerGood({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children} {/* ⭐ 由外部创建的元素,引用不变 → 跳过 */}
</div>
);
}
function App({ data }: { data: Data }) {
return (
<ContainerGood>
<ExpensiveTree data={data} /> {/* 由 App 创建 */}
</ContainerGood>
);
}
为什么有效?
React Element 是「不可变对象」,由「谁创建」决定身份:
· 写在 Container 内部 → Container 每次渲染都创建新元素 → 子树重渲染
· 由外部(App)创建并传入 → Container 渲染时 children 是同一个对象
→ React 发现元素引用相同 → 跳过整棵子树
⚠️ 前提:创建 children 的那个组件(App)本身没有重渲染。
// 变体:用 useMemo 包裹「静态 JSX」(局部版 children 技巧)
function Component() {
const [count, setCount] = useState(0);
const staticTree = useMemo(() => <ExpensiveTree />, []); // ⭐ 引用稳定
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{staticTree}
</div>
);
}
五、手段四:引用稳定 + memo
// 只有当「子组件是 memo 的」且「props 引用稳定」时,才有效
const Row = React.memo(function Row({ item, onDelete }: RowProps) {
console.log('Row 渲染', item.id);
return (
<li>
{item.name}
<button onClick={() => onDelete(item.id)}>删除</button>
{/* ⚠️ 这里的箭头函数是 Row 内部的,不影响 Row 自身的 memo */}
</li>
);
});
function List({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
// ⭐ 稳定回调引用
const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]);
return (
<ul>
{items.map((it) => (
<Row key={it.id} item={it} onDelete={handleDelete} />
// ↑ 引用稳定 → 只有 item 变化时才重渲染
))}
</ul>
);
}
⚠️ 常见的「memo 白加」情况:
① props 里有内联箭头函数 → 引用变化 → 失效
② props 里有对象/数组字面量 → 失效
③ 「链路上层」没有 memo → 上层重渲染会重新创建子元素 → 失效
④ 每次渲染都会 filter/map 出新数组 → 失效
所以顺序是:先「稳定引用」(useCallback/useMemo/提到模块外),再 memo。
六、手段五:外部 store 的选择器订阅
// 场景:状态需要跨组件共享,但变化频繁(用 Context 会全量重渲染)
// ✅ 用 Zustand 的选择器订阅
const useStore = create<{ a: number; b: number }>(() => ({ a: 0, b: 0 }));
function OnlyA() {
const a = useStore((s) => s.a); // ⭐ 只订阅 a
console.log('OnlyA 渲染'); // b 变化时不会渲染
return <div>{a}</div>;
}
| 方案 | 重渲染范围 |
|---|---|
| Context | 所有消费者(value 变了就全量) |
| Redux/Zustand 选择器 | 只有选中值变化的组件 |
useSyncExternalStore + 自研选择器 |
细粒度(需要自己实现) |
七、优化决策顺序
遇到「重渲染过多」问题时的推荐顺序(从结构到工具):
① 状态下移 —— 让状态离使用者更近(成本最低、收益最大)
② 组件拆分 —— 把「静态」「昂贵」「独立状态」的子树分开
③ children 透传 —— 把不依赖状态的子树从「内部」移到「外部」
④ 引用稳定 —— 常量提到模块外、useCallback/useMemo 稳定必要引用
⑤ React.memo —— 在前三步都做过之后再考虑(否则大概率失效)
⑥ Context 拆分 / 状态库选择器 —— 跨组件共享的场景
⑦ useTransition / useDeferredValue —— 无法减少「工作量」时,降低它的优先级
⑧ 虚拟滚动 —— 真正的大列表问题(DOM 数量是根源)
⚠️ 为什么不先上 memo?
· memo 依赖「引用稳定」,而引用稳定又要写 useCallback/useMemo
· 这一串操作增加了代码复杂度,而且经常「看起来加了优化,实际没生效」
· 而「状态下移」不需要任何额外机制,只是把状态挪个位置
→ 所以结构性手段优先。
八、常见坑
// ① 只加 memo,不改结构(大概率失效)
// ② 给所有组件加 memo(增加比较开销与维护成本)
// ③ 提升状态后「忘记自己也重渲染了」
// ④ children 透传但「创建 children 的组件也在重渲染」
function App() {
const [x, setX] = useState(0); // ⚠️ App 自己在重渲染
return <Container><ExpensiveTree /></Container>; // → children 每次都重建
}
// ✅ 把有状态的组件下沉,让 App 保持稳定
// ⑤ 用「memo + 自定义比较函数」却比较错(该更新时不更新)
// ⑥ 把「状态」放到 Context 里然后期待 memo 拦截(拦不住)
// ⑦ 为了「减少重渲染」而把状态放进 ref(UI 不更新了)
// ⑧ 用「重渲染次数」而不是「耗时」判断优化效果
// ⑨ 优化了「渲染次数」但没优化「单次耗时」(瓶颈仍在)
// ⑩ 忘记「每次渲染都新建的对象/数组/函数」是最常见的失效原因
// ⑪ 用 key 变化来「避免重渲染」(其实是卸载重建,更慢)
// ⑫ 拆分过度(组件太多、props 传递混乱,可读性下降)
九、面试延伸
- 「怎么减少 React 的重渲染?」
按优先级:① 状态下移——把状态放到真正使用它的组件里,缩小影响范围(成本最低、最有效);② 组件拆分——按「状态所有权」与「静态/昂贵子树」拆分;③children透传——把不依赖状态的子树由外层创建并传入,容器重渲染不会重建它;④ 引用稳定 +React.memo——先让 props 引用稳定(useCallback/useMemo/常量提到模块外),memo 才生效;⑤ 外部 store 的选择器订阅(跨组件高频共享时)。结构性手段优先于工具性手段。 -
「为什么优先『状态下移』而不是
memo?」
因为memo生效需要「所有 props 引用稳定」——这又要求写useCallback/useMemo、维护依赖数组,代码复杂度上升且很容易失效(一个内联函数就让全部白费)。而「状态下移」只是把状态声明挪到更近的组件里——不需要任何额外机制、不会失效、也不增加心智负担。它直接缩小了「重渲染的影响范围」,是性价比最高的手段。 -
「
children透传为什么能减少重渲染?」
因为 React Element 是不可变对象,它的身份由「谁创建」决定。如果子树写在容器组件内部,容器每次重渲染都会创建新的元素对象 → 子树重渲染;如果子树由上层组件创建并作为children传入,只要上层没有重渲染,children就是同一个元素对象 → React 发现引用相同 → 跳过整棵子树的渲染。前提是「创建children的那个组件本身是稳定的」。 -
「
memo为什么经常失效?」
因为memo用的是浅比较——只要任何一个 prop 的引用变了就失效。最常见的四个原因:① 内联箭头函数(onClick={() => {}});② 对象/数组字面量(options={{ a: 1 }});③ 每次渲染新创建的派生数据(items={list.filter(...)});④ 链路上层没有 memo(上层重渲染会重新创建子元素,导致 props 变化)。所以「memo 必须配合引用稳定使用」。 -
「状态提升会让性能变差吗?」
可能。状态提升到高层后,它的变化会让更多组件重渲染(传播范围更大)。比如把「搜索框的输入值」提升到 App 层,每次输入都会让 App 及整棵子树重渲染。所以原则是「状态放在「需要它的最近的公共祖先」」——不要比这个位置更高。如果共享范围确实很大(跨路由、跨页面),则应改用「Context 拆分 + 状态库选择器订阅」,而不是继续提升。 -
「怎么判断优化是否真的有效?」
三条:① 用 Profiler 对比优化前后的「渲染次数」与「耗时」(不能只看次数,因为「渲染 10 次 × 0.1ms」本来就无所谓);② 看用户体验指标(输入是否跟手、交互响应时间、FPS);③ 确认没有引入新问题(如「该更新的没更新」——这是 memo/自定义比较函数最常见的副作用)。「没有测量就没有优化」——凭感觉加 memo 经常是负优化。 -
「
useTransition和『减少重渲染』是什么关系?」
它们是不同层面的手段:减少重渲染(下移/拆分/children/memo)是「做更少的工作」;useTransition/useDeferredValue是「在工作量不变的情况下,让不紧急的工作让路」——昂贵更新仍会执行,只是不阻塞紧急更新(如输入回显)。所以在「无法进一步减少工作量」时(比如必须渲染一个大列表),用它们改善可交互性;而真正的「DOM 太多」问题要靠虚拟滚动。 -
「大列表卡顿怎么优化?」
分三层:①key用稳定唯一 ID(让 React 能正确复用节点);② 行组件memo+ 稳定的 props(处理函数用useCallback或用data-*+ 事件委托,避免每行一个闭包);③ 虚拟滚动——只渲染可视区域的几十个节点,用占位元素撑起总高度(把 DOM 节点从「1 万」降到「几十」)。如果列表是「过滤/排序」的结果,还要注意「不要在渲染中反复 filter/sort」(用useMemo);如果输入驱动过滤,可以用useDeferredValue让输入保持流畅。
一句话速记
减少重渲染的顺序是「状态下移(最优先)→ 组件拆分 →
children透传 → 引用稳定 +memo→ 外部 store 选择器 →useTransition→ 虚拟滚动」;结构性手段优先(memo依赖引用稳定,容易失效、增加复杂度);children透传有效的原理是「元素由谁创建决定身份」(前提是创建者不重渲染);memo失效的四大原因是「内联函数、对象/数组字面量、每次新造的派生数据、链路上层没 memo」;判断优化效果看 Profiler 的耗时而非渲染次数。



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