在前四篇中,我们走完了 React 核心架构的”主线任务”:Fiber、渲染流程、Hooks、并发模式。从这一篇开始,我们进入”支线副本”——那些你日常开发中高频使用,但底层原理很少有人说清楚的 API。
本篇覆盖两个主题:Memoization 双子星(useMemo / useCallback) 和 Suspense 机制。它们一个关于”跳过不必要的计算”,一个关于”等待异步数据”,但底层都深深扎根在 Fiber 链表和工作循环中。
第一部分:性能优化的双子星——useMemo 与 useCallback
useMemo 和 useCallback 在社区里经常被过度使用,也经常被误解。要搞清楚什么时候该用、什么时候不该用,最好的方式就是看看它们在源码里到底做了什么——你会发现,它们的实现朴素得令人意外。
1. 底层存储结构
在第三篇中我们讲过,所有 Hooks 的数据都挂在 Fiber 节点的 memoizedState 链表上。useMemo 和 useCallback 也不例外,它们存的只是一个简单的二元组:
1 2 3 4 5
| hook.memoizedState = [ value, deps ];
|
没有缓存淘汰策略,没有 LRU,没有任何复杂的数据结构——就是一个数组,存一个值和一份依赖。
2. Mount 阶段:首次渲染
首次渲染时,React 使用 mountMemo 和 mountCallback:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function mountMemo(nextCreate, deps) { const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps; const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps]; return nextValue; }
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 使用 updateMemo 和 updateCallback,核心逻辑就是比较依赖数组:
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
| function updateMemo(nextCreate, 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]; } }
const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue; }
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; } return true; }
|
Object.is 做的是引用比较(对于对象)和值比较(对于原始类型)。这就解释了一个常见的坑:
1 2 3 4
| const result = useMemo(() => expensiveCalc(data), [{ id: 1 }]);
|
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() { 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] );
const fullName = useMemo( () => `${firstName} ${lastName}`, [firstName, lastName] );
|
useCallback 该用的场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const MemoChild = React.memo(function Child({ onClick }) { return <button onClick={onClick}>Click</button>; });
function Parent() { const handleClick = useCallback(() => { }, []); return <MemoChild onClick={handleClick} />; }
function Parent() { const handleClick = useCallback(() => { }, []); return <NormalChild onClick={handleClick} />; }
|
7. React.memo:useMemo/useCallback 的”搭档”
单独说一下 React.memo,因为它和上面两个 Hook 是配套使用的。
React.memo 是一个高阶组件,它在组件外层加了一层浅比较:
1 2 3 4 5 6 7 8 9 10
| 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; const compare = Component.compare || shallowEqual; const prevProps = current.memoizedProps; const nextProps = workInProgress.pendingProps;
if (compare(prevProps, nextProps)) { return bailoutOnAlreadyFinishedWork(current, workInProgress); }
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.memo,useCallback 白费;没有 useCallback,React.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
| function UserProfile({ userId }) { const data = resource.read(userId);
return <div>{data.name}</div>; }
<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; case 'error': throw result; 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
| function renderRoot(root, lanes) { prepareFreshStack(root, lanes);
do { try { workLoop(); break; } catch (thrownValue) { 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' ) { const wakeable = thrownValue; throwException(root, workInProgress, wakeable); } else { throw thrownValue; } }
|
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;
let node = sourceFiber.return; while (node !== null) { if (node.tag === SuspenseComponent) {
const wakeables = node.updateQueue; if (wakeables === null) { node.updateQueue = new Set([wakeable]); } else { wakeables.add(wakeable); }
node.flags |= ShouldCapture; return; } node = node.return; }
}
|
第二步:渲染 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
| function updateSuspenseComponent(current, workInProgress) { const nextProps = workInProgress.pendingProps;
const showFallback = isSuspended(workInProgress);
if (showFallback) { const fallbackChildren = nextProps.fallback;
const primaryChildFragment = createFiberFromOffscreen(nextProps.children); primaryChildFragment.mode |= OffscreenMode;
const fallbackChildFragment = createFiberFromFragment(fallbackChildren);
workInProgress.child = primaryChildFragment; primaryChildFragment.sibling = fallbackChildFragment; primaryChildFragment.return = workInProgress; fallbackChildFragment.return = workInProgress;
return fallbackChildFragment; } else { 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 = () => { ensureRootIsScheduled(root); };
wakeable.then(ping, ping); }
|
当 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> ); }
|
在这个场景下:
- 用户点击新 Tab
startTransition 标记这个更新为低优先级
- React 开始在后台渲染新 Tab 的内容
- 如果新 Tab 的组件
throw Promise(数据还没好),React 不会立即显示 Spinner
- 而是继续显示旧 Tab 的内容(加上
isPending 的半透明效果)
- 直到数据加载完成、新界面完全准备好后,一次性切换过去
这就是”避免 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 />,Header、MainContent、Footer 都不受影响。
如果 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!