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

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);高优先级更新能插队,低优先级更新能被推迟甚至重来。

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

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

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

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

scss 复制代码
while (workInProgress !== null && !shouldYield()) {
  workInProgress = performUnitOfWork(workInProgress);  // 处理一个 Fiber
}

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,可被任何紧急更新打断。

jsx 复制代码
import { useState, useTransition } from "react";

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

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

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

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

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

jsx 复制代码
import { useState, useDeferredValue, useMemo } from "react";

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

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

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

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

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

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

jsx 复制代码
import { Suspense, use } from "react";

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

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

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

2.5 代价:tearing 与 useSyncExternalStore

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

jsx 复制代码
import { useSyncExternalStore } from "react";

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

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

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

3. 应用场景

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

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

4. 总结

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

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

相关推荐
可乐鸡翅yeah_2 小时前
新手梳理:M3U8 线上问题,哪些是前端锅,哪些是后端锅
前端·ios·音视频·实时音视频·m3u8·音视频在线播放
Flynt2 小时前
Linear 用 1000 个 PR 换掉 styled-components,我写了 200 个按钮,把这笔账复现了一遍
前端·css·preact
JavaGuide3 小时前
NVIDIA 又开源了!这次给 AI Agent 加上权限管控
前端·后端
excel3 小时前
prisma 如何处理数据库竞态
前端·数据库·后端
莪_幻尘5 小时前
Skill 体检:30 个 Skill 全凭感觉?体检器先自曝了 8 个“假 0 分
前端·人工智能·llm
风骏时光牛马5 小时前
AI模型综合能力评测:性能、指令遵循与多场景实测对比
前端
Frag0ut5 小时前
Chrome与Chromium内核浏览器在Windows 11上的新特性全景解析
前端·chrome·windows·web安全·chromium·gemini ai·playready drm
hiahiahia1235 小时前
实现完整 Tool Dispatcher
开发语言·前端
IT_陈寒6 小时前
Java线程池这破玩意,差点让我周末加班排查到凌晨
前端·人工智能·后端