
一句话:Hook 的开销分三类——「调用开销(链表节点访问,常量级)」「依赖比较(逐项
Object.is,与依赖数量成正比)」「闭包与对象创建(内存分配)」;单次调用通常是纳秒级,真正的成本来自「渲染次数 × 每次渲染里的分配」——所以优化顺序永远是「先减少渲染次数」,而不是「先减少 Hook 数量」。
一、先建立正确的心智模型
⭐ 一个 Hook 调用在 React 内部做了什么(简化):
useMemo(fn, deps):
① 从「当前 fiber 的 Hook 链表」按顺序取到对应的 hook 节点 ← O(1)
② 逐个比较 deps 与上次的 deps(Object.is) ← O(deps.length)
③ 若不相等 → 调用 fn;否则复用上次的值
④ 把「值 + deps 数组」存回 hook 节点 ← 写内存
⑤ 创建/返回结果
useCallback(fn, deps): 与 useMemo 同构(缓存的是 fn 本身)
useState(init):
① 取 hook 节点(首次时创建) ← O(1)
② 返回 [state, dispatch](dispatch 引用稳定,注意这里要创建数组)← 一次数组分配 ⚠️
useRef(v):
① 取/创建 hook 节点,节点里存 { current: v } ← 一次对象分配(仅首次)
useEffect(fn, deps):
① 取 hook 节点
② 比较 deps ← O(deps.length)
③ 若不相等 → 推进 effect 队列(渲染后统一执行) ← 数组 push
⭐⭐ 从上面能看到三个「成本来源」:
① 【链表访问】——常量级,几乎可以忽略(就是几个指针跳转)
② 【依赖比较】——与「依赖数组长度 × 渲染次数」成正比
③ 【内存分配】——每次渲染都要分配「返回的数组」「对象字面量」「内联函数」;
这是真实成本(触发 GC),也是「为什么 `useMemo` 包字面量反而更贵」的原因之一
⭐ 一句话结论(先给答案,再验证):
· 把「Hook 数量从 20 个减到 10 个」→ 收益极小(常数级差异)
· 把「一次交互触发的渲染次数从 8 次减到 2 次」→ 收益 4 倍
· 把「每次渲染都在创建的大数组」用 useMemo 缓存 → 取决于下游是否 memo
→ 所以「优化渲染次数」永远优先于「优化 Hook 开销」。
二、完整可运行的微基准(130 行)
下面这套代码用来实测上面那些结论。请在自己的环境里跑(不同机器/浏览器差异很大,但量级关系是稳定的)。
// bench/HookBench.tsx —— 一个把「Hook 开销」量出来的实验台
import { useCallback, useMemo, useRef, useState, useEffect, Profiler } from 'react';
/** ============ 工具:把「同步执行 N 次的耗时」测出来 ============ */
function bench(label: string, iterations: number, fn: () => void) {
// 预热(让 JIT 编译生效,避免第一次的噪声)
for (let i = 0; i < Math.min(iterations, 1000); i++) fn();
const t0 = performance.now();
for (let i = 0; i < iterations; i++) fn();
const total = performance.now() - t0;
const perCall = (total * 1e6) / iterations; // 纳秒/次
console.log(`[bench] ${label}: 总计 ${total.toFixed(1)}ms / ${iterations} 次 = ${perCall.toFixed(1)} ns/次`);
return perCall;
}
/** ============ 实验一:依赖数组长度对「比较成本」的影响 ============ */
export function DepsBench() {
const [tick, setTick] = useState(0);
const results = useRef>({});
const runDepsBench = () => {
// 构造 200 项依赖(模拟「大依赖数组」)
const deps200 = Array.from({ length: 200 }, (_, i) => i);
bench('比较 2 项依赖', 200_000, () => {
let eq = true;
for (let i = 0; i < 2; i++) if (!Object.is(deps200[i], deps200[i])) eq = false;
return eq;
});
bench('比较 200 项依赖', 200_000, () => {
let eq = true;
for (let i = 0; i < 200; i++) if (!Object.is(deps200[i], deps200[i])) eq = false;
return eq;
});
// ⭐ 预期:200 项大约是 2 项的「数十倍」,但绝对量仍是纳秒级
};
return (
已跑 {tick} 次(结果看控制台)
);
}
/** ============ 实验二:闭包/对象创建的分配成本 ============ */
export function AllocationBench() {
const run = () => {
bench('创建 1 个箭头函数', 1_000_000, () => {
const fn = () => 1; // 每次都是一次函数对象分配
return fn;
});
bench('创建 1 个对象字面量', 1_000_000, () => {
const obj = { a: 1, b: 2 };
return obj;
});
bench('创建 1 个 100 项数组 (map)', 50_000, () => {
const arr = new Array(100).fill(0).map((_, i) => i * 2); // ⚠️ 真实业务里很常见
return arr;
});
bench('创建 1 个 10_000 项数组 (map)', 500, () => {
const arr = new Array(10_000).fill(0).map((_, i) => i * 2);
return arr;
});
// ⭐ 预期:小对象/函数是「纳秒级」;大数组是「微秒~毫秒级」
// → 所以要缓存的不是「函数」,而是「大数组/大对象」
};
return ;
}
/** ============ 实验三:Hook 数量对「一次渲染」的影响 ============ */
function ManyHooks({ count }: { count: number }) {
// 动态数量会破坏 Hook 规则,所以这里用「固定上限 + 条件返回」的替身:
// 实际做法是「用 5 个组件分别声明 5/20/50 个 Hook」
const values: number[] = [];
for (let i = 0; i < count; i++) values.push(i);
return <>{values.length}>;
}
// 用「真·Hook」写三档对比(Hook 必须在顶层,所以手写三份)
function Hooks5() {
const [a] = useState(1), [b] = useState(2), [c] = useState(3), [d] = useState(4), [e] = useState(5);
const m1 = useMemo(() => a + 1, [a]), m2 = useMemo(() => b + 1, [b]);
const m3 = useMemo(() => c + 1, [c]), m4 = useMemo(() => d + 1, [d]), m5 = useMemo(() => e + 1, [e]);
return {m1 + m2 + m3 + m4 + m5};
}
function Hooks20() {
const s = [useState(1), useState(2), useState(3), useState(4), useState(5),
useState(6), useState(7), useState(8), useState(9), useState(10),
useState(11), useState(12), useState(13), useState(14), useState(15),
useState(16), useState(17), useState(18), useState(19), useState(20)];
const total = useMemo(() => s.reduce((sum, [v]) => sum + v, 0), [s]);
return {total};
}
export function HooksCountBench() {
const [rounds, setRounds] = useState(0);
const run = () => {
const t0 = performance.now();
for (let i = 0; i < 200; i++) {
// 说明:这里无法直接「调用组件函数」,所以用 React 自己的渲染循环来测:
// 见下方 Profiler 版本
}
console.log('[bench] 请用下面的 Profiler 版本测「一次渲染的耗时」', performance.now() - t0);
};
return (
{
if (rounds < 30) console.log(`[Profiler] ${id} ${phase}: ${actualDuration.toFixed(3)}ms`);
}}>
{/* ⭐ 对比方式:注释掉其中一个,观察 actualDuration 的变化 */}
);
}
⭐⭐ 关于「Hook 数量基准」的一个诚实说明:
React 没有暴露「直接调用一个组件函数」的公开 API
(内部有 renderWithHooks,但依赖全局状态,不能随便调)。
所以「Hook 数量 vs 渲染耗时」的正确测法是:
① 写两个只差「Hook 数量」的组件
② 用 或 DevTools Profiler 录制
③ 对比 actualDuration 的差异(多次取中位数)
⚠️ 注意:Profiler 的 actualDuration 分辨率有限(毫秒级,通常精确到 0.005ms),
所以当差异在「微秒级」时,你测出来的可能全是噪声——
这本身就是结论:「Hook 数量的差异在真实渲染里测不出来」。
/** ============ 实验四:Effect 的固定成本 ============ */
export function EffectBench() {
const [n, setN] = useState(0);
// ① 空依赖:只在挂载时执行一次
useEffect(() => { /* 空 */ }, []);
// ② 每次渲染都执行(无依赖)
useEffect(() => { /* 空 */ });
// ③ 依赖 n:只有 n 变化时执行
useEffect(() => { /* 空 */ }, [n]);
return ;
}
// ⭐ 观察:② 会让「每次渲染都排一次 effect 队列」——
// 这在「每秒渲染几百次」的组件里才有意义,日常可以忽略。
⭐ 跑完这四个实验后,你会得到什么结论(而不是数字):
① 「依赖比较」是纳秒级 → 只有「依赖数组极长(>100)且渲染极频繁(每秒数百次)」才需要关心
② 「创建小函数/小对象」是纳秒级 → 缓存它们的收益在「下游是 memo 组件」时才体现
③ 「创建大数组/大对象」是微秒~毫秒级 → ⭐ 这才是真正值得 useMemo 的目标
④ 「Hook 数量」的差异在 Profiler 里测不出来 → 减少 Hook 数量不是有效的优化手段
三、真正的成本:渲染次数 × 每次渲染的分配
⭐⭐ 用一张乘法表理解「为什么会慢」:
总成本 = 渲染次数 × ( 单次渲染的固定开销 + 分配开销 + 用户计算开销 )
· 渲染次数:由「状态变化 + 传播范围」决定(见 4.6、5.4)
· 固定开销:Hook 链表遍历、依赖比较 —— 纳秒级,通常可忽略
· 分配开销:每次渲染创建的函数/对象/数组 —— ⚠️ 与「创建多少个、多大」成正比
· 用户计算:组件里的 sort/filter/map —— 可能到毫秒级 ⚠️
⭐ 举例(量级示意,请自行测量):
一个组件「渲染 8 次 × 每次创建 200 个对象的数组」
→ 成本 ≈ 8 × (200 项数组的创建 + 200 项的下游 diff)
⭐ 优化「渲染次数 8 → 2」比「把数组创建缓存起来」往往收益更大,
因为前者同时减少了「diff、DOM 比较、Effect 比较、GC」所有环节。
// ❌ 常见的「优化方向搞反」
function WrongOrder({ items, keyword }: { items: Item[]; keyword: string }) {
// ① 花力气在「Hook 开销」上:把 5 个 useState 合并成 1 个对象
const [state, setState] = useState({ a: 0, b: 0, c: 0, d: 0, e: 0 });
// ⚠️ 合并后:每次更新都要 { ...state, a: x }(多了一次对象分配 + 传播更粗)
// ② 却忽略了「渲染次数」:这里每次都把整个列表重算 + 让 1000 行全部重渲染
const visible = items.filter((i) => i.name.includes(keyword));
return
;
}
// ✅ 正确顺序
function RightOrder({ items, keyword, onSelect }: Props) {
// ② 先解决「渲染次数」:把状态下移、行组件 memo、处理函数稳定
const visible = useMemo(() => items.filter((i) => i.name.includes(keyword)), [items, keyword]);
const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
return (
);
// ① 再考虑「Hook 开销」:其实没什么可优化的(5 个 useState 完全没问题)
}
| 优化手段 | 影响的成本项 | 相对收益 |
|---|---|---|
| 减少渲染次数(状态下移 / memo / 稳定引用) | 固定开销 + 分配 + 用户计算 + diff + DOM | ⭐⭐⭐⭐⭐ |
| 减少每次渲染的分配(useMemo 缓存大对象) | 分配 + 下游 diff | ⭐⭐⭐(下游是 memo 时) |
| 减少 Hook 数量(合并 useState) | 固定开销(纳秒级) | ⭐(通常测不出) |
| 减少依赖数组长度 | 依赖比较(纳秒级) | ⭐ |
| 把重计算移出渲染(Web Worker / 缓存) | 用户计算(毫秒级) | ⭐⭐⭐⭐ |
四、useMemo / useCallback 自身的成本
// useMemo 的成本 = 「依赖比较」+「缓存读写」+(依赖变化时)「执行 fn」
const value = useMemo(() => compute(a, b), [a, b]);
// useCallback 的成本 = 「依赖比较」+「缓存读写」(比 useMemo 少一次「执行 fn」)
const fn = useCallback(() => doSomething(a), [a]);
⭐⭐ 三个「反直觉」的成本事实:
① useCallback 可能「比不写更贵」
不写:创建 1 个函数(纳秒级分配)
写了:1 次依赖比较 + 1 次缓存读写 + (依赖变了才)1 个新函数
→ 如果「依赖经常变」,你付出了「比较 + 缓存」的固定成本,
却没有换来「引用稳定」的收益 → 净亏
② useMemo 的依赖数组本身要「存起来」
React 需要保存「上一次的依赖数组」用于比较 → 一次数组分配
→ 所以「依赖项很多」时,缓存的成本也在上升
③ 「缓存大对象」的收益 ≠ 「缓存小对象」的收益
缓存 { a: 1 }:省下的是「一次小对象分配」(纳秒级)
缓存 10_000 项数组:省下的是「一次大数组分配 + 可能的 GC 压力」(微秒~毫秒级)
→ 真正值得缓存的是「大的」
// ✅ 用「实测」判断要不要缓存(而不是凭感觉)
function useMeasuredMemo(factory: () => T, deps: unknown[], label: string): T {
const result = useMemo(factory, deps);
if (process.env.NODE_ENV !== 'production') {
// ⚠️ 注意:这只在「依赖变化时」测一次,不适合高频场景;仅用于开发期判断
const t0 = performance.now();
factory();
const cost = performance.now() - t0;
if (cost > 0.5) {
console.info(`[memo-check] ${label} 的计算耗时 ${cost.toFixed(2)}ms → 值得缓存`);
} else {
console.info(`[memo-check] ${label} 的计算耗时 ${cost.toFixed(3)}ms → 缓存收益可能很小`);
}
}
return result;
}
// 使用
const sorted = useMeasuredMemo(() => [...items].sort(cmp), [items], 'sort-items');
五、什么时候「Hook 开销」真的成立
⭐ 只有同时满足下面条件时,Hook 开销才值得优化:
① 组件「渲染极其频繁」——每秒几十到几百次
(如:拖拽跟随、动画帧驱动、滚动驱动的浮层、实时数据流)
② 且「单次渲染里 Hook 数量很多」——几十上百个
(通常是「一个大表单」或「一个被过度抽象的巨型组件」)
③ 且 Profiler 显示「渲染耗时的大部分花在「框架代码」而不是「你的计算」」
⭐ 满足这三条时的实际优化手段(不是「减少 Hook」):
· 把「高频更新的状态」从 React 里拿出来(用 ref + 手动 DOM 更新)
· 用「外部状态库 + 选择器」让只有真正用到的组件重渲染
· 用「useDeferredValue / useTransition」让高频更新降优先级(见 2.10)
· 把「高频组件」从大组件里拆出来(缩小传播范围)
// ✅ 拖拽跟随:把「高频状态」从 React state 移到 ref + 直接改样式
function DraggableBox() {
const boxRef = useRef(null);
const posRef = useRef({ x: 0, y: 0 });
const draggingRef = useRef(false);
const onPointerDown = () => { draggingRef.current = true; };
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
if (!draggingRef.current || !boxRef.current) return;
posRef.current = { x: e.clientX, y: e.clientY };
// ⭐ 直接改 transform,不触发 React 渲染(每秒 60+ 次移动都不怕)
boxRef.current.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0)`;
};
const onPointerUp = () => { draggingRef.current = false; };
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
}, []);
return ;
}
六、本篇特有的坑
// ① 把「减少 Hook 数量」当成优化手段
const [state, setState] = useState({ a, b, c, d, e }); // ⚠️ 省了 4 次「纳秒级调用」
// 却换来:每次更新一次对象展开 + 所有依赖这个对象的组件一起重渲染
// ② 给「每次渲染都新建的小对象」加 useMemo(把成本从「分配」换成「分配 + 比较 + 缓存」)
const opts = useMemo(() => ({ a: 1 }), []); // ⚠️ 除非下游是 memo,否则净亏
// ✅ 常量提到模块作用域(零成本)
const OPTS = { a: 1 };
// ③ 依赖数组「极长」且「每次都变」
const value = useMemo(() => compute(...), [a, b, c, ..., z]); // ⚠️ 比较成本 + 缓存成本都高,且大概率每次都重算
// ✅ 缩小依赖粒度,或把「不稳定的依赖」先稳定下来
// ④ 在渲染里做「大数组创建」却不缓存
const rows = data.map(transformRow); // ⚠️ 1 万项 → 毫秒级
// ✅ useMemo(尤其当下游是 memo 列表时)
// ⑤ 用「微基准」的结论指导业务优化(脱离真实数据量与渲染频率)
// 微基准告诉你「一次 map 是 X ns」,但真实瓶颈可能是「渲染了 200 次」
// ⑥ 用 `performance.now()` 在「单次调用」上测(分辨率不够,全是噪声)
const t0 = performance.now(); useMemo(...); const t1 = performance.now(); // ⚠️ 测不出
// ✅ 循环 N 次取平均(见第二节的 bench 函数)
// ⑦ 忽略「GC 压力」——分配本身不慢,但「累积分配」会引发 GC 停顿
// ✅ 减少「每次渲染都创建的大对象」,比减少「小函数」重要得多
// ⑧ 把「用户计算」误认为「Hook 开销」
// Profiler 显示 12ms → 你以为是 Hook 慢,其实是「组件里的 sort」慢
// ⑨ 在生产构建里跑「微基准」(开发构建有额外的检查与警告,数字不可比)
// ✅ 用 production build 测「性能」,用 development 测「正确性」
// ⑩ 认为「用了 useMemo 就一定更快」
// ✅ 只有当「计算成本 > 比较+缓存成本」且「下游能受益(memo)」时才成立
// ⑪ 忘记「依赖数组长度也影响内存」——每个 useMemo 都要保存一份依赖数组
// ⑫ 用「自适应缓存」(根据耗时决定是否缓存)→ 复杂度爆炸、且行为不确定
// ✅ 固定的策略 + Profiler 验证,而不是「运行时自适应」
七、面试延伸
- 「Hook 本身有性能开销吗?」
有,但分三类且量级差异极大:① 调用开销(从 Hook 链表按顺序取节点)——常量级、纳秒级,基本可忽略;② 依赖比较(逐项Object.is)——与「依赖数量 × 渲染次数」成正比,通常也是纳秒级;③ 内存分配(每次渲染创建的返回数组、对象字面量、内联函数)——这是真实成本(会引发 GC),尤其当「创建的对象大」时是微秒~毫秒级地。结论:单次 Hook 调用通常不是瓶颈,「渲染次数 × 每次渲染的分配」才是。 -
「为什么说『减少 Hook 数量』不是有效的优化?」
因为「取 Hook 节点」是常量级操作(几个指针跳转),把 20 个 Hook 减到 10 个,省下的是「纳秒级 × 渲染次数」。而代价往往更大:① 把多个useState合并成一个对象 → 每次更新都要对象展开(一次分配),且传播范围变粗(任何一个字段变化都会让依赖该对象的所有组件重渲染);② 抽象层级下降,可读性变差。真正有效的是「减少渲染次数」——它同时减少了「Hook 开销、diff、DOM 比较、Effect 比较、GC」所有环节。 -
「怎么测量 Hook 的性能?」
两个层次的工具:① 微基准——用「循环 N 次 +performance.now()取平均」测「依赖比较 / 分配 / 计算」的成本(注意:单次调用测不出,performance.now()的分辨率不够;也要避免在开发构建里测);② 真实场景——用 React DevTools Profiler 或<Profiler onRender>看「一次渲染的actualDuration」,用「对比两组只差一个变量的组件」来做 A/B。关键是「区分固定开销与用户计算」:Profiler 显示 12ms 时,先确认那 12ms 花在「你的计算」还是「框架代码」上。 -
「
useMemo自身的成本是什么?什么时候『用了反而更慢』?」
它的成本是「依赖比较 + 缓存读写 +(依赖变化时)执行fn」,另外还要保存依赖数组(一次数组分配)。用了反而更慢的典型场景:① 计算本身很轻(a + b、items.length)——成本 > 收益;② 常量({ a: 1 })——应该提到模块作用域(那里的引用天然稳定,零成本);③ 依赖每次渲染都变(依赖里有字面量)——你付出了「比较 + 缓存」,但每次都要重算,净亏;④ 下游不是memo组件——引用稳不稳定都不影响渲染。 -
「为什么『缓存大对象』比『缓存小函数』更有价值?」
因为分配的成本与「对象大小」正相关:① 创建一个小箭头函数/小对象是纳秒级(且现代 JS 引擎对小对象分配优化得很好);② 创建「1 万项的数组」是微秒~毫秒级,而且会产生GC 压力(内存分配 → 回收 → 可能触发停顿);③ 更大的数组还会让下游的 diff / 比较变慢(如React.memo的浅比较、useEffect的依赖比较)。所以优先缓存「大的、贵的」——这也是「为什么useMemo应该用在『大数组排序/过滤』而不是『拼字符串』上」。 -
「什么时候 Hook 开销真的会成为瓶颈?」
需要同时满足三个条件:① 组件渲染极其频繁(每秒几十到几百次,如拖拽跟随、滚动驱动、动画帧、实时数据流);② 单次渲染里 Hook 数量很多(几十上百个,通常是巨型表单或被过度抽象的组件);③ Profiler 显示耗时主要花在框架代码上(而不是你的业务计算)。这时正确的优化方向不是「减少 Hook」,而是:① 把「高频状态」移出 React(ref+ 直接改 DOM,如transform);② 用「外部状态库 + 选择器」缩小重渲染范围;③ 用useDeferredValue/useTransition降低优先级;④ 把高频组件从大组件里拆出来。 -
「微基准的结果能直接指导业务优化吗?」
不能直接用。微基准的价值是「建立量级感」(知道「一次分配是纳秒级、一次sort可能是毫秒级」),但业务瓶颈取决于「数据量 × 渲染次数」:微基准说「一次map是 200ns」,而真实场景可能是「1 万项 × 每秒 60 次 = 每秒 120ms」——结论完全不同。所以正确流程是:先用 Profiler 在真实场景里找到热点 → 再看「是次数问题还是单次成本问题」→ 再决定优化手段(见 5.1)。 -
「为什么要在 production build 里测性能?」
因为开发构建包含大量额外检查:StrictMode 的双调用、更详细的警告、未压缩的代码、source map、useDebugValue的记录、React DevTools 的 hooks 追踪等——这些都会让「渲染耗时与渲染次数」显著高于生产环境。所以:性能数字看 production build,正确性(不纯渲染、清理缺失)看 development build。这也是「开发环境测出的数字不能外推」的原因。
一句话速记
Hook 的开销分三类:调用开销(链表取值,纳秒级、可忽略)、依赖比较(逐项
Object.is,与依赖数量成正比,仍是纳秒级)、内存分配(每次渲染创建的数组/对象/函数,大对象是微秒~毫秒级 ⚠️);真正的成本是「渲染次数 × 每次渲染的分配」,所以冒号后面的顺序永远是「先减少渲染次数(状态下移/memo/稳定引用)→ 再缓存大的计算与对象 → 最后才考虑依赖数组长度与 Hook 数量」;减少 Hook 数量基本测不出收益(还会因「合并状态」导致传播变粗);useMemo/useCallback有「比较 + 缓存 + 依赖数组分配」的固定成本,只有「计算够贵」或「下游是memo」时才划算,常量应提到模块作用域;用「循环 N 次取平均」测微基准(单次测不出)、用 Profiler 测真实渲染、性能数字看 production build;Hook 开销真正成为瓶颈的条件是「高频渲染 + 大量 Hook + 框架耗时占大头」,那时的解法是「把高频状态移出 React / 缩小重渲染范围 / 降优先级」。



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