Bakka's Blog

react并发模式原理与应用

2026/2/1

React并发模式部分:

并发模式带来的优化

并发模式优化的是react执行渲染的所耗费的时间

相关性能指标

总阻塞时间(TBT)是一个重要指标,它测量从首次内容绘制(First Contentful Paint, FCP)到可交互(Time to Interactive, TTI)之间的时间。TBT 是执行时间超过 50 毫秒的任务所花费时间的总和,这会对用户体验产生重大影响。

由于在 TTI 之前有两个任务执行时间超过 50 毫秒,TBT 为 45 毫秒,这两个任务分别超过了 50 毫秒阈值 30 毫秒和 15 毫秒。总阻塞时间是这些值的累积:30 毫秒 + 15 毫秒 \\\\\\\= 45 毫秒。

由于在 TTI 之前有两个任务执行时间超过 50 毫秒,TBT 为 45 毫秒,这两个任务分别超过了 50 毫秒阈值 30 毫秒和 15 毫秒。总阻塞时间是这些值的累积:30 毫秒 + 15 毫秒 = 45 毫秒。

交互式下一次绘制(INP),一项新的核心网页指标,衡量用户首次与页面交互(例如点击按钮)到该交互在屏幕上可见(下一次绘制)的时间。这一指标对于具有大量用户交互的页面尤为重要,例如电子商务网站或社交媒体平台。它通过累积用户当前访问期间的所有 INP 测量值,并返回最差分数来衡量。

交互式下一次绘制为 250 毫秒,因为这是测量的最高视觉延迟

交互式下一次绘制为 250 毫秒,因为这是测量的最高视觉延迟

  • TBT(Total Blocking Time) 衡量:主线程被 >50ms 长任务阻塞的总时长。
  • INP(Interaction to Next Paint) 衡量:一次用户交互 → 下一次可视更新的延迟。

二者的共同敌人都是:

长时间不可中断的 JS 执行。

长时间不可中断的 JS 执行的来源于react的计算,这也是原本同步react的缺陷

同步react

React 的渲染阶段是一个纯计算阶段,React 元素与现有 DOM 进行比对(即比较)。这个阶段涉及创建一个新的 React 元素树,也称为”虚拟 DOM”,它本质上是实际 DOM 的一个轻量级内存表示。

在渲染阶段,React 计算当前 DOM 与新的 React 组件树之间的差异,并准备必要的更新。

在渲染阶段之后是提交阶段。在这个阶段,React 将渲染阶段计算出的更新应用到实际的 DOM 上。这包括创建、更新和删除 DOM 节点,以反映新的 React 组件树。

在传统的同步渲染中,React 会对组件树中的所有元素给予相同的优先级。

当渲染组件树时,无论是初始渲染还是状态更新,React 会继续以单个不可中断的任务渲染整个树,然后将其提交到 DOM 中以更新屏幕上的组件。

react的执行时间是执行Javascript的时间,同步渲染的过程,每个组件一旦开始了就必须渲染出来(render phase,整颗fiber tree),不允许中断,在重型组件中,就会导致阻塞其他任务

为了解决这个问题,有了并发模式的react

并发模式效果

React 18 引入了一个新的并发渲染器,在后台运行。这个渲染器提供了一些方式让我们标记某些渲染为非紧急。

在渲染低优先级组件(粉色)时,React 会让出主线程以检查更重要的任务

在渲染低优先级组件(粉色)时,React 会让出主线程以检查更重要的任务

在这种情况下,React 每 5 毫秒会回退到主线程,查看是否有更重要的任务需要处理,例如用户输入,甚至渲染对用户体验更重要的 React 组件状态更新。通过持续回退到主线程,React 能够使这些渲染操作非阻塞,并优先处理更重要的任务。

在每次渲染不是单一不可中断的任务的情况下,并发渲染器在低优先级组件的(重新)渲染过程中每隔 5 毫秒将控制权交还给主线程。

