在前四篇中,我们走完了 React 核心架构的”主线任务”:Fiber、渲染流程、Hooks、并发模式。从这一篇开始,我们进入”支线副本”——那些你日常开发中高频使用,但底层原理很少有人说清楚的 API。

本篇覆盖两个主题:Memoization 双子星(useMemo / useCallback)Suspense 机制。它们一个关于”跳过不必要的计算”,一个关于”等待异步数据”,但底层都深深扎根在 Fiber 链表和工作循环中。


第一部分:性能优化的双子星——useMemo 与 useCallback

useMemouseCallback 在社区里经常被过度使用,也经常被误解。要搞清楚什么时候该用、什么时候不该用,最好的方式就是看看它们在源码里到底做了什么——你会发现,它们的实现朴素得令人意外

1. 底层存储结构

在第三篇中我们讲过,所有 Hooks 的数据都挂在 Fiber 节点的 memoizedState 链表上。useMemouseCallback 也不例外,它们存的只是一个简单的二元组:

1
2
3
4
5
// 每个 useMemo / useCallback 的 Hook 对象中
hook.memoizedState = [
value, // useMemo 存计算结果,useCallback 存函数引用
deps // 依赖数组的快照
];

没有缓存淘汰策略,没有 LRU,没有任何复杂的数据结构——就是一个数组,存一个值和一份依赖。

2. Mount 阶段:首次渲染

首次渲染时,React 使用 mountMemomountCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// useMemo 的首次渲染(简化自 React 源码)
function mountMemo(nextCreate, deps) {
const hook = mountWorkInProgressHook(); // 创建新的 Hook 节点,追加到链表

const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate(); // 立即执行工厂函数,拿到计算结果

hook.memoizedState = [nextValue, nextDeps]; // 存入链表
return nextValue;
}

// useCallback 的首次渲染(简化自 React 源码)
function mountCallback(callback, deps) {
const hook = mountWorkInProgressHook();

const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps]; // 直接存函数本身,不执行
return callback;
}

注意两者的区别:

  • mountMemo执行 nextCreate(),存的是返回值
  • mountCallback不执行 callback,存的是函数引用本身

3. Update 阶段:后续渲染的”找不同”

后续渲染时,React 使用 updateMemoupdateCallback,核心逻辑就是比较依赖数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// useMemo 的更新渲染(简化自 React 源码)
function updateMemo(nextCreate, deps) {
const hook = updateWorkInProgressHook(); // 从链表取出对应的 Hook

const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState; // [prevValue, prevDeps]

if (prevState !== null && nextDeps !== null) {
const prevDeps = prevState[1];

// 逐项用 Object.is 比较
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变 → 返回缓存的旧值,跳过计算
return prevState[0];
}
}

// 依赖变了 → 重新执行工厂函数
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}

// useCallback 的更新渲染(简化自 React 源码)
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook();

const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;

if (prevState !== null && nextDeps !== null) {
const prevDeps = prevState[1];

if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖没变 → 返回旧的函数引用
return prevState[0];
}
}

// 依赖变了 → 存入新函数
hook.memoizedState = [callback, nextDeps];
return callback;
}

关键的比较函数 areHookInputsEqual 在第三篇已经见过:

1
2
3
4
5
6
7
8
9
function areHookInputsEqual(nextDeps, prevDeps) {
for (let i = 0; i < prevDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false; // 任何一项不同就返回 false
}
return true;
}

Object.is 做的是引用比较(对于对象)和值比较(对于原始类型)。这就解释了一个常见的坑:

1
2
3
4
// 每次渲染都会创建新对象 → 引用不同 → useMemo 每次都重新计算
const result = useMemo(() => expensiveCalc(data), [{ id: 1 }]);
// ^^^^^^^^
// 每次渲染 { id: 1 } 都是新对象,Object.is 返回 false

4. useCallback 的本质:useMemo 的语法糖

从源码可以看出,useCallback(fn, deps)useMemo(() => fn, deps) 在行为上完全等价

1
2
3
// 这两行效果完全一样
const memoizedFn = useCallback(fn, deps);
const memoizedFn = useMemo(() => fn, deps);

区别只是 useCallback 省去了那层包裹的箭头函数——React 源码里甚至有注释说明这一点。

5. 关键误区:useCallback 不能阻止函数创建

这是社区里最常见的误解:

误区:用了 useCallback 就不会创建新函数了。

真相:JavaScript 引擎在解析到函数表达式的那一刻,就已经创建了函数对象。useCallback 改变不了这个事实。

