Skip to main content
React 18 的并发调度我是从 MessageChannel 和 setTimeout 的差别看进去的,再补上任务怎么打断、怎么恢复,才理解时间切片在做什么。

Q1: 异步调度的历史?为什么要有它?

1

2013年: 同步渲染的瓶颈

React 诞生之初采用同步渲染——当状态变化时,整棵虚拟 DOM 树一次性协调完成。如果组件树很大(如 10000 个节点),协调过程可能耗时 100ms 以上,期间页面完全阻塞,用户点击无响应。
2

2017年: Fiber 架构的诞生

React 16 引入 Fiber 架构,把协调工作拆分成一个个小单元(Fiber 节点)。每个单元执行完后,检查是否需要让出主线程——如果用户正在输入或有高优先级任务,就暂停当前工作,先处理高优先级任务。
3

2018-2022年: Scheduler 独立成包

React 16.6 起把调度逻辑拆成独立的 scheduler 包,实现时间切片和优先级队列。React 18(2022 年)在此基础上启用并发渲染,把 startTransition、useDeferredValue 等更新接入这套调度器。
异步调度解决的核心问题:
异步调度 = 优先级队列 + 时间切片,让高优先级任务不被低优先级任务阻塞。

🚫 没有异步调度

一家医院没有分诊台,所有患者按先来后到排队。一个感冒患者排在前面,后面的心脏病患者只能等着——直到感冒患者看完,心脏病患者可能已经错过了最佳抢救时间。

✅ 引入异步调度

有了分诊台(调度器),护士根据病情严重程度分配优先级。心脏病患者直接进抢救室(高优先级),感冒患者继续排队(低优先级)。每个人都能在合理的时间内得到治疗。
回答时先说”为什么需要调度”(优先级问题),再说”怎么调度”(时间切片 + 优先级队列)。想听的是你对”并发”本质的理解,而不是背诵 React API。

Q2: 什么是时间切片(Time Slicing)?

时间切片的核心思想:把一个长任务拆成多个小单元,每个单元执行完后检查是否需要让出主线程。
requestIdleCallback 在浏览器空闲时执行,但有两个问题:
  • 兼容性差:Safari 长期未支持(需要 polyfill)
  • 不可靠:如果主线程一直忙碌,回调可能永远不执行
因此 React 的 Scheduler 用 MessageChannel 模拟了更可靠的调度机制——MessageChannel 的回调同样是一个 task,规范并不保证它比 setTimeout 先执行,但 postMessage 会立即入队,而 setTimeout 存在最小延迟,实践中通常更早。
时间切片就像吃自助餐——你不能一个人霸占取餐台 10 分钟(长任务阻塞)。正确做法是:每次只拿 2-3 样菜(小单元),然后回到座位吃完,再去拿。这样其他人也有机会取餐(浏览器处理用户输入)。

MessageChannel vs setTimeout 的调度优先级

React Scheduler 的时间切片实现

React Scheduler 内部用 shouldYieldToHost() 判断是否需要让出主线程:

Q3: 优先级队列的实现

异步调度的核心是优先级队列——不是所有任务都平等,高优先级任务可以插队。
Scheduler 的 5 种优先级: 注意:这是 Scheduler 的 5 档优先级(每档对应一个过期时间),和 React 内部的 Lane 模型(用位掩码标记更新批次)是两层概念,不要混为一谈。
当高优先级任务到来时,调度器会暂停正在执行的低优先级任务,优先处理高优先级任务。这就是”并发”的本质——不是同时执行多个任务,而是可以打断和恢复。
优先级队列就像餐厅后厨——VIP 客人(高优先级)的菜可以插队先做。如果厨师正在做普通客人的菜(低优先级),VIP 客人来了,厨师会先把做到一半的菜放一边(暂停),优先做 VIP 的菜。做完 VIP 的菜后,再回来继续做普通客人的菜(恢复)。

小顶堆(Min Heap)实现优先级队列

React Scheduler 内部用小顶堆实现优先级队列,保证每次取到的都是最高优先级任务:

优先级队列 vs 普通队列


Q4: requestAnimationFrame 与调度的关系

requestAnimationFrame 是浏览器提供的帧同步机制,它在每一帧渲染前执行回调。
rAF 的核心特性:
rAF 和 React Scheduler 是两条独立路径:Scheduler 用 MessageChannel 调度可中断的更新,rAF 负责对齐帧的动画与测量。React 不会把 rAF 回调包装成某个 Scheduler 优先级。
rAF 就像火车时刻表——每 16.67ms 有一班车(一帧)。你必须在火车到站前(渲染前)完成准备工作(更新 DOM),否则就要等下一班车(下一帧)。调度器的作用是帮你安排好哪些工作在这一班车完成,哪些推迟到下一班车。

rAF 的取消机制


Q5: Vue 的 nextTick 与调度机制

