并发模式:让渲染学会排队、插队和让路

文章来源声明: 原文作者:前端阿凡; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690799816373878811; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把 Fiber、调度器、Lane 与三个并发 API 串成一条完整因果链,适合已懂 Hooks、想真正吃透 React 18 性能调优的中高级前端,照着优先级思路定位卡顿更新比盲加 memo 更有效。

Day15 拆过 Fiber,说它让渲染"可中断";Day16 顺着 Fiber 讲了 Hooks 的跨渲染记忆从哪来。今天把"可中断"三个字兑现成真实产品能力,也就是 React 18 的并发特性。官方叫 Concurrent Features,民间还习惯叫 Concurrent Mode。名字里有 Concurrent,但它和"多线程并行"没有一毛钱关系,JS 是单线程的。React 做的是另一件更微妙的事:**把一次渲染拆成可以随时暂停、让位、重来的多个片段,然后按优先级决定谁先谁后。**
  1. 技术难点:同步渲染是一把焊死主线程的锁

先回到没有并发时的渲染模型。一次 setState 触发一次渲染,React 从根 Fiber 出发,把需要更新的整棵组件树一口气全部执行完,再统一提交 DOM。这个流程是同步的、不可中断的:render phase 一旦开始,必须跑完才能碰下一件事。

想清楚这个模型的代价,用一个经典场景:搜索框过滤 2 万条数据。onChange 每敲一个字都触发一次完整渲染,单次渲染算上过滤、diff、虚拟 DOM 重建要 100ms 以上。打字是个高频事件,两次按键间隔可能只有 80ms,于是输入事件永远排在渲染后面排队。表现就是:input 里字母半天不出现、光标闪烁停滞、列表一顿一顿。浏览器连一帧都画不出来,因为主线程被 React 占着。

把问题抽象出来,同步渲染有三宗罪:

  1. 长任务独占主线程。渲染吃掉全部 CPU 时间片,浏览器没有机会执行帧绘制和事件处理,交互被饿死。
  2. 所有更新一视同仁。用户打字(紧急)和列表过滤结果(不紧急)走同一条同步路径,紧急的等不紧急的。
  3. 没有中间态。旧 UI 和"渲染中的新 UI"之间没有过渡,要么卡在旧界面,要么等新界面整体就绪后跳变。

难点本质在这里:渲染一旦开始就必须跑完,CPU 不够用时它选择牺牲交互而不是让出。解决它需要三个能力:渲染能被打断并让出主线程;被打断后能干净地恢复或直接放弃(不产出脏 UI);高优先级更新能插队,低优先级更新能被推迟甚至重来。

  1. 完整解法:可中断渲染 + Lane 优先级 + 三个并发 API

2.1 地基:Fiber 让"打断"成为可能

为什么 React 17 之前做不到可中断?因为递归。旧的 reconcile 用递归遍历组件树,函数调用栈一旦入栈,语言层面就无法从中间退出再回来。Fiber 的改造(Day15 讲过)本质是把递归改成链表上的迭代:每个 Fiber 节点只做一小份工作,做完通过 return / sibling / child 指针找到下一个节点。工作单元小到可以随时放下,链表结构让"下次从哪里继续"有据可查。

于是 render phase 变成了一个可以被任意暂停的循环:

while (workInProgress !== null && !shouldYield()) {
  workInProgress = <span>performUnitOfWork</span>(workInProgress);  <span>// 处理一个 Fiber</span>
}

shouldYield() 返回 true,循环就停在这里,workInProgress 指针记住了现场。等主线程有空了,从指针处接着干。这就像读一本长篇小说,随时能夹书签走人,下次翻开不用从头找。

2.2 调度器:5ms 让出 + MessageChannel

谁来决定 shouldYield() 什么时候返回 true?React 内置的 Scheduler 包。它维护一个任务队列,每个渲染任务有个过期时间。Scheduler 用 MessageChannel 把任务排到宏任务里执行,每处理一段时间就检查一次当前帧还剩多少时间(performance.now() 对比帧预算)。超过预算(默认约 5ms)就暂停当前工作,把控制权交还浏览器去绘制帧、处理输入。

5ms 是刻意选的:一帧 16.6ms,React 只占用不到三分之一,剩下的留给浏览器。用户感受到的效果是"页面一直能响应",而不是"卡一下,然后猛地出结果"。

2.3 Lane:优先级不是数字,是位图

有了中断能力,还要回答"谁先谁后"。React 没有用简单的数字优先级,而是一组叫 Lane(车道) 的位图。一个 31 位的二进制数,每一位代表一条车道,从 SyncLane(最高)到 IdleLane(最低),可以同时标记多个 lane。更新的优先级用位运算表达:lane & allLanes 判断是否包含某级,getHighestPriorityLane 取最高位。

车道分三档大方向:同步(SyncLane,如 flushSync)、连续输入(InputContinuousLane,如 onChange 里的更新)、默认与过渡(DefaultLane / TransitionLane,如 startTransition 包起来的更新)。渲染时 React 永远先处理高 lane 的更新;低 lane 的更新跑到一半被高 lane 打断,直接丢弃重来(反正 Fiber 树还在,重跑成本只是时间)。

