陌上人如玉
公子世无双

4.8 并发渲染

4.8 并发渲染

一句话:并发渲染(Concurrent Rendering)不是多线程,而是「渲染过程可以被中断、可以被丢弃、可以按优先级调度」——它让紧急更新(如输入)不被长渲染阻塞,也是 useTransition、useDeferredValue、Suspense 的基础。

一、并发渲染是什么

❌ 常见误解:
   「并发 = 多线程/并行渲染」→ 错
   React 仍然是【单线程】的,组件函数仍在主线程执行

✅ 准确理解:
   并发 = 「可中断的渲染 + 优先级调度」
   一次渲染可以「开始 → 暂停 → 让浏览器处理别的事 → 继续」,
   也可以「丢弃」已经做了一部分的工作。

类比:
   同步渲染  = 一口气把一本厚书读完(期间不能接电话)
   并发渲染  = 可以随时放下书去接电话,之后再从书签处继续
               甚至「发现有更要紧的事」时撕掉已读的部分重新读
为什么需要它?

① 浏览器的帧预算是 16.7ms(60fps),留给 JS 的更少
② 一次渲染(尤其大列表)可能耗时几百毫秒
③ 同步渲染会在这段时间里【完全阻塞】:
   · 用户输入无响应(输入框卡顿)
   · 动画掉帧
   · 点击延迟

并发渲染的目标:让「长渲染」不再阻塞「紧急交互」。

二、可中断渲染

机制(结合 4.2 的 Fiber):

① 渲染工作被拆成「一个个 Fiber 单元」
② 每处理完一个单元,检查:
   · 是否用完了时间片?
   · 是否有更高优先级的更新?
③ 如果「是」,就让出主线程(保存当前 workInProgress 指针)
④ 浏览器处理输入/动画/绘制
⑤ 下一帧(或稍后)从这个指针继续

⚠️ 关键点:
   · 可中断的是【render 阶段】
   · commit 阶段【不可中断】(否则用户看到改了一半的界面)
「丢弃」也是很重要的能力:

场景:用户在输入框里快速输入 "abc"
  ① 输入 "a" → 开始渲染「过滤结果」(低优先级,耗时 200ms)
  ② 用户输入 "b" → 有新的更新
  ③ React 中断当前的 render,【丢弃】已经算了一半的 "a" 的结果
  ④ 直接用最新状态("ab")重新渲染
  → 避免了「用落后的输入渲染出落后结果」的浪费

这也是「为什么闭包/中间态不会显示在界面上」的原因之一。

三、优先级与车道(Lane)

// Lane 是位掩码(见 4.2)
const SyncLane = 0b001;                    // 最高(离散事件)
const InputContinuousLane = 0b100;         // 连续事件(mousemove/scroll)
const DefaultLane = 0b10000;               // 默认(普通 setState)
const TransitionLanes = 0b1110000000;      // 过渡(startTransition)
const IdleLane = 0b10000000000;            // 空闲
优先级 典型来源 是否可中断
Sync 点击、键盘输入、flushSync ❌(同步执行)
InputContinuous mousemove、scroll、拖拽 ✅
Default 普通 setState、网络回调、定时器 ✅
Transition startTransition、useTransition、useDeferredValue ✅(最容易被让路)
Idle 完全不紧急的工作 ✅
调度规则:
① 高优先级车道可以「插队」——先渲染
② 低优先级的渲染被打断后,会「稍后重试」
③ 如果低优先级渲染期间状态又变了,会「丢弃已作废的工作」
④ Commit 会「按优先级」把已完成的高优先级结果先提交

⚠️ 但要注意:默认的 setState 仍然是 DefaultLane(不低也不高)。
   「让某个更新变成低优先级」必须显式用 startTransition / useDeferredValue。

四、时间切片

React 用 Scheduler 包调度:
① 每个时间片约 5ms
② 时间片用完(或出现更高优先级任务)→ 让出主线程
③ 用 MessageChannel 安排下一次继续(比 setTimeout 更及时,比 requestIdleCallback 更可控)

⚠️ 时间切片只在「Concurrent 模式 + 有可中断的更新」时生效。
   如果整棵树的更新都是 SyncLane(如点击触发),仍然会同步跑完。