Vue 的 nextTick 本质上也是一种异步调度——把 DOM 更新推迟到微任务队列清空后执行。
Vue 3 的调度策略是:状态变化时把副作用 job 推入队列(默认 flush: 'pre'),DOM 更新推迟到微任务统一执行。这意味着:

Vue 的异步队列 vs React 的优先级队列


Q6: 如何实现一个简易的异步调度器?

面试中经常考”实现一个带并发限制的异步调度器”。
并发限制不是”同时执行”,而是”同时发起”。浏览器对同一域名的 HTTP 并发限制是 6 个(Chrome)。如果不限制并发数,可能导致:
  • 网络请求堆积,带宽被占满
  • 低优先级请求阻塞高优先级请求
  • 浏览器响应变慢

带优先级的异步调度器


Q7: AbortController 与任务取消

异步调度中,任务取消是关键能力——当高优先级任务到来时,需要取消低优先级任务。
在搜索框输入时,每次输入都会触发搜索请求。如果不取消旧请求,可能出现:

Q8: Web Workers 与真正的并行计算

当任务是 CPU 密集型(如大数组排序、图像处理)时,异步调度无法解决问题——因为 JS 是单线程的。此时需要用 Web Workers 实现真正的并行。
Web Workers 不是万能的,它有以下限制:
  • 不能访问 DOM:Worker 在独立线程中运行,无法操作 DOM
  • 通信开销:主线程和 Worker 之间通过 postMessage 通信,数据会被序列化/反序列化
  • 启动成本:创建 Worker 有启动成本,不适合频繁创建销毁

🔍 底层源码:React Scheduler 是如何实现的?

React Scheduler 内部用 MessageChannel 实现时间切片(详见 React Scheduler 源码:packages/scheduler/src/forks/Scheduler.js):
这段代码做了 3 件事:
  1. 用 MessageChannel 替代 setTimeout(更精确的宏任务调度)
  2. 每帧最多执行 5ms(yieldInterval),剩下的时间留给浏览器渲染
  3. 用 shouldYieldToHost() 判断是否需要让出主线程
Vue 的调度器(queueJob)本质上是一个微任务批处理机制,而 React 的调度器是一个优先级时间切片机制:
本质区别:
  • Vue:不能中断,所有任务一次性执行完(微任务必须清空)
  • React:可以中断,每个 Fiber 节点执行完后检查是否需要让出主线程

及格回答 vs 满分回答

问题:“说说异步调度,React 的并发模式是怎么实现的?”

及格回答(60-70分):
“React 18 引入了并发模式,用 Scheduler 实现时间切片。把长任务拆成小块,每块执行完后让出主线程。还有优先级队列,高优先级任务可以打断低优先级任务。”
问题出在:知道概念但不理解实现。 满分回答(90-100分):
异步调度解决的核心问题是高优先级任务被低优先级任务阻塞。在同步渲染模式下,状态变化触发整棵组件树的协调,如果组件树很大,主线程会被阻塞 100ms 以上,期间用户输入无响应。[1]
React 18 的 Scheduler 用 MessageChannel 实现时间切片——每帧最多执行 5ms 的 JS 任务,剩下的时间留给浏览器渲染和响应用户输入。内部用小顶堆实现优先级队列,5 种优先级(Immediate > UserBlocking > Normal > Low > Idle)对应不同的超时时间。高优先级任务到来时,可以打断正在执行的低优先级任务。[2]
实际项目中的应用:用 startTransition 标记低优先级更新(如搜索结果),用 useDeferredValue 延迟非紧急的渲染。在搜索框场景中,输入框用同步更新(立即响应),搜索结果用 startTransition 包裹(可以被打断),用户始终看到最新的输入,搜索结果异步更新。[3]
Vue 的调度机制不同——它用微任务批处理,不能中断。Vue 3 的 nextTick 本质上是 Promise.resolve().then(flushJobs),所有状态变化被收集到队列中,最后一次性更新 DOM。这种机制简单高效,但无法像 React 那样根据优先级中断和恢复任务。

常见追问

答题要点: 用 MessageChannel 替代 setTimeout,每帧最多执行 5ms。高分回答: “React Scheduler 用 MessageChannel 实现时间切片。MessageChannel 的回调作为宏任务执行,但比 setTimeout 优先级更高。每帧最多执行 5ms(yieldInterval),超过 5ms 就让出主线程。
为什么用 MessageChannel 而不是 setTimeout?
  1. setTimeout 在嵌套达到 5 层后会被钳制到最小 4ms,本身也有调度延迟
  2. setTimeout 的回调执行时机受任务队列影响,不够精确
  3. MessageChannel 的 postMessage 立即入队,时间片计算更稳定”
