Notes

Transition:startTransition / useTransition / useDeferredValue(机制视角)

一句话结论:Transition 的本质是把一部分更新标记为“非紧急”(低优先级 lane),从而让 React 在并发渲染中优先保证输入/点击等紧急更新,必要时中断正在进行的渲染并先响应用户;代价是这些“非紧急更新”可能被延后、重做,甚至被更高优先级更新覆盖。

1. React 为什么需要 Transition?

在真实业务里最常见的卡顿来自两类工作:

  1. 大渲染:列表很大、组件树很深、每次输入都触发昂贵的 re-render
  2. 大计算:过滤/排序/聚合等 CPU 工作跟着输入同步跑

如果把这些更新都当成“同等紧急”,用户会感受到:

  • 输入掉帧(INP 变差)
  • 点击无响应

Transition 提供了一个“架构级语义”:

  • 交互必须立即响应(urgent updates)
  • UI 的细节更新可以稍后跟上(non-urgent updates)

2. 心智模型:Urgent vs Transition

你可以把一次交互拆成两种状态更新:

  • Urgent(紧急):输入框字符、按钮按下态、hover 等
  • Transition(过渡):筛选结果列表、页面内容区切换、复杂图表刷新

典型例子:输入框联想

  • urgent:setText(nextText)(必须立刻)
  • transition:setQuery(nextText) → 触发昂贵列表过滤(可以稍后)

3. 机制:Transition 在调度里发生了什么?

3.1 更新是如何被“打标签”的(Lane 视角)

React 内部会把不同来源的更新映射到不同优先级(Lane)。你在仓库里已经有:

  • Scheduler 的优先级(Immediate/UserBlocking/Normal/Low/Idle)
  • React 的 Lane 位图模型(pendingLanes / suspendedLanes / pingedLanes / expiredLanes ...)

Transition 做的关键事情就是:

  • startTransition(() => { ...setState }) 包裹的更新里,React 会把这些更新放入 Transition 语义的 lane(低于输入/点击等更新)。

这意味着:

  • 如果此时用户继续输入(更高优先级),React 可以中断当前渲染(render 阶段可中断)
  • 先处理高优先级 lane
  • 再回来继续(或重做)Transition lane

3.2 “可中断”的边界在哪里?(render 可中断,commit 不可中断)

  • render 阶段:可以 time slicing,检查 shouldYield(),中断/恢复/重做
  • commit 阶段:一次性提交到 DOM,不可中断

Transition 利用的就是:

  • 让昂贵的 UI 更新尽量发生在可中断的 render 阶段
  • 让主线程在需要时可以立刻让出给输入/点击

3.3 为什么 Transition 更新可能“被重做”?

因为并发渲染允许:

  • 一个低优先级渲染进行中
  • 途中来了更高优先级更新
  • React 丢弃当前 workInProgress,先做高优先级
  • 然后重新基于最新 state 再做低优先级

这就是为什么你不能把“必须只执行一次的副作用”放到 render 里(StrictMode 与并发会放大问题)。

4. API:startTransition / useTransition / useDeferredValue 分别解决什么?

4.1 startTransition:给更新降级

import { startTransition, useState } from 'react'

function Search() {
  const [text, setText] = useState('')
  const [query, setQuery] = useState('')

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    const next = e.target.value

    // urgent:立刻更新输入
    setText(next)

    // transition:昂贵更新可延后
    startTransition(() => {
      setQuery(next)
    })
  }

  // query 用于昂贵的过滤/请求/渲染
}

机制含义:setQuery 产生的更新会被标记到 Transition lane。

4.2 useTransition:拿到 pending 状态 + 一个带语义的 startTransition

const [isPending, start] = useTransition()

start(() => {
  setQuery(next)
})
  • isPending 不是“网络请求是否完成”,而是“是否还有 Transition 更新未完成/未提交”。
  • 常用于:
    • 按钮 loading(但不阻塞输入)
    • 列表区域显示“更新中”提示

4.3 useDeferredValue:把某个值的“传播”延后

const deferredQuery = useDeferredValue(query)

心智模型:

  • query 仍然是 urgent(立刻更新)
  • deferredQuery滞后,用于驱动昂贵子树

适合场景:

  • 你不想(或不方便)在事件处理里显式拆两份 state
  • 想让昂贵区域自动“慢一拍”更新

5. 与数据请求(TanStack Query)/ 路由切页的关系(架构建议)

5.1 Transition ≠ “把请求变快”

Transition 不会缩短请求耗时,它优化的是:

  • 请求期间 UI 不要卡死
  • 昂贵渲染不要阻塞交互

在数据层你仍需要:缓存、预取、去重、失效策略(TanStack Query / Next 缓存)。

5.2 切页体验:路由更新经常是 Transition 候选

  • SPA(TanStack Router):切页会触发大量组件树变化
  • Next App Router:导航可能触发 streaming/hydration 等

常见策略:

  • 导航触发时把“内容区更新”作为 Transition
  • 保持顶部导航/输入框等 urgent 区域流畅

6. 常见误区(机制推导)

  1. 把所有 setState 都包进 startTransition
  • 结果:输入也变慢,交互语义被破坏
  1. 在 Transition 里做“必须立即生效”的东西
  • 例如必须马上读 DOM、必须马上同步给第三方库
  • 这种需求应该用 flushSync 或 useLayoutEffect(且要谨慎)
  1. 把副作用写在 render 路径里
  • 并发渲染 + StrictMode 会导致重复执行、被丢弃重做

7. Checklist

  • 是否能把一次交互拆成 urgent(输入/反馈)与 transition(结果区渲染)?
  • 昂贵子树是否由一个“可延后”的值驱动(useDeferredValue 或拆 state)?
  • 是否存在 render 期间的副作用(订阅/请求/写外部状态)?
  • 是否把 Transition 当成“性能银弹”而忽视了缓存/预取/列表虚拟化?

关联阅读

cd ..