2.4 三个并发 API:startTransition、useDeferredValue、Suspense

可中断和调度是引擎,API 是方向盘。React 18 把它们暴露成三个面向开发者的工具:

startTransition:手动把不紧急的状态更新标记为"过渡"。标记后这个更新走 TransitionLane,可被任何紧急更新打断。

<span>import</span> { useState, useTransition } <span>from</span> <span>"react"</span>;

<span>export</span> <span>default</span> <span>function</span> <span>Tabs</span>(<span></span>) {
  <span>const</span> [tab, setTab] = <span>useState</span>(<span>"home"</span>);
  <span>const</span> [isPending, startTransition] = <span>useTransition</span>();

  <span>function</span> <span>switchTab</span>(<span>next</span>) {
    <span>// 不包:setTab 是紧急更新,切到重型 tab 时会卡住当前页面</span>
    <span>// 包上:切换变成过渡,旧界面保持可交互,新内容在后台慢慢渲染</span>
    <span>startTransition</span>(<span>() =></span> <span>setTab</span>(next));
  }

  <span>return</span> (
    <span><span><<span>div</span>></span>
      <span><<span>button</span> <span>onClick</span>=<span>{()</span> =></span> switchTab("home")}>首页<span></<span>button</span>></span>
      <span><<span>button</span> <span>onClick</span>=<span>{()</span> =></span> switchTab("heavy")}>重型报表<span></<span>button</span>></span>
      {isPending ? <span><<span>p</span> <span>style</span>=<span>{{</span> <span>opacity:</span> <span>0.6</span> }}></span>正在准备视图…<span></<span>p</span>></span> : null}
      {tab === "home" ? <span><<span>Home</span> /></span> : <span><<span>HeavyReport</span> /></span>}
    <span></<span>div</span>></span></span>
  );
}

isPending 是附带福利:过渡进行中为 true,可以用来给旧 UI 加个"加载中"的视觉提示,而不用真的白屏等新内容。

useDeferredValue:声明式的"延迟值",适合自己控制不了 setState 调用点的场景。它返回一个值的滞后版本,React 会先用旧值渲染(保证响应),再用低优先级渲染新值。最经典的落地就是大列表过滤:

<span>import</span> { useState, useDeferredValue, useMemo } <span>from</span> <span>"react"</span>;

<span>const</span> <span>ALL_ITEMS</span> = <span>Array</span>.<span>from</span>({ <span>length</span>: <span>20000</span> }, <span>(<span>_, i</span>) =></span> ({
  <span>id</span>: i,
  <span>name</span>: <span>`商品 <span>${i}</span>`</span>,
}));

<span>export</span> <span>default</span> <span>function</span> <span>SearchBox</span>(<span></span>) {
  <span>const</span> [keyword, setKeyword] = <span>useState</span>(<span>""</span>);
  <span>// keyword 的"延迟副本":紧急渲染用 keyword,闲下来再渲染 deferredKeyword</span>
  <span>const</span> deferredKeyword = <span>useDeferredValue</span>(keyword);
  <span>const</span> isStale = keyword !== deferredKeyword;

  <span>const</span> list = <span>useMemo</span>(<span>() =></span> {
    <span>const</span> kw = deferredKeyword.<span>toLowerCase</span>();
    <span>return</span> <span>ALL_ITEMS</span>.<span>filter</span>(<span>(<span>it</span>) =></span> it.<span>name</span>.<span>includes</span>(kw));
  }, [deferredKeyword]);

  <span>return</span> (
    <span><span><<span>div</span> <span>style</span>=<span>{{</span> <span>font:</span> "<span>14px</span>/<span>1.6</span> <span>system-ui</span>" }}></span>
      <span><<span>input</span>
        <span>value</span>=<span>{keyword}</span>
        <span>onChange</span>=<span>{(e)</span> =></span> setKeyword(e.target.value)}
        placeholder="过滤 2 万条数据,试试连打"
      />
      <span><<span>p</span> <span>style</span>=<span>{{</span> <span>opacity:</span> <span>isStale</span> ? <span>0.45</span> <span>:</span> <span>1</span> }}></span>
        {list.length} 条结果{isStale ? "(渲染中…)" : ""}
      <span></<span>p</span>></span>
      <span><<span>ul</span> <span>style</span>=<span>{{</span> <span>maxHeight:</span> <span>240</span>, <span>overflow:</span> "<span>auto</span>" }}></span>
        {list.slice(0, 80).map((it) => (
          <span><<span>li</span> <span>key</span>=<span>{it.id}</span>></span>{it.name}<span></<span>li</span>></span>
        ))}
      <span></<span>ul</span>></span>
    <span></<span>div</span>></span></span>
  );
}

运行它然后飞快打字:input 里的字母即时回显(紧急更新),列表区域显示半透明"渲染中"状态并慢慢追上来(过渡更新反复被按键打断,直到你停顿才真正算完)。React 18 之前这个组件每敲一个字都会白屏卡顿。useDeferredValue 与 startTransition 的区别只有一个:前者延迟的是值(读它的一方重渲染),后者延迟的是更新本身;效果等价,前者代码侵入更小。