1
2
3
4
5
6
7
8
function Parent() {
// 不管有没有 useCallback,这一行每次渲染都会创建一个新的函数对象
const handleClick = useCallback(() => {
console.log('clicked');
}, []);

return <Child onClick={handleClick} />;
}

useCallback 做的事情是:每次渲染都创建了新函数,但如果依赖没变,React 把新函数扔掉,返回旧函数的引用。这样 handleClick 在两次渲染之间保持了引用稳定。

6. 什么时候该用,什么时候不该用

理解了源码之后,判断标准就很清晰了:

useMemo 该用的场景:

1
2
3
4
5
6
7
8
9
10
11
// ✅ 计算成本高(比如对大数组排序/过滤)
const sortedList = useMemo(
() => hugeArray.sort((a, b) => a.score - b.score),
[hugeArray]
);

// ❌ 计算成本低——useMemo 本身的开销(比较依赖 + 存储)可能比直接算还大
const fullName = useMemo(
() => `${firstName} ${lastName}`, // 字符串拼接比 Object.is 比较还快
[firstName, lastName]
);

useCallback 该用的场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ✅ 传给用 React.memo 包裹的子组件
const MemoChild = React.memo(function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
});

function Parent() {
const handleClick = useCallback(() => { /* ... */ }, []);
return <MemoChild onClick={handleClick} />;
// 如果不用 useCallback,每次 Parent 渲染都会传新的函数引用
// React.memo 的浅比较会认为 props 变了,导致 MemoChild 重新渲染
}

// ❌ 子组件没有用 React.memo,useCallback 纯属浪费
function Parent() {
const handleClick = useCallback(() => { /* ... */ }, []);
return <NormalChild onClick={handleClick} />;
// NormalChild 没有 memo,Parent 渲染时它一定会重新渲染
// 引用是否稳定没有任何意义
}

7. React.memo:useMemo/useCallback 的”搭档”

单独说一下 React.memo,因为它和上面两个 Hook 是配套使用的。

React.memo 是一个高阶组件,它在组件外层加了一层浅比较:

1
2
3
4
5
6
7
8
9
10
// React.memo 的简化实现
function memo(Component, compare) {
function MemoComponent(props) {
// ...
}

MemoComponent.$$typeof = REACT_MEMO_TYPE;
MemoComponent.compare = compare || shallowEqual; // 默认浅比较
return MemoComponent;
}

beginWork 阶段,React 遇到 Memo 类型的组件时会:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function updateMemoComponent(current, workInProgress, renderLanes) {
const Component = workInProgress.type; // MemoComponent
const compare = Component.compare || shallowEqual;
const prevProps = current.memoizedProps;
const nextProps = workInProgress.pendingProps;

// 用 compare 函数对比 props
if (compare(prevProps, nextProps)) {
// props 没变 → 跳过这个组件的渲染
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}

// props 变了 → 正常渲染
return updateFunctionComponent(current, workInProgress, renderLanes);
}

shallowEqual 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function shallowEqual(objA, objB) {
if (Object.is(objA, objB)) return true;

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

if (keysA.length !== keysB.length) return false;

for (let i = 0; i < keysA.length; i++) {
if (
!objB.hasOwnProperty(keysA[i]) ||
!Object.is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}

return true;
}

所以完整的优化链条是:

1
2
3
4
5
6
7
8
9
10
11
12
Parent 重新渲染

├── useCallback 保证 handleClick 引用不变


传 props 给 MemoChild

├── React.memo 用 shallowEqual 比较 props
├── handleClick 引用没变 → props 相同


跳过 MemoChild 的渲染(bailout)

三者缺一不可:没有 React.memouseCallback 白费;没有 useCallbackReact.memo 每次都通不过 props 比较。


第二部分:Suspense 的悬停魔法

如果说 Memoization 是”跳过不必要的工作”,那 Suspense 就是”等待还没准备好的工作”。React Suspense 的实现方式相当”离经叛道”——它利用了 JavaScript 的错误处理机制来实现异步流程控制。

1. 核心原理:抛出 Promise

在传统的 React 组件中,如果数据还没加载完,你通常会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 传统方式:自己管理加载状态
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetchUser(userId).then(d => {
setData(d);
setLoading(false);
});
}, [userId]);

if (loading) return <Spinner />;
return <div>{data.name}</div>;
}

