React 18 追踪:useTransition 与 useDeferredValue

5 分钟阅读
·

8 月初 master 上有一串 DevTools 提交值得留意:8 月 2 日的 42251331d8 把 scheduling profiler 合了进来,8 月 5 日的 a8725a3e62 给 profiler 里的 React measures 加上了 lane 标签和时长。也就是说很快就可以在 DevTools 里直接看到一次渲染跑在哪条 lane 上。追了两个月 lane 模型,终于要有可视化的工具了。

借这个机会把欠的一篇补上。第 2、3 篇讲 lane 模型时反复提到 transition 车道,但都是从调度侧看的:requestUpdateLane 里有一个 transition 分支,claimNextTransitionLane 负责轮转分配。当时留了一个问题没展开:用户侧的代码怎么走进那个分支。答案就是 startTransition、useTransition、useDeferredValue 这三个 API。它们在 5 月 12 日的 2bf4805e4b 里摘掉了 unstable 前缀,随 react 稳定入口导出,6 月公布的 React 18 Alpha 里已经可以直接用。这篇把三个 API 的实现挨个拆开,看它们怎么和 lane 模型对接。参考代码是 master 分支 b9934d6db5,并发相关实现仍在 .new.js fork 里。

系列目录

日期 标题
06-08 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构
06-10 React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime
06-17 React 18 追踪:Lane 模型(下):调度决策与饥饿保护
06-24 React 18 追踪:Concurrent 工作循环与时间切片
07-01 React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告
07-08 React 18 追踪:flushSync 统一同步刷新入口
07-15 React 18 追踪:自动批处理与交错更新队列
07-22 React 18 追踪:Suspense 的挂起与恢复
08-05 React 18 追踪:useTransition 与 useDeferredValue(本篇)

startTransition:一个全局开关

packages/react/src/ReactStartTransition.js 是 startTransition 的全部实现,不带任何渲染逻辑:

