REACT · Vol.VI · LESSON 37 · 性能优化与部署
重渲染剖析:memo 何时真正有用
重渲染的三个入口,只有三个
上一篇第 36 篇收官卷五:路由管「去哪」,Query 管「数据从哪来」,骨架齐了。卷六回答下一个问题——快不快。第一站先把「重渲染」这个词钉准确,结论拍在这:组件重渲染,只有三个入口:
- 自身 setState(useReducer 的 dispatch 也算);
- Context 变化——订阅了该 Context 的组件跟着重渲染(第 15 篇);
- 父组件重渲染——父渲染,子默认跟着渲染,不管 props 变没变。
注意第三条:很多人以为「props 变了所以子组件重渲染」——这是因果倒置。props 变化不是触发器,它是父组件重渲染的伴生结果:父渲染时带着新 props 往下传,子组件默认重跑一遍。用代码验证:
import { useState } from "react";
function Child({ label }: { label: string }) {
console.log("Child 渲染了");
return <p>{label}</p>;
}
function Parent() {
const [count, setCount] = useState(0);
console.log("Parent 渲染了");
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
{/* label 从没变过,但 Parent 一渲染,Child 必跟着渲染 */}
<Child label="固定文案" />
</div>
);
}
点五下按钮,控制台五组「Parent + Child」成对出现。可 Child 渲染了,DOM 动了吗?没有。重渲染是「重新执行函数组件 + 与上次输出做 diff」(第 5 篇)——Child 两次输出一样,diff 结果为空,DOM 纹丝不动。记住:重渲染 ≠ DOM 更新。固定成本是「函数执行 + diff」,通常便宜,贵的例外是组件树庞大、列表超长。优化的目标不是「零重渲染」,而是砍掉「输入没变还全量重算」的浪费。
重渲染像「把报表再算一遍」——重新执行只读方法;DOM 更新才像「写库」。只读方法跑一百遍数据库毫发无损,只是费 CPU。优化思路不是「让方法别跑」(那会破坏 UI = f(state) 的正确性),而是让「入参没变的方法」直接返回上次结果——这不就是缓存嘛,React.memo 马上登场。
默认传染:一个输入框,全家跟着跑
看一个真实形状的页面。订单页 OrderPage 持有关键词状态,侧边栏和订单表都不依赖它:
import { useState } from "react";
function SidePanel() {
console.log("SidePanel 渲染了");
return <aside>侧边栏(与关键词无关)</aside>;
}
function OrderTable({ orders }: { orders: string[] }) {
console.log("OrderTable 渲染了");
return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}
export default function OrderPage() {
const [keyword, setKeyword] = useState("");
const orders = ["ORD-1001", "ORD-1002", "ORD-1003"];
return (
<div>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)} // 每敲一键触发一次 setState
/>
<SidePanel />
<OrderTable orders={orders} />
</div>
);
}
每敲一个字符,控制台三行一组地刷:OrderPage → SidePanel → OrderTable。订单表要是有 200 行、每行 8 个节点,等于每敲一键做一次两千节点规模的函数重跑 + diff。关键词只影响搜索框,另外两个组件纯属陪跑——重渲染会沿组件树向下传染,默认没有免疫机制。
默认重渲染像全量跑批:上游批次一到,下游所有任务无条件重跑。你要的是增量——入参没变的任务直接跳过。React 给的开关就是 React.memo:给组件声明「入参没变就别叫我」。
React.memo:浅比较这道闸门
React.memo 是包在组件外面的一层 HOC(第 26 篇见过的模式):重渲染从父组件传染过来时,memo 先拦一道,对旧 props 和新 props 做一遍浅比较——每个 key 用 Object.is 比对;全部相等就跳过整个组件的渲染,直接复用上次输出。试试:
import { memo } from "react";
// memo 包裹:重渲染传染过来时先做 props 浅比较,全相等就跳过
const OrderTable = memo(function OrderTable({ orders }: { orders: string[] }) {
console.log("OrderTable 渲染了");
return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
});
export default function OrderPage() {
const [keyword, setKeyword] = useState("");
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{/* 坏了:每次渲染都新建数组,引用必不同,memo 白包 */}
<OrderTable orders={["ORD-1001", "ORD-1002", "ORD-1003"]} />
</div>
);
}
每敲一键,OrderTable 照样渲染——数组字面量每次渲染都新建数组,引用必然不同,浅比较必然失败。这是 memo 最经典的失效场景,重灾区列全:
- 对象 / 数组字面量:
orders={[...]}、style={{ color: "red" }}; - 内联箭头函数:
onClick={() => doIt()}——每次新引用; - children 塞 JSX:
<Card><b>内容</b></Card>的 children 也是 props,同理。
memo 只看引用,不看内容。修复的钥匙第 11 篇早就给过你:useMemo 钉值的引用,useCallback 钉函数的引用:
import { useCallback, useMemo, useState } from "react";
export default function OrderPage() {
const [keyword, setKeyword] = useState("");
// 值用 useMemo 钉住引用:依赖不变就不造新数组(第 11 篇)
const orders = useMemo(
() => ["ORD-1001", "ORD-1002", "ORD-1003"],
[]
);
// 函数用 useCallback 钉住引用:keyword 变了才换新函数
const handleExport = useCallback(() => {
console.log("导出订单,关键词:", keyword);
}, [keyword]);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<OrderTable orders={orders} onExport={handleExport} />
</div>
);
}
现在再敲关键词:OrderPage 渲染(自己 setState,天经地义),SidePanel 和 OrderTable 全部安静——传染被闸门拦下了。
@Cacheable(key = "#orderId") 能命中,因为 String 的 hashCode 稳定;每次 new 一个没重写 equals 的对象当 key,缓存永远 miss。memo 的 props 浅比较就是「对入参做相等性检查」,内联对象 / 函数 = 每次都换 key = 永远 miss。所以 useMemo / useCallback 不是点缀,它们是 memo 这张缓存表的 key 稳定性保证——先有稳定 key,才谈得上命中。
三件套分工,以及 memo 自身的成本
useMemo、useCallback、memo 经常被混着叫「性能优化三件套」,但各管一段,一张表钉死:
| 工具 | 作用层级 | 管什么 | 后端类比 |
|---|---|---|---|
React.memo | 组件级 | props 浅比较全等 → 跳过整个组件的渲染 | 方法级缓存 @Cacheable |
useCallback | 值级(函数) | 函数引用稳定,供 memo 比对、供依赖数组用 | 稳定的缓存 key |
useMemo | 值级(任意值) | 对象 / 数组引用稳定 + 昂贵计算结果缓存 | 稳定的 key + 结果缓存 |
记忆口诀:memo 管「组件跳不跳过」,useCallback 管「函数引用稳不稳」,useMemo 管「值引用稳不稳、重算要不要」。useCallback 单独用几乎没意义,它和 memo 是配套的。
另一半真相:memo 不是免费的。每次父组件渲染它都要做一遍 props 比对;组件越小越纯,「重渲染」越便宜,「比对」的相对开销越高——给渲染成本约等于零的小组件包 memo 是负优化。memo 留给「渲染贵、props 经常不变」的体重级选手:列表项、大表格、图表。
谁是体重级选手?先测量,再优化。用 React DevTools 的 Profiler 录一次交互,看谁渲染次数多、单次耗时长;也可以临时包一层 <Profiler> 组件,在 onRender 回调里拿 id / phase / duration 给热点排序(第 41 篇细讲)。
标准流程由此固定:测量 → 找热点 → 稳定引用 → memo 包热点 → 再测量确认。跳过第一步的优化,大概率是 memo 撒胡椒面:代码量翻倍,热点那一个组件的收益却被平均掉。
- 重渲染只有三个入口:自身 setState、Context 变化(第 15 篇)、父组件渲染。「props 变了」不是独立入口,是父渲染的伴生结果。
- 重渲染 ≠ DOM 更新:函数重跑 + diff(第 5 篇),DOM 多半纹丝不动;要防的是大树里的无效重跑。
- memo 是 props 浅比较的闸门:内联对象 / 函数每次都是新引用,必穿闸——useMemo / useCallback 稳定引用是 memo 生效的前提(第 11 篇)。
- memo 有比较成本:小而纯的组件别包;先 Profiler 测量(第 41 篇),对「渲染贵、props 稳」的热点下手。
- 分工:memo 管组件跳过,useCallback 管函数引用,useMemo 管值引用与昂贵计算——三件套要配套使用。
收官:memo 治不了的病,留给虚拟列表
把本篇的判断收进速查表,下次遇到「页面卡」对着开方:
| 症状 | 处方 | 出处 |
|---|---|---|
| 父组件一打字,无关子组件跟着跑 | memo + 稳定引用,挡住传染 | 本篇 |
| 包了 memo 还是每次都跑 | 揪出 props 里的内联对象 / 函数,useMemo / useCallback 钉住 | 本篇 + 第 11 篇 |
| 列表项全都 memo 了,滚动还是卡 | 病根是节点数量,不是重渲染——换思路 | 第 38 篇 |
| 不知道该从哪优化 | 先 Profiler 测出热点,再动手 | 第 41 篇 |
本章回顾
重渲染只有三个入口,且重渲染 ≠ DOM 更新——只是函数重跑加一次 diff,多数时候便宜且正确。memo 的价值是用 props 浅比较拦住「输入没变还重跑」的浪费,但命门是引用:内联对象和函数会让闸门永远失效,useMemo / useCallback 因此是配套件;memo 自身也有比较成本,先测量、对热点下手。然而 memo 有治不了的病:列表有 1 万行时,哪怕每行都 memo、一次都不重渲染,几万个 DOM 节点的挂载与布局照样卡——它省的是重复劳动,省不出绝对工作量。下一篇第 38 篇换釜底抽薪的思路:虚拟列表——1 万行数据,只渲染看得见的那 30 行。
Comments · 评论