Suspense 的思路完全不同——组件不管理加载状态,而是在数据没准备好时直接”中断”自己的渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
// Suspense 方式:组件假设数据一定存在
function UserProfile({ userId }) {
const data = resource.read(userId);
// 如果数据没好 → read() 内部会 throw 一个 Promise
// 如果数据好了 → 直接返回数据

return <div>{data.name}</div>;
}

// 使用时用 Suspense 包裹
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>

resource.read() 内部的实现大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 简化版的"数据资源"实现
function createResource(fetcher) {
let status = 'pending';
let result;

const promise = fetcher().then(
(data) => {
status = 'success';
result = data;
},
(error) => {
status = 'error';
result = error;
}
);

return {
read() {
switch (status) {
case 'pending':
throw promise; // 数据没好 → 抛出 Promise
case 'error':
throw result; // 出错了 → 抛出 Error
case 'success':
return result; // 数据好了 → 正常返回
}
},
};
}

注意这里最关键的一行:throw promise。这不是 throw new Error(),而是抛出了一个 Promise 对象。这是 Suspense 整个机制的核心。

2. React 工作循环中的 try…catch

在第一篇中我们讲过,React 的工作循环(workLoop)会逐个处理 Fiber 节点。为了支持 Suspense,这个循环被包裹在一个 try...catch 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// renderRootSync / renderRootConcurrent 内部(简化)
function renderRoot(root, lanes) {
// 准备 workInProgress
prepareFreshStack(root, lanes);

do {
try {
// 正常的工作循环
workLoop();
break; // 正常结束
} catch (thrownValue) {
// 有东西被 throw 了!
handleThrow(root, thrownValue);
}
} while (true);
}

handleThrow 的逻辑是判断被抛出的东西是什么类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function handleThrow(root, thrownValue) {
if (
thrownValue !== null &&
typeof thrownValue === 'object' &&
typeof thrownValue.then === 'function' // duck typing:像 Promise 吗?
) {
// 是 Promise → 这是一个 Suspense 场景
const wakeable = thrownValue;
throwException(root, workInProgress, wakeable);
} else {
// 是 Error → 这是一个 ErrorBoundary 场景
throw thrownValue; // 向上传播,被 ErrorBoundary 的 catch 接住
}
}

3. 挂起(Suspend):Suspense 边界的查找

当 React 确认这是一个 Suspense 场景后,它需要做两件事:

第一步:向上查找最近的 Suspense 边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function throwException(root, sourceFiber, wakeable) {
// 给当前节点标记为"未完成"
sourceFiber.flags |= Incomplete;

// 向上遍历,查找最近的 <Suspense> 组件
let node = sourceFiber.return;
while (node !== null) {
if (node.tag === SuspenseComponent) {
// 找到了 Suspense 边界

// 在 Suspense 节点上记录这个 wakeable(Promise)
const wakeables = node.updateQueue;
if (wakeables === null) {
node.updateQueue = new Set([wakeable]);
} else {
wakeables.add(wakeable);
}

// 给 Suspense 节点打上标记:需要显示 fallback
node.flags |= ShouldCapture;
return;
}
node = node.return;
}

// 如果找不到 Suspense 边界 → 这是一个未捕获的异常
// React 会把整个应用标记为错误状态
}

第二步:渲染 fallback

找到 Suspense 边界后,React 会让这个 Suspense 组件渲染它的 fallback 而不是 children

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 简化的 Suspense 组件 beginWork 逻辑
function updateSuspenseComponent(current, workInProgress) {
const nextProps = workInProgress.pendingProps;

// 检查是否处于挂起状态
const showFallback = isSuspended(workInProgress);

if (showFallback) {
// 挂起了 → 渲染 fallback
const fallbackChildren = nextProps.fallback;

// 标记 primary children 为隐藏(offscreen)
const primaryChildFragment = createFiberFromOffscreen(nextProps.children);
primaryChildFragment.mode |= OffscreenMode;

// 创建 fallback 的 Fiber
const fallbackChildFragment = createFiberFromFragment(fallbackChildren);

workInProgress.child = primaryChildFragment;
primaryChildFragment.sibling = fallbackChildFragment;
primaryChildFragment.return = workInProgress;
fallbackChildFragment.return = workInProgress;

return fallbackChildFragment; // beginWork 返回 fallback,优先渲染它
} else {
// 没有挂起 → 正常渲染 children
const primaryChildren = nextProps.children;
return reconcileChildren(current, workInProgress, primaryChildren);
}
}

4. 恢复(Resume):Promise 决议后的重渲染

挂起后,React 需要在 Promise 决议时收到通知。它会给 Promise 附加一个回调:

