React篇 | 并发模式
前言
如果你面试中被问到”React 18 有什么新特性”,你大概率会说:并发模式。但如果面试官接着问:
- 并发模式和同步模式到底有什么区别?不就是”能中断”吗?
- Fiber 架构和以前的 Stack 架构到底差在哪?
- 时间切片是怎么做到的?浏览器不是单线程吗?
- useTransition 和 useDeferredValue 有什么区别?什么时候用哪个?
- Lane 模型是什么?和 expirationTime 有什么关系?
- 并发模式下为什么会出现 tearing?怎么解决?
很多人就开始支支吾吾了。
本章,我们就从同步渲染的瓶颈出发,逐步拆解 React 并发模式的核心设计思路。读完之后,你不仅能应对面试中的并发相关问题,还能在实际项目中更好地使用 useTransition、useDeferredValue 等 API。
诊断自测
在开始正文之前,先用几道题测测你目前对并发模式的理解程度。答不上来也没关系,读完全文再回来看,效果更好。
Q1:React 的同步渲染模式下,如果一次更新触发了大量组件重渲染,会出现什么问题?React 是怎么解决的?
点击查看答案
同步渲染模式下,React 会一口气完成整棵组件树的 reconciliation 和 commit,中间不会让出控制权。如果组件树很大(比如一次渲染几千个列表项),主线程就会被长时间占用,导致用户的输入、动画等无法及时响应,页面看起来”卡住了”。
React 的解决方案是并发渲染:把一次大的渲染任务拆分成多个小的工作单元(Fiber),每完成一个单元就检查一下是否有更高优先级的任务(如用户输入),如果有就先让出主线程去处理高优先级任务,等闲暇时再回来继续。
Q2:useTransition 和 useDeferredValue 都是”降低优先级”的手段,它们有什么区别?
点击查看答案
useTransition 用于包裹触发状态更新的操作——你告诉 React”这个 setState 不紧急,可以晚点处理”。它返回一个 isPending 标志和一个 startTransition 函数。
useDeferredValue 用于包裹一个值——你告诉 React”这个值的更新不紧急,可以先用旧值渲染,等闲暇时再用新值重渲染”。
简单说:useTransition 控制的是更新的发起端,useDeferredValue 控制的是值的消费端。当你能控制 setState 的调用时,用 useTransition;当你只能拿到一个值(比如来自 props)时,用 useDeferredValue。
Q3:什么是 tearing?为什么只在并发模式下才会出现?
点击查看答案
Tearing(撕裂)是指在同一次渲染中,不同组件读到了同一个外部状态的不同版本,导致 UI 不一致。
在同步模式下不会出现这个问题,因为整个渲染是一口气完成的,中间外部状态不会变。但在并发模式下,渲染可以被中断和恢复,如果在中断期间外部状态发生了变化,恢复渲染后读到的就是新值,而之前已经渲染的组件读到的是旧值——这就产生了 tearing。React 18 提供了 useSyncExternalStore 来解决这个问题。
一、从同步渲染到并发渲染:为什么需要变?
1.1 同步渲染的痛点
在 React 18 之前(严格说是 createRoot 之前),React 的渲染是完全同步的。当你调用 setState,React 会:
- 从触发更新的组件开始,向上标记需要更新的子树
- 从根节点开始,递归地对整棵需要更新的子树进行 reconciliation(调和),生成新的虚拟 DOM 树
- 一次性把所有变更 commit 到真实 DOM
整个过程是不可中断的。这在大多数场景下没问题,但当组件树非常大时,问题就来了:
// 假设这个列表有 10000 条数据
function HugeList({ query }) {
const filtered = data.filter(item => item.name.includes(query));
return (
<ul>
{filtered.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
);
}
// 用户在搜索框快速输入
function App() {
const [query, setQuery] = useState('');
return (
<>
<input onChange={e => setQuery(e.target.value)} />
<HugeList query={query} />
</>
);
}
用户每敲一个字,就会触发一次完整的列表重渲染。如果每次渲染需要 200ms,而用户连续敲了 5 个字,主线程就会被占用近 1 秒。在这 1 秒内,输入框的光标不会闪、输入的字符不会显示、滚动和动画全部冻结——用户体验极差。
1.2 并发渲染的核心思想
并发渲染的核心思想很朴素:不要让低优先级的渲染工作阻塞高优先级的用户交互。
具体来说:
- 渲染可以被中断:如果用户正在输入,React 可以暂停正在进行的列表渲染,优先处理输入事件
- 渲染可以被丢弃:如果用户又敲了一个字,之前还没完成的渲染结果直接扔掉,用最新的 query 重新开始
- 多个版本可以同时存在:React 可以在内存中同时准备多个 UI 版本,选择合适的时机展示给用户
但要注意:并发不等于多线程。JavaScript 仍然是单线程的,React 的”并发”是通过协作式调度实现的——每完成一小块工作就主动检查是否需要让出控制权。
二、Fiber 架构:让渲染变得可中断
2.1 旧架构的问题:Stack Reconciler
React 15 及之前使用的是 Stack Reconciler。它的工作方式和函数调用栈一样——递归遍历组件树,每进入一个子组件就压栈,返回时弹栈。
App → Header → Logo → Text → ... → Footer → Copyright → ...
↓ ↓ ↓ ↓ ↓ ↓
push push push push push push
问题在于:函数调用栈是不可中断的。一旦开始递归,就必须等整棵树遍历完才能返回。没有任何机制可以让你在中途”暂停”一个递归调用,去做点别的事,然后再回来继续。
2.2 Fiber:用链表替代调用栈
Fiber 架构的核心改变是:用自定义的链表数据结构替代了 JavaScript 的函数调用栈。
每个 React 组件(包括原生 DOM 元素)都对应一个 Fiber 节点,每个 Fiber 节点有三个关键指针:
// 简化的 Fiber 节点结构
const fiber = {
type: 'div', // 组件类型
stateNode: domElement, // 对应的真实 DOM 或组件实例
// 三个指针,构成链表
child: childFiber, // 第一个子节点
sibling: siblingFiber, // 下一个兄弟节点
return: parentFiber, // 父节点
// 工作相关
pendingProps: newProps, // 新的 props
memoizedState: currentState, // 当前 state
flags: 'Update', // 需要执行的操作(插入、更新、删除等)
lanes: 0b0001, // 优先级(Lane 模型)
};
有了这三个指针,React 就可以用迭代(while 循环)而非递归来遍历组件树:
// 简化的工作循环
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// 还没做完,但时间用光了,让出控制权
requestIdleCallback(workLoop);
} else {
// 全部做完,提交变更
commitRoot();
}
}
每处理一个 Fiber 节点就是一个”工作单元”。处理完一个工作单元后,React 可以检查剩余时间——如果时间不够了,就暂停,把控制权还给浏览器去处理用户事件、绘制动画等。等浏览器空闲了,再回来继续下一个工作单元。
这就是可中断渲染的基础。
2.3 双缓冲机制
Fiber 架构还引入了**双缓冲(double buffering)**机制。React 同时维护两棵 Fiber 树:
- current 树:当前屏幕上显示的 UI 对应的 Fiber 树
- workInProgress 树:正在内存中构建的新 Fiber 树
新的渲染工作都在 workInProgress 树上进行。当所有工作完成后,React 通过一次指针切换,把 workInProgress 树变成 current 树——这就是 commit 阶段。
双缓冲的好处是:渲染过程中用户看到的始终是完整的旧 UI,不会出现渲染到一半的”半成品”。
三、时间切片(Time Slicing):怎么做到”不卡”
3.1 调度器(Scheduler)
时间切片的底层依赖 React 的 Scheduler(调度器)。Scheduler 的核心逻辑是:
- 把任务按优先级排序放入队列
- 用
MessageChannel(而非requestIdleCallback或setTimeout)调度宏任务 - 每次从队列中取出最高优先级的任务执行
- 每执行一个工作单元后检查是否超过 5ms 的时间片
- 如果超时,让出控制权,下一个宏任务再继续
// 简化的调度逻辑
function scheduleCallback(priorityLevel, callback) {
const task = {
callback,
priorityLevel,
expirationTime: currentTime + timeout(priorityLevel),
};
taskQueue.push(task);
requestHostCallback(flushWork);
}
function flushWork(initialTime) {
let currentTime = initialTime;
let currentTask = peek(taskQueue);
while (currentTask !== null) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// 还没过期,但时间片用完了,让出控制权
break;
}
const callback = currentTask.callback;
callback(); // 执行一个工作单元
currentTask = peek(taskQueue);
currentTime = getCurrentTime();
}
return currentTask !== null; // 是否还有剩余工作
}
3.2 为什么是 5ms?
React 选择 5ms 作为默认时间片长度,是基于这样的考虑:浏览器的一帧通常是 16.67ms(60fps)。如果 React 的时间切片占用超过一帧的时间,浏览器就没有时间处理布局、绘制和用户输入了。5ms 留出了足够的空间给浏览器的其他工作。
3.3 为什么用 MessageChannel 而不是 requestIdleCallback?
你可能在很多文章中看到用 requestIdleCallback 来解释时间切片,但实际上 React 并没有使用它,原因有几个:
requestIdleCallback的触发时机不确定,在某些情况下延迟很大(浏览器忙时可能几十毫秒才触发一次)- 兼容性不够好(Safari 直到很晚才支持)
- 不够灵活,无法精确控制时间片长度
React 使用 MessageChannel 来创建宏任务,因为它的延迟很小(通常不到 1ms),而且不会被浏览器的帧渲染阻塞。
四、useTransition 和 useDeferredValue
这是面试中并发模式最高频的考点,也是你在实际项目中最常用的两个 API。
4.1 useTransition:让更新”可以等一等”
function SearchPage() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
// 输入框的更新是紧急的,立即执行
setQuery(e.target.value);
};
// 但列表的筛选可以延后
const [filterQuery, setFilterQuery] = useState('');
const handleChange2 = (e) => {
setQuery(e.target.value); // 紧急更新
startTransition(() => {
setFilterQuery(e.target.value); // 非紧急更新,可以被中断
});
};
return (
<>
<input value={query} onChange={handleChange2} />
{isPending && <Spinner />}
<HugeList query={filterQuery} />
</>
);
}
startTransition 包裹的 setState 会被标记为低优先级的过渡更新(transition)。React 会优先处理其他紧急更新(如用户输入),等闲暇时再处理 transition 更新。如果在处理过程中又来了新的紧急更新,React 会中断当前的 transition 渲染,处理紧急更新后重新开始。
isPending 可以用来显示一个 loading 指示器,告诉用户”后台正在处理”。
4.2 useDeferredValue:让值”可以落后一拍”
function SearchResults({ query }) {
// query 可能变得很快,但我们不需要每次都立即渲染列表
const deferredQuery = useDeferredValue(query);
// 可以用来判断当前是否在"追赶"中
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<HugeList query={deferredQuery} />
</div>
);
}
useDeferredValue 会在紧急更新完成后,再用新值触发一次低优先级的重渲染。在这次重渲染完成前,它会返回旧值。
4.3 怎么选?
| 场景 | 推荐 API |
|---|---|
| 你能控制 setState 的调用 | useTransition |
| 值来自 props,你无法控制更新时机 | useDeferredValue |
| 需要显示 loading 状态 | useTransition(有 isPending) |
| 只是想让某个值”慢半拍” | useDeferredValue |
五、优先级调度:Lane 模型简介
5.1 从 expirationTime 到 Lane
React 16 使用 expirationTime(过期时间)来表示更新的优先级——时间越近越紧急。但这种方式有一个重大缺陷:无法表示”一批”更新。
举个例子:你希望”所有来自用户输入的更新”作为一批处理,但用 expirationTime 很难表达”这些时间戳不同的更新属于同一批”。
React 17 引入了 Lane 模型,用**二进制位(bitmask)**来表示优先级:
// 简化的 Lane 定义
const SyncLane = 0b0000000000000000000000000000001; // 同步
const InputContinuousLane = 0b0000000000000000000000000000100; // 连续输入
const DefaultLane = 0b0000000000000000000000000010000; // 默认
const TransitionLane1 = 0b0000000000000000000000001000000; // 过渡
const IdleLane = 0b0100000000000000000000000000000; // 空闲
用位运算可以非常高效地:
- 合并优先级:
lanes = laneA | laneB - 检查是否包含:
(lanes & SyncLane) !== 0 - 移除某个优先级:
lanes = lanes & ~laneA
5.2 优先级从高到低
- SyncLane:同步优先级,必须立即执行(如
flushSync) - InputContinuousLane:连续输入(如拖拽、滚动)
- DefaultLane:默认优先级(如普通的
setState) - TransitionLane:过渡优先级(
startTransition包裹的更新) - IdleLane:空闲优先级
面试中不需要记住所有 Lane 的值,但要理解为什么用位运算和大致的优先级分层。
六、并发模式下的常见问题:Tearing
6.1 什么是 Tearing?
Tearing(撕裂)是并发模式特有的问题。想象这个场景:
// 一个外部 store
let externalCount = 0;
function ComponentA() {
// 在渲染开始时读到 externalCount = 0
return <div>A: {externalCount}</div>;
}
function ComponentB() {
// 渲染被中断,中间 externalCount 变成了 1
// 恢复渲染时读到 externalCount = 1
return <div>B: {externalCount}</div>;
}
function App() {
return (
<>
<ComponentA /> {/* 显示 0 */}
<ComponentB /> {/* 显示 1 */}
</>
);
}
同一次渲染中,A 和 B 读到了不同版本的值——这就是 tearing。UI 上看起来就像画面被”撕裂”了。
6.2 为什么同步模式不会出现?
同步模式下,整个渲染过程不可中断。从 A 渲染到 B 的过程中,主线程一直被 React 占用,没有任何代码能修改 externalCount。所以所有组件读到的一定是同一个快照。
6.3 useSyncExternalStore:官方解法
React 18 提供了 useSyncExternalStore 来解决 tearing 问题:
import { useSyncExternalStore } from 'react';
function useExternalCount() {
return useSyncExternalStore(
// subscribe:订阅外部 store 的变化
(callback) => {
store.subscribe(callback);
return () => store.unsubscribe(callback);
},
// getSnapshot:获取当前快照
() => store.getCount()
);
}
function ComponentA() {
const count = useExternalCount();
return <div>A: {count}</div>;
}
useSyncExternalStore 会保证:在同一次渲染中,所有调用它的组件读到的都是同一个快照。如果在渲染过程中检测到外部值变了,React 会同步地重新开始整个渲染。
主流状态管理库(Redux、Zustand 等)都已经内置支持了 useSyncExternalStore,你直接使用它们通常不需要手动处理 tearing。
常见误区
误区一:“并发模式让 React 变成了多线程”
不是。JavaScript 仍然是单线程的。React 的”并发”是协作式调度——React 主动把大任务拆成小块,每小块执行完后检查是否需要让出控制权。这更像是 Generator 的 yield,而不是线程切换。
误区二:“用了 useTransition 就一定更快”
不一定。useTransition 不会让计算变快,它只是让高优先级的交互(如输入)不被低优先级的渲染(如列表更新)阻塞。如果你的组件渲染本身不慢(比如只有几十个列表项),加 useTransition 反而会因为额外的调度开销而变慢。它的价值在于感知性能——让用户感觉页面没卡,而不是让计算更快。
误区三:“并发模式下组件会被渲染多次,有副作用的代码会出 Bug”
这个说法有一定道理但不完全准确。在严格模式(StrictMode)下,React 确实会故意执行两次渲染函数来帮你发现副作用问题。但在生产环境中,被中断的渲染不会执行 commit 阶段——也就是说 useEffect、DOM 操作等不会执行多次。真正需要注意的是渲染函数本身必须是纯函数,不要在渲染过程中触发副作用。
误区四:“Fiber 是 React 16 引入的,所以 React 16 就有并发模式”
Fiber 架构确实在 React 16 就引入了,但 React 16 默认仍然是同步模式——Fiber 架构只是提供了并发的基础能力(可中断渲染),而并发调度是在 React 18 的 createRoot 中才真正启用的。React 16-17 的 Fiber 架构在默认模式下和 Stack Reconciler 的行为几乎一样——只是数据结构变了,但渲染过程仍然是一口气完成的。
小结
本章我们从同步渲染的瓶颈出发,逐步拆解了 React 并发模式的核心设计。
核心要点
- 同步渲染的问题:大组件树的渲染会阻塞主线程,导致交互卡顿
- Fiber 架构:用链表替代调用栈,使渲染过程可中断、可恢复
- 时间切片:每 5ms 检查一次,超时让出控制权,通过 MessageChannel 调度
- useTransition:把 setState 标记为低优先级,返回 isPending 表示状态
- useDeferredValue:让值”慢半拍”,适合无法控制更新源的场景
- Lane 模型:用位运算表示优先级,支持批量操作
- Tearing:并发模式特有的 UI 不一致问题,用
useSyncExternalStore解决
本章思维导图
- 为什么需要并发
- 同步渲染阻塞主线程
- 大组件树导致交互卡顿
- Fiber 架构
- 链表结构(child / sibling / return)
- 可中断渲染:while 循环替代递归
- 双缓冲:current 树 + workInProgress 树
- 时间切片
- Scheduler 调度器
- 5ms 时间片
- MessageChannel 宏任务
- 并发 API
- useTransition:控制更新的发起端
- useDeferredValue:控制值的消费端
- startTransition:无 Hook 版本
- 优先级调度
- Lane 模型:位运算表示优先级
- 优先级分层:Sync > Input > Default > Transition > Idle
- Tearing 问题
- 并发渲染中断导致读到不同版本
- useSyncExternalStore 保证一致性
练习挑战
第一题 ⭐(基础):用 useTransition 优化搜索
下面的代码在输入时会卡顿,请用 useTransition 优化:
function App() {
const [query, setQuery] = useState('');
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<HeavyList query={query} />
</>
);
}
点击查看答案与解析
function App() {
const [inputValue, setInputValue] = useState('');
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setInputValue(e.target.value); // 紧急更新:输入框立即响应
startTransition(() => {
setQuery(e.target.value); // 过渡更新:列表可以延后
});
};
return (
<>
<input value={inputValue} onChange={handleChange} />
{isPending && <div>Loading...</div>}
<HeavyList query={query} />
</>
);
}
关键是把一个 state 拆成两个:inputValue 用于输入框的紧急更新,query 用于列表的过渡更新。输入框始终立即响应,列表的重渲染被标记为低优先级。
第二题 ⭐⭐(进阶):useDeferredValue 的行为预测
function App() {
const [count, setCount] = useState(0);
const deferredCount = useDeferredValue(count);
console.log('render', { count, deferredCount });
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<p>Count: {count}</p>
<SlowComponent value={deferredCount} />
</div>
);
}
如果快速连续点击两次按钮,控制台可能输出什么?
点击查看答案与解析
可能的输出:
render { count: 1, deferredCount: 0 }
render { count: 2, deferredCount: 0 }
render { count: 2, deferredCount: 2 }
第一次点击后,React 先执行紧急更新(count 变为 1),deferredCount 仍然是旧值 0。在 React 准备用 deferredCount=1 重渲染时,第二次点击来了(count 变为 2),React 中断之前的过渡渲染,优先处理紧急更新。最后 deferredCount 直接跳到 2,中间的 1 被跳过了。
这就是并发模式的特点——低优先级的渲染可以被中断和丢弃。
第三题 ⭐⭐⭐(综合):手写简化版 useSyncExternalStore
请实现一个简化版的 useSyncExternalStore,要求:
- 接收
subscribe和getSnapshot两个参数 - 当外部 store 变化时,触发组件重渲染
- 返回最新的 snapshot
点击查看参考实现
function useSyncExternalStoreSimplified(subscribe, getSnapshot) {
const [snapshot, setSnapshot] = useState(getSnapshot);
// 用 ref 保持对最新 getSnapshot 的引用
const getSnapshotRef = useRef(getSnapshot);
getSnapshotRef.current = getSnapshot;
useEffect(() => {
const handleStoreChange = () => {
const nextSnapshot = getSnapshotRef.current();
setSnapshot(nextSnapshot);
};
// 订阅时先同步一次,防止在 mount 和 subscribe 之间 store 已经变了
handleStoreChange();
const unsubscribe = subscribe(handleStoreChange);
return unsubscribe;
}, [subscribe]);
return snapshot;
}
注意:这是一个简化版,真正的 useSyncExternalStore 还需要处理:
- 服务端渲染(
getServerSnapshot) - 并发安全(检测 tearing 并回退到同步渲染)
- snapshot 的引用比较优化
在面试中能写出这个简化版并说明其局限性,已经非常不错了。
自我检测
读完本章后,对照下面的清单检验一下自己的掌握程度。
- 能解释同步渲染的瓶颈以及并发渲染如何解决这个问题
- 能描述 Fiber 节点的基本结构(child / sibling / return 三个指针)以及它为什么能实现可中断渲染
- 能说出时间切片的基本原理:5ms 时间片 + MessageChannel 调度
- 能区分 useTransition 和 useDeferredValue 的使用场景
- 能解释 Lane 模型为什么用位运算,以及大致的优先级分层
- 能说清楚什么是 tearing,为什么只在并发模式下出现,以及如何用 useSyncExternalStore 解决
- 能在实际项目中判断何时需要使用并发 API,何时不需要
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90