跳到主要内容
预计阅读 40 分钟

React篇 | 并发模式

前言

如果你面试中被问到”React 18 有什么新特性”,你大概率会说:并发模式。但如果面试官接着问:

  • 并发模式和同步模式到底有什么区别?不就是”能中断”吗?
  • Fiber 架构和以前的 Stack 架构到底差在哪?
  • 时间切片是怎么做到的?浏览器不是单线程吗?
  • useTransition 和 useDeferredValue 有什么区别?什么时候用哪个?
  • Lane 模型是什么?和 expirationTime 有什么关系?
  • 并发模式下为什么会出现 tearing?怎么解决?

很多人就开始支支吾吾了。

本章,我们就从同步渲染的瓶颈出发,逐步拆解 React 并发模式的核心设计思路。读完之后,你不仅能应对面试中的并发相关问题,还能在实际项目中更好地使用 useTransitionuseDeferredValue 等 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 会:

  1. 从触发更新的组件开始,向上标记需要更新的子树
  2. 从根节点开始,递归地对整棵需要更新的子树进行 reconciliation(调和),生成新的虚拟 DOM 树
  3. 一次性把所有变更 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 的核心逻辑是:

  1. 把任务按优先级排序放入队列
  2. MessageChannel(而非 requestIdleCallbacksetTimeout)调度宏任务
  3. 每次从队列中取出最高优先级的任务执行
  4. 每执行一个工作单元后检查是否超过 5ms 的时间片
  5. 如果超时,让出控制权,下一个宏任务再继续
// 简化的调度逻辑
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 优先级从高到低

  1. SyncLane:同步优先级,必须立即执行(如 flushSync
  2. InputContinuousLane:连续输入(如拖拽、滚动)
  3. DefaultLane:默认优先级(如普通的 setState
  4. TransitionLane:过渡优先级(startTransition 包裹的更新)
  5. 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 并发模式的核心设计。

核心要点

  1. 同步渲染的问题:大组件树的渲染会阻塞主线程,导致交互卡顿
  2. Fiber 架构:用链表替代调用栈,使渲染过程可中断、可恢复
  3. 时间切片:每 5ms 检查一次,超时让出控制权,通过 MessageChannel 调度
  4. useTransition:把 setState 标记为低优先级,返回 isPending 表示状态
  5. useDeferredValue:让值”慢半拍”,适合无法控制更新源的场景
  6. Lane 模型:用位运算表示优先级,支持批量操作
  7. Tearing:并发模式特有的 UI 不一致问题,用 useSyncExternalStore 解决

本章思维导图

React:并发模式
  • 为什么需要并发
    • 同步渲染阻塞主线程
    • 大组件树导致交互卡顿
  • 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,要求:

  1. 接收 subscribegetSnapshot 两个参数
  2. 当外部 store 变化时,触发组件重渲染
  3. 返回最新的 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