此外,并发渲染器能够在后台“并发地”渲染组件树的多个版本,而无需立即提交结果。

同步渲染是一种全有或全无的计算,而并发渲染器允许 React 暂停和恢复一个或多个组件树的渲染,以实现最优的用户体验。

React 基于用户交互而暂停当前渲染,迫使它优先渲染另一个更新

React 基于用户交互而暂停当前渲染,迫使它优先渲染另一个更新

利用并发特性,React 可以根据用户交互等外部事件暂停和恢复组件的渲染。当用户开始与

ComponentTwo 交互时,React 暂停了当前的渲染,优先渲染 ComponentTwo,然后继续渲染 ComponentOne 。

假设有一个文本输入框和一个包含大量城市的列表,该列表会根据文本输入框的当前值进行过滤。在同步渲染中,React 会在每次按键时重新渲染 CitiesList 组件。由于列表包含数万个城市,这是一个非常昂贵的计算,因此,在按键和看到文本输入框中反映这一变化之间存在明显的视觉反馈延迟。如果你使用的是高端设备,你可能需要将 CPU 限制为 4 倍以模拟低端设备。

import React, { useState } from "react";
import CityList from "./CityList";

export default function SearchCities() {
  const [text, setText] = useState("Am");

   return (
      <main>
          <h1>Traditional Rendering</h1>
          <input type="text" onChange={(e) => setText(e.target.value) }   />
          <CityList searchQuery={text} />
      </main>
     );
};

每次按键都会因为要渲染CityList发生长任务阻塞绘制,导致输入ab,的a和b之间存在明显的延迟,这是不理想的。

在这种情况下,React 开发者通常会使用 debounce 等第三方库来延迟渲染,但没有内置的解决方案。

在每次按键时直接更新并作为 searchQuery 参数的值,这反过来又会导致每次按键时进行同步渲染调用(props的更新导致render)

并发模式下我们可以降低CityList更新的优先级,导致一个下一个字符被输入时,打断CityList的渲染,优先绘制输入的字符

并发模式的使用方式

常见的两种方式是transition和suspense

  • transition 是:
    • “由优先级标记触发的并发 render 调度”
  • Suspense 本质上是:
    • “由异步条件触发的并发 render 暂停 / 恢复”

两者共享同一套机制:

  • Fiber
  • 时间切片
  • 抢占式调度

Transition

一个方式是使用useTransition的hooks

我们可以通过使用由 useTransition 钩子提供的 startTransition 函数将更新标记为非紧急。这是一个强大的新功能,允许我们将某些状态更新标记为“过渡”,表示它们可能导致视觉变化,如果同步渲染,可能会中断用户体验。

通过将状态更新包装在 startTransition 中,我们可以告诉 React 我们愿意推迟或中断渲染,以优先处理更重要的任务,保持当前用户界面具有交互性。

当触发startTransition时(类似setState是一个触发渲染的update信号),并发渲染器会开始构建fiber tree。一旦渲染完成,它会将结果保存在内存中,直到 React Scheduler能够高效地更新 DOM 以反映新状态。这个时刻可能是浏览器空闲且没有更高优先级的任务(如用户交互)待处理

对于之前的例子,我们可以将状态拆分为两个值,并将 searchQuery 的状态更新在 startTransition 中。

现在当我们输入字段中输入时,用户输入保持流畅,按键之间没有任何视觉延迟。这是因为 text 状态仍然同步更新,输入字段将其用作 value

import React, { useState, useTransition } from "react";
import CityList from "./CityList";

export default function SearchCities() {
  const [text, setText] = useState("Am");
  const [searchQuery, setSearchQuery] = useState(text);
  const [isPending, startTransition] = useTransition();

   return (
      <main>
          <h1><code>startTransition</code></h1>
          <input
              type="text"
              value={text}
              onChange={(e) => {
                 setText(e.target.value)
                 startTransition(() => {
                    setSearchQuery(e.target.value)
                 })
             }}  />
          <CityList searchQuery={searchQuery} />
      </main>
     );
};