Suspense + use:渲染可以"等待"。配合并发,Suspense 让组件在异步数据未就绪时先渲染 fallback,数据到达后无缝补上,不必用 loading 状态手搓。React 19 的 use() 可以直接接收 promise,在渲染中挂起:

<span>import</span> { <span>Suspense</span>, use } <span>from</span> <span>"react"</span>;

<span>const</span> <span>fetchProfile</span> = (<span></span>) =>
  <span>fetch</span>(<span>"/api/profile"</span>).<span>then</span>(<span>(<span>r</span>) =></span> r.<span>json</span>());

<span>function</span> <span>Profile</span>(<span></span>) {
  <span>const</span> data = <span>use</span>(<span>fetchProfile</span>()); <span>// 数据没到就挂起,让 Suspense 接管</span>
  <span>return</span> <span><span><<span>div</span>></span>{data.name} · {data.title}<span></<span>div</span>></span></span>;
}

<span>export</span> <span>default</span> <span>function</span> <span>App</span>(<span></span>) {
  <span>return</span> (
    <span><span><<span>Suspense</span> <span>fallback</span>=<span>{</span><<span>div</span>></span>加载中…<span></<span>div</span>></span>}>
      <span><<span>Profile</span> /></span>
    <span></<span>Suspense</span>></span></span>
  );
}

2.5 代价:tearing 与 useSyncExternalStore

可中断渲染带来一个隐蔽的新问题。既然同一时刻可以存在"旧版本 UI 正在展示、新版本 UI 正在后台渲染"两个世界,那么外部 store(Redux、Zustand、自研 store)的值可能在一次渲染中间发生变化。两个组件在同一轮渲染里读到不同版本的值,UI 就撕裂了(tearing)。React 的解法是 useSyncExternalStore:渲染期间反复调用 getSnapshot 校验,值变了就放弃本轮重新渲染,保证单次渲染内看到的 store 版本一致:

<span>import</span> { useSyncExternalStore } <span>from</span> <span>"react"</span>;

<span>const</span> store = {
  <span>state</span>: <span>0</span>,
  <span>listeners</span>: <span>new</span> <span>Set</span>(),
  <span>subscribe</span>(<span>listener</span>) {
    store.<span>listeners</span>.<span>add</span>(listener);
    <span>return</span> <span>() =></span> store.<span>listeners</span>.<span>delete</span>(listener);
  },
  <span>getSnapshot</span>(<span></span>) {
    <span>return</span> store.<span>state</span>;
  },
  <span>inc</span>(<span></span>) {
    store.<span>state</span>++;
    store.<span>listeners</span>.<span>forEach</span>(<span>(<span>l</span>) =></span> <span>l</span>());
  },
};

<span>function</span> <span>Counter</span>(<span></span>) {
  <span>const</span> count = <span>useSyncExternalStore</span>(
    store.<span>subscribe</span>,
    store.<span>getSnapshot</span>
  );
  <span>return</span> (
    <span><span><<span>button</span> <span>onClick</span>=<span>{()</span> =></span> store.inc()}>
      点击了 {count} 次
    <span></<span>button</span>></span></span>
  );
}

这个 API 取代了老的 useSyncExternalStore 前身(useMutableSource)和无数手写 useEffect + setState 订阅模式,是 React 18 给第三方状态库的"标准插座"。

  1. 应用场景

什么时候值得用,什么时候别用,这是并发特性最容易被误伤的地方。判断标准一句话:更新是不是"用户正在等"的那一个。

  • 值得用:输入框联动的重型过滤 / 搜索联想(useDeferredValue);点击后内容很重但用户不急着看的视图切换(startTransition);路由懒加载与按需数据(Suspense 流式渲染);图表库、编辑器这类第三方 store 接入 React(useSyncExternalStore)。
  • 别乱用:表单提交、保存按钮的反馈、模态框开关这类用户盯着等结果的更新,包进 transition 反而让反馈延迟,制造"点了没反应"的错觉。
  • 踩坑提醒:startTransition 里不能包输入框自身的 setState(输入要即时回显,属于紧急更新),只能包依赖它的计算结果;useDeferredValue 要和 useMemo 配合,否则延迟值每次渲染都触发整棵子树重建,优化变负优化。
  1. 总结

把三天的线索串起来:Fiber 给了 React 一台"可随时暂停的引擎"(Day15),Hooks 给了组件跨渲染的记忆(Day16),并发特性则给了这套引擎调度规则。它们的共同地基是同一个:渲染不再是一次性的全有或全无,而是一个可以排队、插队、让路、重来的持续过程。

并发模式没有引入并行,它引入的是秩序:主线程的时间被切成小片,紧急的事先做,不紧急的事让路,做一半的事可以放弃重来。这套秩序最终表现为开发者手里三个克制而精确的 API:startTransition 标记不紧急的更新,useDeferredValue 声明可以等的值,Suspense 让渲染本身学会等待。理解了"更新有优先级、渲染可中断",React 的性能调优就不再是玄学式的"加 memo",而是顺着优先级思路去找真正卡住交互的那次更新。