React 学习站
React›前沿与实践›前沿

并发渲染与调度

前沿前沿与实践

一句话定义

并发渲染(React 18 起默认)让 React 可以中断渲染、按优先级排队、丢弃过时结果——不是「多线程」,而是把渲染变成可抢占的任务流,让紧急更新(输入)不被缓慢更新(大数据列表)阻塞。

为什么重要

它是理解 React 18/19 所有新 API 的钥匙:useTransition、useDeferredValue、Suspense 的流式行为、Actions 的语义,全部建立在「渲染可中断」之上。不懂调度,这些 API 只能当语法糖背下来。

前置知识

  • kp-008 Fiber 与渲染阶段可中断。
  • kp-020 Suspense(并发让 fallback 显示时 UI 仍可交互)。

核心概念

  • Lane(优先级模型):React 内部用位掩码给更新分优先级:同步输入 > 过渡(Transition)> 离屏(Offscreen)。
  • 时间切片(Time Slicing):长渲染拆成 ≤5ms 的工作单元,之间让路给输入事件。
  • 可中断渲染:渲染阶段随时暂停;期间来了高优先级更新 → 当前渲染丢弃重来(这就是组件必须纯的终极原因)。
  • 过渡更新(Transition):被标记为「非紧急」的更新,可以让位于输入,且保证最终显示(不丢)。
  • 并发安全的 API 时代:useDeferredValue、useSyncExternalStore、Suspense 流式、Actions。

原理 / 机制

同步模式 vs 并发模式的时序对比:

text【同步模式(React 17 及以前)】
输入事件 → 渲染整个慢列表(300ms) → 期间输入无响应 → 提交 → 下一帧

【并发模式(React 18+)】
输入事件 → 开始渲染慢列表(过渡优先级)
        ↘ 5ms 后用户又敲了一个字符
          → 高优先级更新抢占,慢渲染作废
          → 快速渲染新输入 → 提交
          → 空闲时重新以新状态续渲染慢列表

关键洞察:并发模式里同一个界面可能同时存在「已显示的旧 UI」和「渲染中的新 UI」。React 保证随时可退回旧 UI(已提交的那份),这要求:

  1. 组件纯函数(同一输入永远同一输出,重放无副作用)。
  2. 副作用全部挪到提交之后(effect)或事件中。
  3. 不在渲染期间读「会变的外部可变量」——外部 store 要用 useSyncExternalStore 接入。

调度示意(简化):

jsxsetInput(e.target.value);        // 紧急:立即渲染输入框
startTransition(() => {
  setResults(filter(input));     // 过渡:慢,可被打断
});

直观类比

旧渲染像单一窗口的银行柜台:柜员办一笔大业务(慢渲染),后面排队的客户(输入事件)全部干等。并发渲染像叫号系统:VIP(输入)插队,柜员手头的业务单(正在计算的渲染)可以放下作废,办完 VIP 回来重开一张新单(以最新状态重放渲染);「过渡业务」是「可以慢慢办但绝不能弄丢」的排队任务。

实例 / 案例

可感知的并发最小 demo:

jsxfunction SearchPage() {
  const [input, setInput] = useState('');
  const [query, setQuery] = useState('');

  function onChange(e) {
    setInput(e.target.value);              // 紧急:每个字符立即回显
    startTransition(() => {
      setQuery(e.target.value);            // 过渡:搜索结果慢慢来
    });
  }

  return (
    <>
      <input value={input} onChange={onChange} />
      <Results query={query} />            {/* 内部可配合 Suspense */}
    </>
  );
}

体验差异:不加 transition 时,长列表页面每敲一键都整树重渲染、输入框都卡;加上后输入框丝滑,结果区延后刷新。细节 API(useTransition 返回 isPending、useDeferredValue)在 kp-023 展开。

并发时代的额外纪律(写法上的新要求):

jsx// 渲染期间读外部可变值(时间/随机/store)在并发下会"渲染两次得到不同结果"
const id = Math.random();            // ❌ 并发下两次渲染 id 不同 → 不一致 UI
const [id] = useState(() => Math.random()); // ✅ 快照进 state

// 订阅外部 store 的正确姿势
const online = useSyncExternalStore(subscribe, () => navigator.onLine);

常见误区

  • 「并发 = 多线程 / 真并行」:都在主线程,靠让路与抢占实现「并发地安排工作」。
  • 把 startTransition 包住 setState 就会更快:它不减少工作量,只降优先级——总时间可能不变,但交互不再被阻塞;真正提速仍靠 kp-019。
  • 以为过渡更新可能被永久丢弃:不会——被抢占后会以最新状态重放,最终一定呈现。
  • 在渲染中读 Date.now()/随机数:并发可重入使两次渲染结果不一致;需快照进 state 或 ref 初始化。
  • 以为必须手动「开启并发模式」:React 18+ 使用任何并发 API(useTransition/Suspense 数据流)即按并发语义运行,不存在单独的「模式开关」(createRoot 只是新根 API)。

自测题

  1. 「并发渲染不是多线程」——那它靠什么实现「输入不被慢渲染卡住」?
  2. 为什么「渲染可中断」要求组件纯函数?举一个副作用被重复执行的例子。
  3. startTransition 包裹的更新会被丢弃吗?完整描述被抢占后的重放过程。
  4. Math.random() 写在渲染体里在并发下会出什么问题?两种正确写法。
  5. useSyncExternalStore 解决的问题是什么?

与其他知识点的关系

  • 向前:kp-008 的 Fiber 是它的物理基础,kp-020 的 Suspense 是它的声明面。
  • 向后:kp-023 是它的两个用户级 API;kp-025 的 Actions 内部使用过渡优先级;kp-019 的优化手段与它正交互补(少渲染 vs 错峰渲染)。

延伸阅读

  • React 18 发布文(并发特性总纲):<https://react.dev/blog/2022/03/29/react-v18>
  • useTransition:<https://react.dev/reference/react/useTransition>
  • useSyncExternalStore:<https://react.dev/reference/react/useSyncExternalStore>