在运行时,React 在每次按键时开始以并发模式渲染新的树。但这个任务并非1/0的同步操作,React 在当前 UI(显示“旧”状态)保持对进一步用户输入的响应的同时,开始在内存中准备组件树的新版本。

Suspense in react 18

另一个重要的并发特性是 Suspense

早期(React 16):

  • Suspense ≈ React.lazy 的加载占位
  • 解决的是 代码没下载完

React 18 之后:

  • Suspense 可以覆盖 数据没准备好
  • 条件不再只是 “JS 加载完成”
  • 而是 “这个组件渲染所需的前置条件是否满足”

这里主要讲react 18下suspense基于并发模式的特性,在包体积相关的优化时会展开讲前者的用法

Suspense 是一个声明式的“渲染是否可继续”边界。

使用 Suspense ,我们可以在满足特定条件(如从远程源加载数据)之前延迟组件的渲染。在此期间,我们可以渲染一个备用组件,以指示该组件仍在加载中。

  • 不再写:
if (!data)return<Loading />
  • 而是:
    • 组件在 render 阶段 直接等待(suspend)
    • 外层 Suspense 决定显示 fallback

好处是因果关系很清楚:

  • 数据没到 → 组件不能渲染 → fallback 出现
  • 逻辑从「组件内部条件分支」转为「渲染流程的一部分」

例如,当组件被挂起时,比如因为它仍在等待数据加载,React 不会只是闲置直到组件收到数据。相反,它会暂停挂起组件的渲染,并将注意力转移到其他任务上。

  • 旧方案 (useEffect + useState)
function Profile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser().then(data => {
      setUser(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <Spinner />; // 手动处理状态
  return <div>{user.name}</div>;
}
  • 新方案 (Suspense + 支持异步的库): 使用 TanStack Query (React Query) 或集成框架时:
// 开启 suspense 模式
const { data: user } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser });

// 组件里直接写业务逻辑,不需要判断 loading
// 如果数据没回,React 会自动去上层找最近的 <Suspense>
return <div>{user.name}</div>;
  • 应用点:你可以把加载状态(Loading)的控制权上移到父级。多个并排的组件可以被包裹在一个 Suspense 里,实现“全有或全无”的同步显示,避免页面像打补丁一样一块块跳出来。

Suspense结合服务端能力,进行ssr时,还能结合并发模式有更强的能力,最终出现rsc这样的东西,感兴趣可阅读一开始贴出的一些文章

Concurrent mode Under the hood

这里谈并发模式这一套机制怎么实现的

Scheduler

在使用scheduler之前,react使用的是requestidleCallback进行任务的执行(同步方案)

他利用空闲时间的方式是以帧为单位

浏览器判断:在下一帧截止前,还有“用不完的时间”

这段时间:

  • 不影响输入
  • 不影响动画
  • 可以被“让出去”

浏览器通过 requestIdleCallback 暴露它。

浏览器的一帧(16.67ms):

1. 处理输入事件(input / click / scroll)
2. 执行 JavaScript
3. 样式计算(Recalculate Style)
4. 布局(Layout / Reflow)
5. 绘制(Paint)
6. 合成(Composite)
7. 等待下一帧

Scheduler的想法是既然 JavaScript 不能被抢占,那就把一件大工作拆成很多次“短暂占用主线程”,每次占用完立刻退出,让浏览器有机会处理交互以及绘制等等任务

拆分任务的单位是一次render,从root到fiber树构建到commit的过程,

让出主线程的方式是,把当前的render任务放到eventloop的宏任务队列,这样主线程js执行的负担会大量减少,同步js基本不会阻塞事件、交互等等js的执行