1
2
3
4
5
6
7
8
9
10
function attachPingListener(root, wakeable, lanes) {
const ping = () => {
// Promise 完成了!安排一次新的渲染
// 这次渲染时,resource.read() 会返回数据而不是 throw
ensureRootIsScheduled(root);
};

// 监听 Promise 的完成
wakeable.then(ping, ping); // 无论 resolve 还是 reject 都要处理
}

当 Promise 决议后,整个流程重新来一遍:

1
2
3
4
5
6
7
8
9
10
11
12
第一次渲染:
UserProfile 执行 → resource.read() → throw Promise
React catch → 找到 <Suspense> → 渲染 <Spinner />
监听 Promise

Promise 决议:
ping() 触发 → 安排新一轮渲染

第二次渲染:
UserProfile 执行 → resource.read() → 返回 data(因为 status 已经是 'success')
正常渲染 <div>{data.name}</div>
Suspense 检测到 children 可以正常渲染 → 隐藏 fallback,显示 children

用时间线来表示:

1
2
3
4
5
6
7
8
9
10
11
12
时间 ──────────────────────────────────────────────────────→

├── Render 1:UserProfile throw Promise ──┐
│ 渲染 <Spinner /> │
│ 显示 Spinner │ 网络请求进行中...
│ │
├── Promise resolved ←────────────────────┘
│ ping() → scheduleWork

├── Render 2:UserProfile 正常返回 data
│ 渲染 <div>{data.name}</div>
│ Spinner 消失,内容出现

5. Suspense 与并发模式的协作

Suspense 在并发模式下会变得更加强大。当 Suspense 与 useTransition 配合时,React 可以在”等待数据”的同时保持旧 UI,而不是立即显示 Loading:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();

const switchTab = (newTab) => {
startTransition(() => {
setTab(newTab); // 过渡更新
});
};

return (
<div>
<TabBar currentTab={tab} onSwitch={switchTab} />
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<Suspense fallback={<Spinner />}>
<TabContent tab={tab} />
</Suspense>
</div>
</div>
);
}

在这个场景下:

  1. 用户点击新 Tab
  2. startTransition 标记这个更新为低优先级
  3. React 开始在后台渲染新 Tab 的内容
  4. 如果新 Tab 的组件 throw Promise(数据还没好),React 不会立即显示 Spinner
  5. 而是继续显示旧 Tab 的内容(加上 isPending 的半透明效果)
  6. 直到数据加载完成、新界面完全准备好后,一次性切换过去

这就是”避免 Loading 闪烁”的底层机制——并发模式让 React 可以选择性地延迟 Suspense fallback 的显示

6. Suspense 的边界嵌套

多个 Suspense 可以嵌套使用,React 会按照”就近原则”向上查找边界:

1
2
3
4
5
6
7
8
9
10
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ContentSpinner />}>
<MainContent />
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Suspense>
<Footer />
</Suspense>

如果 Sidebar 的数据还没好,只有最内层的 Suspense 会显示 <SidebarSkeleton />HeaderMainContentFooter 都不受影响。

如果 MainContent 也挂了,中间层的 Suspense 接管,显示 <ContentSpinner />——此时 Sidebar 的 Suspense 也被包含在 fallback 范围内。

这种嵌套设计让你可以细粒度地控制加载体验:

1
2
3
4
5
数据加载状态               用户看到的界面
────────── ──────────────
Sidebar 未就绪 Header ✓ | MainContent ✓ | SidebarSkeleton
MainContent 未就绪 Header ✓ | ContentSpinner | Footer ✓
全部未就绪 PageSkeleton

总结

主题 核心机制 一句话概括
useMemo deps 浅比较 → 复用旧值或重新计算 用比较的开销换取计算的跳过
useCallback deps 浅比较 → 复用旧函数引用 useMemo 的语法糖,核心是引用稳定性
React.memo props 浅比较 → bailout 跳过渲染 useCallback 的搭档,缺一不可
Suspense throw Promise → catch → 渲染 fallback → Promise 决议 → 重新渲染 用”假装报错”实现异步流程控制

Memoization 是关于比较与跳过——通过对比依赖数组,决定是复用旧值还是计算新值。

Suspense 是关于中断与恢复——通过抛出 Promise 中断渲染,利用 Promise 的状态变化重启渲染。

两者在底层都依赖 Fiber 架构提供的基础能力:链表存储让数据有地方放,可中断的工作循环让异常能被安全处理。

Happy Coding!