让动态世界拥有确定性(二):React 如何让 UI 更新变得可调度

setState、Fiber Tree、Lanes Priority、Scheduler 与 Concurrent Rendering 如何协作,让一次状态更新从同步阻塞变成可暂停、恢复、抢占的任务

上一篇我们看了 V8 如何在运行时给动态对象偷偷加“结构描述”,让属性访问从查找变成直接偏移。

但 V8 的优化有一个前提:代码的执行路径是可预测的。

如果代码本身写得混乱——频繁改变对象结构、传入各种类型的参数——V8 也无能为力。

那到了应用层呢?当用户在搜索框里输入一个字母,React 需要做的事情是:

用户输入 → setState → 重新计算组件树 → 更新 DOM

如果搜索结果计算很慢,输入框会卡住吗?

在 React 18 引入 Concurrent Rendering 之前,render 阶段是同步不可中断的。一旦开始计算更新,React 必须一路完成,算完之前用户什么都做不了。

React 19 的并发模型要解决的就是:

让一次状态更新,从立即执行的同步任务,变成可以暂停、恢复、抢占的任务。


一、问题:setState 是同步的

先看最朴素的 React:

function SearchBox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  function handleChange(e) {
    setQuery(e.target.value)
    setResults(search(e.target.value))  // 假设很慢
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultList results={results} />
    </>
  )
}

React 的默认行为:一次 setState 就是一次同步更新。

用户输入 "a"
  → setQuery("a")
  → setResults(search("a"))    ← 假设要 200ms
  → 等待 200ms
  → DOM 更新
  → 用户才能输入下一个字母

两个问题:

  1. 输入框卡住了。 用户敲完一个字母后要等 200ms 才能继续输入。
  2. 结果可能过时。 用户快速输入 “abc”,但你算的是 “a” 的结果。

这不是 React 的 bug,而是设计选择:React 默认把所有 setState 视为同等优先级的同步任务


二、Fiber:让更新可以被打断

要让同步任务变成可中断的任务,React 需要一个数据结构来表示“工作进度”,这样停下来的时候知道从哪里继续。

这就是 Fiber

旧架构的问题

React 15 之前的 Reconciler(协调器)是递归的:

更新 Root

遍历整棵组件树(递归,同步,不可中断)

完成所有组件的 diff

更新 DOM

递归遍历意味着:一旦开始,必须跑完。 不能中途停下来去做更紧急的事。

如果组件树很大,一次更新可能需要几十毫秒甚至上百毫秒,整个过程里浏览器无法处理用户输入。

Fiber 的核心思路

Fiber 仍然是树结构,但它通过 childsiblingreturn 三个指针,把递归遍历转换成可暂停的迭代遍历

React 15(递归,同步不可中断):

Root → Child → Child → Child → 回溯 → Child → ...

一旦开始,一口气跑完。


React 18+(迭代遍历,可暂停):

Root

Child1

Child2 → 工作做了一半 → 可以暂停

Child3

每个 Fiber 节点仍然是树的一部分,但它是一个 JavaScript 对象,包含:

{
  type: 'div',
  key: null,
  stateNode: DOM节点,
  child: 子节点,
  sibling: 兄弟节点,
  return: 父节点,
  pendingProps: 新props,
  memoizedState: 当前state,
  updateQueue: 待处理的更新,
}

关键字段是 childsiblingreturn——它们让 React 可以用迭代代替递归来遍历这棵树。

React 可以在任何 Fiber 节点上停下来:

处理 Root → 处理 Child1 → 处理 Child2

                            "有更紧急的任务"

                            暂停,保存进度

                            处理紧急任务

                            回来,从 Child2 继续

Fiber 让 React 的工作变成了可调度的任务。


三、Lanes:给任务分优先级

Fiber 解决了“可以中断”的问题。但下一个问题来了:

什么时候该中断?中断去做什么?

答案是 Lanes(车道模型)

Lanes 是 React 内部的优先级系统。每个更新被分配到一个 Lane,不同的 Lane 代表不同的优先级:

// React 内部的 Lane 定义(简化)

SyncLane            = 0b0000000000000000000000000000001  // 同步,最高优先级
InputContinuousLane = 0b0000000000000000000000000000100  // 连续输入(拖拽)
DefaultLane         = 0b0000000000000000000000000010000  // 普通更新
TransitionLane      = 0b0000000000000000000001000000000  // startTransition
IdleLane            = 0b0100000000000000000000000000000  // 空闲

为什么用位运算?因为 React 需要高效地做批量合并

Lane A  = 0b001
Lane B  = 0b010
A | B    = 0b011   // 合并后的任务集

位运算让 React 可以在一个整数里同时表示多个优先级的任务,O(1) 复杂度完成合并和检查。

调度规则

核心规则:高优先级可以抢占低优先级,低优先级不能抢占高优先级。

用户输入 "a"
  → setQuery("a")          → SyncLane(最高优先级)
  → setResults(search("a")) → TransitionLane(低优先级)

React 开始处理 TransitionLane 的任务(计算搜索结果)

用户继续输入 "b"
  → setQuery("ab")         → SyncLane(最高优先级)

React 立刻:
  1. 暂停当前 TransitionLane 任务
  2. 执行 SyncLane 任务(更新输入框)
  3. 有空闲时再恢复 TransitionLane

这就是为什么 React 18+ 的输入框不会卡住——输入是 SyncLane,永远能抢占。

useTransition:手动标记低优先级

Lanes 是内部机制,开发者通过 useTransition 来使用它:

function SearchBox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    setQuery(e.target.value)

    startTransition(() => {
      setResults(search(e.target.value))  // 标记为 TransitionLane
    })
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <ResultList results={results} />}
    </>
  )
}
setQuery(e.target.value)              → SyncLane,立即执行
startTransition(() => setResults(..)) → TransitionLane,可以被打断