而等到主线程空闲时主线程自然就会处理宏任务,这样就自然地实现了任务的中断和恢复

中断即意味着一次任务拆分,中断的触发由两个事情决定,分别是时间片耗尽和更高优先级的任务抢占

调度器自己会维护一个优先队列,把短时间内的渲染任务放在里面,通过循环直至处理完所有任务

完成一个任务/中断一个任务,会把优先队列的队头放入浏览器的宏任务队列,决定下一个执行的任务

优先级依据

核心依据有三类(从高到低):

  1. 是否直接来自用户输入
    • 例如:点击、输入、滚动、拖拽。
    • 这类更新如果延迟,用户会立刻感觉“卡”。
  2. 是否会阻塞界面可用性
    • 比如:影响布局、可点击状态、视觉反馈的更新。
    • 这些更新即使不是输入触发,也必须尽快完成。
  3. 是否属于“可以延后”的状态变化
    • 例如:搜索结果列表、复杂计算后的展示。
    • 延迟不会破坏当前交互,只影响“内容新不新”。

React 在内部会把这些场景映射到不同的 Lane 类型:

  • 同步(Sync)
  • 用户阻塞型(Input/Blocking)
  • 普通(Default)
  • 过渡(Transition)
  • 空闲(Idle)

所以,优先级本质上 = 这个更新对“当前交互是否顺畅”的影响程度。

Lane

Update是触发react渲染的来源

Update 类型典型 API / 场景语义结果
State 更新setState, dispatch, useState组件内部状态变化分配 Lane → 触发一次 render
Props 变化父组件重新 render输入数据变化子 Fiber 产生 Update
Context 更新Context.Provider value 改变依赖的全局状态变化所有订阅 Fiber 更新
Force 更新forceUpdate()跳过 shouldComponentUpdate强制 render
外部 StoreuseSyncExternalStore外部数据源变更并发安全订阅更新
Suspense 恢复Promise resolve异步数据就绪从 fallback 恢复渲染

Update的对象是一个fiber,快照和快照之间发生的一系列update通过Lane表示

Lane是一个表示某个fiber更新的优先级的集合,就像这样

export const TotalLanes = 31;
export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000001;
export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lanes = /*            */ 0b0000000000000000000000000000100;
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000001000;
export const DefaultLane: Lanes = /*                    */ 0b0000000000000000000000000010000;
const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000000100000;
const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111111000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000001000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane16: Lane = /*                       */ 0b0000000001000000000000000000000;
const RetryLanes: Lanes = /*                            */ 0b0000111110000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000010000000000000000000000000;
const RetryLane5: Lane = /*                             */ 0b0000100000000000000000000000000;
export const SomeRetryLane: Lane = RetryLane1;
export const SelectiveHydrationLane: Lane = /*          */ 0b0001000000000000000000000000000;
const NonIdleLanes = /*                                 */ 0b0001111111111111111111111111111;
export const IdleHydrationLane: Lane = /*               */ 0b0010000000000000000000000000000;
export const IdleLane: Lanes = /*                       */ 0b0100000000000000000000000000000;
export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000;

Lane 的本质不是“优先级”,而是“可并行状态集合的高效表示符号与运算模型”。

在复杂调度的语境下,相对普通类似集合的数据结构,位图模型的性能和实现难度都有很大的优势

Lane 用于标记更新的优先级,我们也可以说标记一项工作的优先级。

fiber 树是一个可双向遍历的链表树

这意味着lane可冒泡,最终是否yield是由这些子lane经过一系列冒泡位运算,得到的根lane

rootlane参与scheduler的调度

lane的生命周期:

Lane诞生于一个update(比如一次setState,这往往发生在workloop之外,因为workloop是阻塞其他计算的),然后,update进入系统的方式是在current fiber进行标记,首先会冒泡影响root Lane,作为下次调度的优先级依据,其次下次的render的reconcile会把这个update消费落实

并发模式运行方式