答题要点: Scheduler 的 5 档优先级负责”何时执行”,Lane 位掩码负责”哪些更新可以合并”。高分回答: “React 里有两层机制,别混为一谈:
  1. Scheduler 优先级:Immediate(-1ms)、UserBlocking(250ms)、Normal(5000ms)、Low(10000ms)、Idle(1073741823ms),决定回调什么时候执行、何时过期。
  2. Lane 模型:React 自己用 31 位掩码给每个更新打标签(SyncLane、InputContinuousLane、DefaultLane、TransitionLanes、IdleLane 等),用于批量合并和跳过更新。Lane 不是”优先级数字”,不直接对应超时时间。
Lane 的价值是位运算:
面试时先讲 Scheduler 的 5 档超时,再说明 Lane 是 React 内部的更新标记模型,就不会把两个概念搅在一起。”
答题要点: startTransition 标记低优先级,可以被高优先级打断;setTimeout 延迟到下一帧。高分回答: “startTransition 和 setTimeout 的本质区别是:
区别:
  1. 时机:setTimeout 在下一个宏任务执行;startTransition 在当前帧的低优先级时间片执行
  2. 中断:setTimeout 的回调一旦执行就不能中断;startTransition 标记的更新可以被高优先级任务打断
  3. 状态:startTransition 有 isPending 状态,可以显示加载指示器
答题要点: nextTick 是微任务批处理,flushSync 是同步刷新。高分回答: “Vue.nextTick 和 React.flushSync 的区别:
核心区别:
  1. Vue nextTick:把 DOM 更新推迟到微任务,多次变化合并为一次更新(优化)
  2. React flushSync:强制同步刷新,立即更新 DOM(用于需要立即看到效果的场景)
使用场景:
  • Vue 的 nextTick:读取更新后的 DOM(如获取元素尺寸)
  • React 的 flushSync:在事件处理函数中需要立即看到 DOM 变化(如焦点管理)”
答题要点: 用队列 + 计数器实现,每次最多执行 N 个异步任务。高分回答: “核心思路:维护一个等待队列和一个正在执行的计数器。
实际应用:
  • 限制 API 请求并发数(避免触发限流)
  • 限制图片上传并发数(避免带宽占满)
  • 限制文件下载并发数(避免磁盘 I/O 过载)”
答题要点: 兼容性差、执行时机不可靠、不适合动画。高分回答: “requestIdleCallback 有三个主要局限性:
  1. 兼容性差:Safari 长期未支持,需要 polyfill
  2. 执行时机不可靠:如果主线程一直忙碌,回调可能永远不执行
  3. 不适合动画:回调在浏览器空闲时执行,无法保证在渲染前执行
React Scheduler 选择用 MessageChannel 而不是 requestIdleCallback,正是因为:
  • MessageChannel 兼容性好(IE 10+)
  • MessageChannel 的回调在当前宏任务结束后立即执行,时机可控
  • React 自己实现了时间切片(shouldYieldToHost),不需要依赖浏览器的空闲判断”
答题要点: 微任务必须清空,会导致主线程永远阻塞,调度器无法工作。高分回答: “会导致主线程彻底阻塞,调度器完全失效。因为微任务的优先级高于宏任务——必须把所有微任务清空,才能进入下一个阶段。如果微任务无限递归产生新微任务,主线程就永远困在微任务队列中。
这就是为什么 React Scheduler 用 MessageChannel(宏任务)而不是 Promise(微任务)——宏任务每次只取一个执行,执行完就有机会让出主线程。”
答题要点: 根据任务的紧急程度选择不同的调度 API。高分回答: “React 18 提供了多种调度策略,根据任务的紧急程度选择:
选择策略:
  • 用户输入:同步更新(立即响应)
  • 搜索结果:startTransition(可以被打断)
  • 大列表过滤:useDeferredValue(延迟渲染)
  • 焦点管理:flushSync(强制同步)”
答题要点: 区分”并发限制”和”并行限制”,手写 async pool 核心逻辑,理解 p-limit 的信号量模型。高分回答: “并发任务控制的核心是限制同时处于 pending 状态的 Promise 数量,而不是限制同时执行的线程数(JS 是单线程的)。常见实现有三种思路:思路 1:Promise.race 动态池(p-limit 原理)维护一个’正在执行’的 Set,池满时用 Promise.race 等待任意一个完成后再加入新任务:
思路 2:信号量(Semaphore)模型(p-limit 库的实现)用一个计数器模拟信号量,每获取一个令牌执行一个任务,任务完成后归还令牌:
思路 3:Generator 顺序喂入用 Generator 逐个产出任务,调度器从 Generator 中取任务喂入执行池:
p-limit vs asyncPool 的核心区别:
  • p-limit 是通用限流器,返回一个 limit 函数,可以在任何场景复用,支持排队(queue)
  • asyncPool 是一次性批处理,传入数组和并发数,返回全部结果,不保留队列状态
实际应用场景:
  • 批量上传文件(限制同时上传 3 个)
  • 批量请求接口(避免触发服务端限流)
  • 批量 Puppeteer 截图(限制同时打开的页面数)“

延伸阅读与引用参考