startTransition 做的事情很简单:把里面的 setState 标记为 Transition 优先级。

React 不需要你手动管理调度——它只需要你告诉它哪些更新可以被推迟


四、Scheduler:React 的调度器

Fiber 是“可中断的工作单元”,Lanes 是“优先级标签”。

但还有一个问题:什么时候执行这些任务?

React 不直接控制浏览器主线程。它需要一个调度器来决定“什么时候工作,什么时候让步”。

这就是 Scheduler(调度器)

时间切片(Time Slicing)

Scheduler 的核心机制是时间切片:

浏览器给 JavaScript 的帧预算:约 16ms(60fps)

React 的做法:
  1. 开始执行一个时间切片(5ms)
  2. 时间到了 → 停下来,把控制权还给浏览器
  3. 浏览器处理用户输入、绘制帧
  4. 浏览器空闲 → React 继续下一个时间切片

MessageChannel(不是 setTimeout)实现:

React 任务

requestIdleCallback / MessageChannel

浏览器空闲时执行

5ms 到了

暂停,yield 回主线程

浏览器处理其他事件

空闲时 React 继续

Scheduler 与 Lanes 的协作

Lanes 告诉 React:哪些任务优先级高
Scheduler 告诉 React:什么时候可以执行

完整流程:

1. 用户输入 → setQuery → SyncLane
2. React 收集更新,按 Lane 排序
3. Scheduler 调度:SyncLane 任务立即执行
4. React 处理 Fiber 树,每个时间切片 5ms
5. 时间到了 → yield → 浏览器处理输入 → 继续
6. startTransition 里的 setResults → TransitionLane
7. 空闲时 Scheduler 才调度 TransitionLane 任务
8. 用户继续输入 → SyncLane 抢占 → 暂停 TransitionLane

五、Concurrent Rendering:完整的并发流程

把所有机制串起来,一次状态更新的完整路径是:

用户点击 / 输入

事件处理器触发 setState

React 根据调用方式分配 Lane
  - 普通 setState → SyncLane
  - startTransition → TransitionLane

更新被添加到对应 Fiber 节点的 updateQueue

Scheduler 调度一个宏任务

React 遍历 Fiber 树(链表遍历)
  - 每个节点处理 props / state diff
  - 每个时间切片约 5ms
  - 如果有更高优先级的更新 → yield

高优先级 Lane 插入 → 暂停当前工作 → 优先处理

所有 Lane 处理完毕

提交阶段(commit):一次性更新 DOM

浏览器绘制

关键设计:Diff 阶段可以中断,Commit 阶段不可中断。

Diff(render 阶段):
  - 遍历 Fiber 树,计算变化
  - 可以暂停、恢复、丢弃
  - 不产生任何 DOM 变化

Commit(commit 阶段):
  - 一次性把所有变化应用到 DOM
  - 不可中断(避免中间状态暴露给用户)
  - 浏览器在 commit 之后绘制

六、React 19 的变化

React 19 在这个架构上做了几个调整:

async startTransition

React 18 的 startTransition 只支持同步函数。React 19 允许异步:

// React 18
startTransition(() => {
  setResults(search(query))  // 同步
})

// React 19
startTransition(async () => {
  const data = await fetchResults(query)  // 异步
  setResults(data)
})

useOptimistic:乐观更新

在异步操作等待期间,先展示一个“乐观”的中间状态:

const [optimisticItems, addOptimistic] = useOptimistic(items)

async function handleBuy() {
  addOptimistic({ id: newItemId, bought: true })
  await buyItem()
  refreshItems()
}

addOptimistic 不会触发真正的状态更新,而是临时展示一个预测值。异步操作完成后自动恢复真实状态。

useActionState / useFormStatus:标准化表单交互

async function submitForm(prevState, formData) {
  const email = formData.get('email')
  const { error } = await submitEmail(email)
  if (error) return { error }
  return { success: true }
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitForm, null)

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state?.error && <p className="error">{state.error}</p>}
      <button disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
    </form>
  )
}

这些 API 不是“新功能”,而是并发架构的标准化封装。底层还是 Fiber + Lanes + Scheduler。


七、与 V8 的对应

回到系列主题“让动态世界拥有确定性”:

V8:
  对象结构动态 → Hidden Class 给出结构描述
  属性访问动态 → IC 缓存访问路径
  代码行为动态 → TurboFan 生成优化机器码

React:
  状态更新动态 → Fiber 让更新可中断
  任务优先级动态 → Lanes 给出优先级标签
  执行时机动态 → Scheduler 决定何时执行

V8 在运行时观察代码行为,React 在运行时观察用户行为

两者都在做同一件事:

在动态系统中,找到一种确定性模式,然后利用它做针对性优化。


总结

React 的并发架构不是一次状态更新的“加速”,而是一次认知转变

从“setState = 立刻执行”到“setState = 提交一个待调度的任务”。

setState

Fiber:让任务可以被中断
Lanes:让任务有优先级
Scheduler:让任务在合适的时间执行

Concurrent Rendering

核心问题的答案:

一次状态更新如何从同步阻塞变成可调度的?

通过把“立即执行”拆成“描述任务 + 分配优先级 + 调度执行”三步。


下一篇

React 让你配合它的规则来获得性能——按它的模式写,它来帮你调度更新。

但有人会想:能不能不让开发者配合,直接在编译阶段把该优化的都优化了?

Vue Vapor 做的就是这个尝试。

《让动态世界拥有确定性(三):Vue Vapor 如何用编译换运行时》

V8        优化代码执行
React     优化任务调度
Vue Vapor 优化代码生成