为了避免一些条件判断开销,旧的同步循环和新的并发循环是两种方式

在用到了并发特性时,才会进入并发循环,发生上述的fiber行为变化,否则还是纯粹的同步循环

对于一个fiber的节点:

开始之前:

  • 并发逻辑: 在进入 beginWork 之前,React 会先对比当前 rootFiber 节点的 lanes(优先级)。
  • Bailout (跳过) 机制: 如果当前 Fiber 节点的优先级低于本次渲染的优先级,且子节点也没有更高优先级的更新(检查 childLanes),React 会直接克隆并跳过这个 Fiber 及其子树,不去执行 Diff。这是并发模式高性能的关键。

useState 在 Render Phase 的计算逻辑

  • 同步模式:hook.queue 里的 update 只要有,就全量执行,算出新 state。
  • 并发模式: useState 的内部逻辑变成了 “优先级筛选器”
    • 场景: 假设有三个更新:A(Sync), B(Transition), C(Sync)。
    • Render Phase 内部逻辑: 当 React 在渲染一个 Transition 优先级时,它会跳过 A 和 C。
    • 计算差异: 它必须维护一个 baseState 和 baseQueue。当它渲染低优先级的 B 时,它会记录下哪些高优先级的更新被跳过了,以便在未来重新合并。
    • 结论: Hooks 的内部代码完全变了。 为了支持 Lane,Hooks 的状态更新函数需要处理复杂的数学逻辑,以保证在多次打断、重试后,状态依然正确。

Transition的工作方式:

  • 分配低优先级 Lane: startTransition 会将其包裹的状态更新标记为 TransitionLane(这是一个比用户输入、点击优先级低很多的等级)。
  • 渲染决策:
    • 当渲染进入 workLoopConcurrent 时,React 发现当前处理的是 TransitionLane。
    • 在每一轮 Fiber 单元处理后,shouldYield() 会检查:“是否有更高优先级的任务(比如 SyncLane 的点击事件)进来了?”
  • 打断(Interruption):
    • 如果有,React 会立即中断当前的 Transition 渲染。
    • 关键点: 此时 WIP 树被丢弃或搁置,React 先去处理高优先级的点击事件,渲染完成后,再重新启动刚才被中断的 Transition 任务。
  • 感知: 这就是为什么你在搜索框输入时,输入框不会卡顿(SyncLane),而下方的搜索列表可以“慢吞吞”地渲染(TransitionLane)。

这个过程是基本不影响fiber构建过程的,在影响fiber的选择上

Suspense则不同

在并发模式中,一个 Suspense 结构大致是:

<Suspense>
  primary children// 正常内容
  fallback children// loading / 占位
</Suspense>

内部对应:

  • 一棵 主内容树(primary tree)
  • 一棵 fallback 树
  • “切换” = 这次 commit 选择 fallback 作为可见子树
  • 而不是把原来的内容删掉再重建

正常情况:

Suspense
 └─ PrimaryContent Fiber Tree

挂起时:

Suspense
 ├─ Offscreen(Primary ContentFiber Tree)// 隐藏
 └─ FallbackFiber Tree// 当前可见

Offscreen = 既不卸载,又不阻塞,还可被延后调度的隐藏子树。只是通过极低的lane让他到最后再执行

挂起意味着,产生fiber过程中,某个fiber依赖的数据没有获取到,那么就能通过offScreen进行封存,保存生成的fiber,拿完数据后,下次rerender的diff中,不用生成已经生成过的fiber

Suspense 的恢复 = Promise 触发一次低成本的重渲染,React 通过 Offscreen 复用旧 Fiber,把隐藏的主内容重新变为可见

What’s next

懒加载和并发模式结合,在ssr中的性能优化,可以看这两个资料的相关部分

https://vercel.com/blog/how-react-18-improves-application-performance

https://oceanofpdf.com/category/authors/nadia-makarevich/