// 让「耗时更新」变成可中断的(这才是时间切片真正发挥作用的地方)
function Search() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setKeyword(e.target.value);                    // 紧急:SyncLane

    startTransition(() => {
      setResults(filterHugeList(e.target.value));  // ⭐ 非紧急:TransitionLane,可被打断
    });
  };

  return (
    <>
      <input value={keyword} onChange={handleChange} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {results.map((r) => <Row key={r.id} item={r} />)}
      </div>
    </>
  );
}

五、并发相关的 API

API 作用 见
useTransition 把更新标记为「过渡(低优先级)」 2.10
useDeferredValue 返回一个「滞后版本」的值 2.10
startTransition 非 Hook 版本(可在组件外调用) react 直接导出
Suspense 等待某件事完成时显示 fallback 8.4
useSyncExternalStore 订阅外部数据,避免并发下的撕裂 2.11
useInsertionEffect 为 CSS-in-JS 库设计的、在布局 Effect 前执行 库作者使用
createRoot 启用并发能力的入口 React 18+
// startTransition 也可以从 react 导入(不依赖 Hook,可在任意地方调用)
import { startTransition } from 'react';

// 场景:路由跳转(紧急) + 内容渲染(非紧急)
function navigate(to: string) {
  router.push(to);                      // 紧急:导航立即响应
  startTransition(() => {
    setPageContent(computeContent(to));  // 非紧急:内容可以稍后
  });
}

六、并发与 StrictMode 的双调用

为什么 StrictMode 要「双调用」?

因为并发渲染带来一个前提假设:「**render 必须可以被执行多次**」

① 渲染可被中断 → 之后「重放」(重新执行未完成的部分)
② 渲染可能被执行两次(React 需要在某些情况下「预先」执行)
③ 未来 React 可能支持「在保留状态的情况下卸载/重挂载组件」

→ 所以「render 必须是纯函数」不再只是「好习惯」,而是【正确性的要求】。

StrictMode 的双调用就是在开发环境【故意】制造这个场景,
把「不纯的 render」和「缺失清理的 Effect」暴露出来。

七、并发带来的新问题

1) 撕裂(Tearing)

场景:一个「外部数据源」(不是 React state):
  ① 组件 A 渲染时读到 store.value = 1
  ② 渲染被打断,外部数据被改成 2
  ③ 组件 B 在同一轮渲染里读到 store.value = 2
  → 同一个界面上 A 显示 1、B 显示 2 → 撕裂

解法:useSyncExternalStore(它保证在读取外部数据时的一致性)
↑ 这也是为什么 Redux/Zustand 内部都改用了它。

2) 「渲染期间修改状态」

并发下,「渲染期间修改外部状态」的行为变得不可预测:
  · 可能被重放 → 修改执行多次
  · 可能被丢弃 → 修改「看起来没生效」

所以规则更严格了:
  render 里只能【读】props/state,不能【写】任何外部东西。

3) 副作用不能「预期执行顺序」

// ⚠️ 不要依赖「渲染执行了几次」
let renderCount = 0;
function Comp() {
  renderCount++;                       // ❌ 并发下次数不可预测
  return <div>{renderCount}</div>;
}

八、常见坑

① 认为「并发 = 多线程」
   → 仍是单线程,只是「可中断 + 优先级」

② 认为「升级 React 18 就自动享受并发的好处」
   → 需要 createRoot;且默认 setState 仍是 sync/default 优先级,
     真正要用并发能力得显式用 transition/Suspense

③ 认为「所有更新都会被打断」
   → SyncLane(点击/输入)是同步不可中断的;
     只有被标记为过渡的更新才容易被让路

④ 在 render 里做副作用
   → 并发下可能重复执行或看起来「没生效」

⑤ 用「执行次数」判断逻辑正确性
   → 次数是优化细节,不可依赖

⑥ 在 transition 里放「紧急反馈」
   → 输入回显、hover、loading 提示不能是过渡优先级

⑦ 自己管理「外部 store」而不使用 useSyncExternalStore
   → 可能出现撕裂

⑧ 认为「时间切片会让长列表不卡」
   → 它降低「阻塞」,但总工作仍需完成;大列表的真正解法是虚拟滚动

⑨ 在 useLayoutEffect 里做重计算
   → commit 不可中断,会直接阻塞绘制

⑩ 认为「Suspense 只是 lazy 的配套」
   → 它是并发体系的一部分(数据获取、流式 SSR 都依赖它)