export function startTransition(scope: () => void) {
  const prevTransition = ReactCurrentBatchConfig.transition;
  ReactCurrentBatchConfig.transition = 1;
  try {
    scope();
  } finally {
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}

把一个模块级变量的 transition 字段置 1,同步执行回调,再恢复。ReactCurrentBatchConfig 定义在同目录的同名文件里,本体就是一个带 transition 字段的对象,注释写它「记录当前批次的配置,比如更新需要挂起时可以挂多久」。从这句注释能看出来,这个对象预留了扩展位:suspense 相关的配置(当年 suspenseConfig 的遗留语义)本来也打算放在这里,目前只剩 transition 一个字段。

为什么要用一个可变的全局变量,而不是把标记作为参数一层层传下去。setState 的调用链很长:事件回调、dispatchAction、scheduleUpdateOnFiber,中间隔着事件系统和 hook 队列,标记要在最外层的用户回调里写入、在最内层的 requestUpdateLane 里读出,参数穿透意味着改动链路上每一层签名。全局上下文变量是这个场景下的务实选择,代价是它有作用域泄漏的风险,所以写入方必须用 try/finally 成对恢复,startTransition 的写法就是标准示范。

关键在这个对象怎么被 reconciler 看到。react 包和 react-reconciler 是两个独立的包,reconciler 不反向依赖 react。通道是 ReactSharedInternals:react 包把内部需要共享的对象挂在 ReactSharedInternals 上,reconciler 从 shared/ReactSharedInternals 进口拿到同一份引用。packages/react-reconciler/src/ReactFiberTransition.js(这个文件没有 fork,两个分支共用)里就是这么读的:

const {ReactCurrentBatchConfig} = ReactSharedInternals;

export function requestCurrentTransition(): number {
  return ReactCurrentBatchConfig.transition;
}

所以 startTransition 做的事可以概括成:在一个同步代码段的作用域里,把「接下来的更新属于 transition」这个信息写进一个跨包共享的变量。它不调度任何东西,不碰 fiber,只是个上下文标记。try/finally 保证嵌套调用和外层异常时都能恢复原值。

标记在哪里被消费

消费点在 requestUpdateLane(ReactFiberWorkLoop.new.js)。第 2 篇列过它的完整分支链,这里只看 transition 这一支:

const isTransition = requestCurrentTransition() !== NoTransition;
if (isTransition) {
  if (currentEventTransitionLane === NoLane) {
    // All transitions within the same event are assigned the same lane.
    currentEventTransitionLane = claimNextTransitionLane();
  }
  return currentEventTransitionLane;
}

每个 setState 进来都会过这个判断。transition 标记非零时,不查事件优先级、不查 flushSync 设置的上下文,直接领一条 transition 车道。claimNextTransitionLane 的 16 条车道轮转第 3 篇细讲过,这里不重复。

值得展开的是 currentEventTransitionLane 这一层缓存。它的作用是保证同一个事件里的所有 transition 更新拿同一条 lane。点击回调里连着调三个 setState,如果不缓存,三个更新会领到三条不同的 transition lane,之后 getNextLanes 可能把它们拆到不同批次渲染,中间态就有机会上屏。缓存之后它们落在同一条 lane 上,同批渲染、同批提交。注释里写明了约束:分配算法对同一事件内同优先级的更新必须是稳定的,所以算法的输入在同一事件内必须相同。

缓存的清零点在 performConcurrentWorkOnRoot 的入口:每次进入并发工作循环,currentEventTime 和 currentEventTransitionLane 一起被重置。代码把「进入并发渲染」当作事件已经结束的启发式判据,注释里承认这只是个 heuristic。也就是说「同一事件」的边界不是严格的事件循环语义,而是用调度器的任务边界近似的。

到这里,从 startTransition 到 lane 的链路就齐了:用户回调里置标记,每个更新的 requestUpdateLane 读标记领车道,后续的挂起、插队、饥饿保护全部走第 2、3 篇讲的既有机制。transition 在调度器眼里没有任何特殊地位,它就是一组优先级靠后的车道,所有行为都是 lane 模型的自然推论。

useTransition:startTransition 加一个 pending 状态

useTransition 的实现分两半。公开入口在 packages/react/src/ReactHooks.js,只是转发给 dispatcher;真正的逻辑在 ReactFiberHooks.new.js 的 mountTransition 和 updateTransition:

function mountTransition(): [boolean, (() => void) => void] {
  const [isPending, setPending] = mountState(false);
  // The `start` method never changes.
  const start = startTransition.bind(null, setPending);
  const hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [isPending, start];
}

返回的 isPending 是一个普通的 useState,初始 false。start 是内部函数 startTransition 绑定了 setPending 之后的产物,mount 时创建一次,存进 hook.memoizedState,之后每次 update 直接从 memoizedState 里取回来,引用永远不变,注释特意标了这一点。引用稳定意味着 start 可以安全地放进其他 hook 的依赖数组,不会引发多余执行。

文件里 mountTransition 和 updateTransition 之外还有一对 rerenderTransition、rerenderDeferredValue,注册在另一套 dispatcher 上。ReactFiberHooks.new.js 里一共维护着好几套 dispatcher:挂载一套、更新一套、render 阶段更新引发的重渲染一套。transition 和 deferredValue 这两个 hook 在每套 dispatcher 里都有对应实现,rerender 版本和 update 版本几乎一样,只是 state 的读取入口换成 rerenderState。看 hook 实现时认清这个结构能省不少时间:搜同一个 API 名会跳出好几处,多数只是各套 dispatcher 上的注册项,真正的实现只有挂载、更新、重渲染三份。

注意这个内部 startTransition 和 react 包导出的那个不是同一个函数,它多了一段:

function startTransition(setPending, callback) {
  const previousPriority = getCurrentUpdatePriority();
  setCurrentUpdatePriority(
    higherEventPriority(previousPriority, ContinuousEventPriority),
  );

  setPending(true);

  const prevTransition = ReactCurrentBatchConfig.transition;
  ReactCurrentBatchConfig.transition = 1;
  try {
    setPending(false);
    callback();
  } finally {
    setCurrentUpdatePriority(previousPriority);
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}

执行顺序拆开看。第一步把 update priority 提升到 ContinuousEventPriority:higherEventPriority 取两者中优先级更高的那个,DiscreteEventPriority(SyncLane)会保留,NoLane 或 DefaultEventPriority 会被提到 ContinuousEventPriority。第二步在这个优先级下发出 setPending(true),通常落在 InputContinuousLane 上;调用方本身在离散事件上下文里时,DiscreteEventPriority 被保留,落在 SyncLane 上。第三步才把 transition 标记置 1,发出 setPending(false) 和用户的 callback,这些更新走 transition 分支领同一条 transition 车道。

于是 isPending 的变化是一个两拍的节奏。第一拍:pending=true 的更新以连续输入的优先级调度,排在 transition 渲染之前提交,界面先进入加载态,同时旧内容还在。第二拍:transition 渲染完成提交,pending=false 和新内容一起上屏。整个时序靠的就是 lane 的优先级排序:InputContinuousLane 的位值比所有 transition lane 小,getNextLanes 永远先选它。

useTransition 内部更新与 lane 的对应关系

这也解释了为什么全局的 React.startTransition 没有 isPending:它没有 setPending 这两步,只是单纯把回调里的更新标记成 transition。需要加载态就用 useTransition,不需要就用全局函数。两个 API 是同一套机制的两个封装层级。

拿第 3 篇那个筛选列表的场景再过一遍,这次从用户视角看。onChange 里调 start(() => setFilter(text))。onChange 是离散事件,第一拍:setPending(true) 拿到 SyncLane,同步提交,输入框回显和 isPending=true 一起上屏,用户看到自己敲的字和加载指示。第二拍:setFilter 和 setPending(false) 落在同一条 transition lane,渲染走时间切片,期间用户继续敲键盘,新的 SyncLane 更新进来会按第 3 篇讲的规则打断它,输入始终不卡。等列表数据算完,transition 提交,加载指示和新列表同帧替换。两拍之间用户界面没有任何一帧是卡死的,这就是 transition 对外承诺的效果,而实现上只是两条优先级不同的 lane 先后提交。

还有一个细节值得一提。setPending(false) 和 callback() 都在 transition 作用域里发出,又在同一个事件里,靠前面说的 currentEventTransitionLane 缓存拿到同一条 lane。如果它们落到两条 lane 上,getNextLanes 按组取整批 transition 时也多半会一起渲染,但「同一条」是最强的保证:同批被 pick、同批被清。pending 状态和用户更新同批提交、同批清除,不会出现内容变了但 isPending 还亮着的中间帧。

useDeferredValue:state 加 effect 的组合

useDeferredValue 的形态我原来猜过几种,看了代码发现它非常朴素,就是 useState 和 useEffect 的组合,transition 标记的写法是内联的,和上面那个内部函数同一个模式:

function mountDeferredValue<T>(value: T): T {
  const [prevValue, setValue] = mountState(value);
  mountEffect(() => {
    const prevTransition = ReactCurrentBatchConfig.transition;
    ReactCurrentBatchConfig.transition = 1;
    try {
      setValue(value);
    } finally {
      ReactCurrentBatchConfig.transition = prevTransition;
    }
  }, [value]);
  return prevValue;
}

拆开看每个阶段的行为。首次挂载:state 初始为传入的 value,hook 直接返回它,同时挂一个依赖为 [value] 的 passive effect。提交后 effect 执行一次,setValue(value) 写入相同的值,dispatchSetState 的提前 bailout 会直接跳过,不触发渲染。

value 变化的那次渲染:updateDeferredValue 走同样的结构,updateState 读到的还是旧的 state,所以本次渲染返回旧值。提交后 effect 因依赖变化重新执行,在 transition 标记下发出 setValue(新值),这个更新领一条 transition 车道,以可中断的优先级再渲染一次,这次 updateState 读到新值,hook 返回新值。

所以 deferred value 的延迟结构是:value 本身以正常优先级立刻渲染(hook 返回旧值),deferred 出来的那份 state 以 transition 优先级在第二次渲染里追平。延迟来自第二次渲染本身低优先级、可打断,主线程忙时它自然靠后,hook 内部没有节流或防抖逻辑。

和手写的 debounce 对比能看出差别。debounce 靠定时器压住更新频率,延迟是固定的墙钟时间,输入停了还要再等一个窗口,而且定时器到点后的渲染该卡还是卡。useDeferredValue 里没有定时器,每次 value 变化都会发出 transition 更新,只是这些更新可以被打断、可以被后来者带着最新 state 覆盖。快速连续输入时,中间的 transition 渲染做了一半就被丢弃,直接渲染最新值,浪费的只是被丢掉的那部分计算,界面上没有固定延迟。调度决策全部复用 lane 模型,hook 本身一点调度逻辑都没有。

连续变化的合并还有一个兜底机制。同一个 hook 的 state queue 上如果挂着来自不同 transition lane 的更新,dispatchAction 会把这些 lane 纠缠(entangle)起来,保证对同一个 state 的多次更新要么一起出现要么都不出现。第 3 篇讲 markRootEntangled 时提过这个机制,useDeferredValue 连续触发时走的就是这条路,界面不会呈现只应用了一半的中间态。

和 useTransition 的分工也随之清楚了。useTransition 包的是更新的发起方:setState 的调用点在你手里,用 startTransition 包一层就行。useDeferredValue 包的是消费方:value 是从 props 或上游传下来的,你控制不了它什么时候变、以什么优先级变,只能在本地复制一份,让这份副本滞后追平。父组件一个高优先级渲染把新 props 推下来,子组件里重的部分挂在 deferredValue 上,就能留在低优先级慢慢算。两个 API 接的是 lane 模型的同一个入口,区别只是标记写在哪一侧。

这套 API 比 lane 模型老

查 git log 能看到一条清晰的时间线。685ed561f2 在 2019 年 10 月就把 useTransition 和 useDeferredValue 迁移进了 reconciler,那时还没有 unstable 前缀,lane 模型也还不存在,底层挂的是 suspenseConfig 和过期时间。9c6de716d0 的 withSuspenseConfig 是 startTransition 的前身,5564f2c95b 在 2020 年 8 月把它换成 React.startTransition,92fcd46cc7 随后把 SuspenseConfig 对象简化成一个整数。fe7163e73d(2020 年 5 月)给这批实验 API 统一加了 unstable 前缀,今年 5 月的 2bf4805e4b 又把它们摘掉,和 createRoot 一起放进稳定入口。

也就是说,API 形态在 lane 模型出现之前就定了,底层实现换过一轮:从 suspenseConfig 换到 expirationTime 再换到 lane。用户侧的写法几乎没变过。这种「API 先行、机制后换」的轨迹在这个系列里不是第一次出现:createRoot 之前也是 unstable_createRoot 挂了很久,转正时内部早已换过几轮。API 是团队对外的承诺,定得早、动得慢;机制是实现,可以追着目标一直改。第 2 篇列配额时 31 条车道里有 16 条划给 transition,比例显眼,看过这三个 API 在并发模式下的使用频率就知道这个配额不是拍脑袋给的,transition 是 18 里普通开发者会直接用手摸到的特性。

下一篇继续看 hooks 相关的小件:useOpaqueIdentifier,一个为了在 SSR 和 hydration 两侧生成一致 id 的 API,看它怎么用 fiber 的树路径解决多 root 下的 id 冲突。


918 字 · 39 段落
xi ming

Written by xi ming You should follow him on Github