九、面试延伸

  1. 「什么是并发渲染?」
    并发渲染是 React 18 的核心能力,它的本质是「渲染过程可中断、可丢弃、可按优先级调度」——不是多线程(React 仍在主线程执行组件函数)。它让 React 可以在「渲染一个大列表」的中途暂停,让浏览器先处理用户的输入与动画,然后再继续。这样「长渲染」就不会阻塞「紧急交互」。它是 useTransition、useDeferredValue、Suspense 等特性的底层基础。

  2. 「并发渲染和 Fiber 是什么关系?」
    Fiber 是「让可中断渲染成为可能的数据结构」——它把递归改成「链表遍历 + 工作单元」,因此能在任意一步暂停并保存进度。并发渲染是「建立在这个结构之上的调度能力」——按优先级安排工作、在必要时中断与丢弃。一句话:Fiber 是基础设施,并发渲染是上层能力。

  3. 「并发渲染解决了什么问题?」
    解决「长渲染阻塞主线程」的问题。在同步渲染下,一次耗时 300ms 的渲染会让:① 输入框卡顿(用户打字延迟);② 动画掉帧;③ 点击无响应。并发渲染通过「时间切片 + 优先级」让紧急更新先执行、低优先级更新可被打断,从而保持界面「可交互」。注意:它不减少总工作量,只是改变「谁先做、能不能暂停」。

  4. 「时间切片是怎么工作的?」
    React 用独立的 Scheduler 调度:给一次渲染分配约 5ms 的时间片,每处理完一个 Fiber 单元检查「时间是否用完 / 是否有更高优先级任务」;如果用完就让出主线程(用 MessageChannel 安排下一帧继续),让浏览器处理输入与绘制。注意两点:① 只有「可中断的更新」(transition 优先级)才真正受益;② commit 阶段不参与时间切片。

  5. 「优先级是怎么划分的?」
    React 用 Lane(车道)位掩码表示优先级,从高到低大致是:Sync(点击、键盘等离散事件、flushSync)→ InputContinuous(mousemove、scroll 等连续事件)→ Default(普通 setState、网络回调)→ Transition(startTransition / useTransition / useDeferredValue)→ Idle。高优先级可以「插队」,低优先级的渲染被打断后会稍后重试,过期的工作会被丢弃。

  6. 「并发渲染与 StrictMode 的双调用有什么关系?」
    并发渲染建立在「render 可以被执行多次」的前提上——因为渲染可能被重放(中断后重新执行未完成部分)、可能被丢弃重来,未来 React 还可能「保留状态地卸载/重挂载组件」。所以「render 必须是纯函数」从「好习惯」升级为「正确性要求」。StrictMode 的双调用就是故意制造这个场景,在开发环境暴露「不纯的 render」与「缺失清理的 Effect」。

  7. 「什么是撕裂(tearing)?怎么解决?」
    撕裂指「同一次渲染中,不同组件读到了外部数据的不同版本」——在并发渲染下(渲染可被打断),外部数据源可能在渲染过程中变化,导致组件 A 读到旧值、组件 B 读到新值,界面出现不一致。React 的官方解法是 useSyncExternalStore:它在读取外部数据时保证一致性(必要时强制同步渲染)。这也是 Redux、Zustand 等库内部都改用它订阅的原因。

  8. 「useTransition 是必需的吗?不加会怎样?」
    不加也能工作——但「昂贵更新」会以 Default 优先级同步执行,阻塞输入回显等紧急交互,表现为「输入卡顿」。加上 useTransition 后,这些更新被标记为 Transition 优先级,可以被中断,从而让输入保持流畅。所以它的作用是「在需要区分优先级的地方,显式告诉 React 哪些更新不紧急」——这也是「并发能力默认不生效」的原因(React 不会自动猜测你的意图)。

一句话速记

并发渲染 = 「可中断 + 可丢弃 + 按优先级调度」,不是多线程(单线程执行组件函数);它建立在 Fiber(可暂停的链表遍历)之上,用 Lane 位掩码表达优先级(Sync → InputContinuous → Default → Transition → Idle),用 约 5ms 时间切片让出主线程;commit 阶段不可中断;默认 setState 是 Default 优先级(并发好处不会自动生效,要显式用 useTransition/useDeferredValue/Suspense);它带来的新约束是「render 必须纯」(StrictMode 双调用就是检验),以及需要 useSyncExternalStore 避免撕裂。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.8 并发渲染

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