useTransition:React 的框架级让路

0 阅读6分钟

你在搜索框里飞快打字,下面的列表每敲一个键就重新渲染一次。字母还没打完,页面已经开始卡了——光标不跟手,输入框像灌了水泥。

问题不是"渲染太慢",而是 React 把所有状态更新都当成同等紧急。输入框要刷新、列表也要刷新,它们挤在同一条车道上排队。你的手指在等输入框,输入框在等列表渲染完——这就是阻塞。

昨天我们聊了浏览器层面的"长任务"优化:用 scheduler.yield() 把占用太久的主线程让出来。今天换个视角——React 在框架层面做了同一件事,而且做得更聪明

一、一个 Hook,两个东西

const [isPending, startTransition] = useTransition();

就这一行。返回值只有两个:

返回值类型作用
isPendingboolean"低优先级的活还没干完吗?"
startTransitionfunction"把这个 setState 标记为低优先级"

不接受任何参数,没有配置项。这是 React 给你的一张"优先级贴纸"——你贴在哪个 setState 上,那个更新就自动降级。

二、贴纸贴上去,发生了什么

来看搜索框的经典场景:

function Search() {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    setInputValue(e.target.value);         // ← 同步,立刻刷新输入框
    startTransition(() => {
      setSearchQuery(e.target.value);      // ← Transition,列表可以等一等
    });
  }

  return (
    <>
      <input value={inputValue} onChange={handleChange} />
      {isPending && <span>搜索中...</span>}
      <HeavyList query={searchQuery} />
    </>
  );
}

两个 setState 在同一个事件处理函数里,但命运完全不同:

• setInputValue 走的是 SyncLane——最高优先级,浏览器下一帧就得看到字母出现。

• setSearchQuery 被 startTransition 包裹,走的是 TransitionLane——React 会说"行,这个不急,我先把输入框搞定,有空再渲染列表"。

一个事件处理函数里拆出两条车道,紧急的先走,不急的靠边。  这就是 useTransition 的全部。

三、Lane 模型:React 内部的高速公路

为什么叫"车道"不是随便说的。React 内部真的有一套叫 Lane 的优先级模型,用 31 位二进制数表示 31 条赛道。位数越低,优先级越高

车道优先级典型场景位数
SyncLane🔴 最高flushSync、过期任务1 位
InputDiscreteLanes🟠 高点击、按键2 位
InputContinuousLanes🟡 较高拖拽、滚动2 位
DefaultLanes🟢 一般网络请求回调3 位
TransitionLanes🔵 较低useTransitionuseDeferredValue9 位
IdleLanes⚪ 最低空闲任务2 位

注意一个精妙的设计:越低优先级的车道,分配的位数越多。原因很直觉——低优先级的任务容易被反复打断导致积压,需要更多车道来容纳排队的更新。最高优先级的 SyncLane 只需要 1 位,因为它从不排队,来了就执行。

这和高速公路的车道设计是一个道理:应急车道只需要一条,但普通行车道有三四条。

React 的所有优先级计算都用位运算完成——与、或、非,O(1) 复杂度。  这不是巧合,这是用数据结构的选择来保证调度本身不成为瓶颈。

四、isPending:并发时代的状态信号

很多人用 useTransition 只用了 startTransition,忽略了 isPending。但 isPending 才是这个 Hook 的灵魂之一。

它告诉你:低优先级的更新正在后台渲染,还没完成。

有了它,你可以做到"即时反馈":

// Tab 切换的例子
function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();

  if (isActive) return <b>{children}</b>;
  if (isPending) return <b className="pending">{children}</b>;  // ← 立刻变灰

  return (
    <button onClick={() => startTransition(async () => { await action(); })}>
      {children}
    </button>
  );
}

用户点了"Posts"标签,不用等列表渲染完,标签立刻变成 pending 样式。用户的感知是"系统在响应我",而不是"系统卡住了"。  这个区别在体验上是天壤之别。

如果不用 Transition,React 会怎么做?它会触发最近的 <Suspense> 边界的 fallback——你精心渲染好的当前页面"哗"地消失,换成一个大大的 spinner。用户会觉得页面坏了。

isPending 让你在"旧世界"和"新世界"之间搭了一座桥:旧内容还在,新内容正在路上,过渡期有明确信号。

五、陷阱:受控输入绝对不能放进 Transition

这是 React 官方文档里反复强调的,也是新手最容易踩的坑:

// ❌ 千万不要这样写
const [text, setText] = useState('');
function handleChange(e) {
  startTransition(() => {
    setText(e.target.value);    // 输入框会"吃"字!
  });
}
return <input value={text} onChange={handleChange} />;

为什么?因为 startTransition 标记的更新是可以被打断的。用户每敲一个键,前一次渲染可能还没完成就被中断了。结果就是输入框的值追不上手指的速度——你打了"abc",屏幕上可能只显示"a"。

输入框必须同步响应,这是用户对交互设备最底层的预期。

正确做法永远是拆两个 state:

const [inputValue, setInputValue] = useState('');     // 同步:控制输入框
const [searchQuery, setSearchQuery] = useState('');   // Transition:控制下游

function handleChange(e) {
  setInputValue(e.target.value);          // ← 立刻生效
  startTransition(() => {
    setSearchQuery(e.target.value);       // ← 可以等
  });
}

一个管门面,一个管后台。两条路各走各的,互不阻塞。

六、async Action 的坑:await 之后要再包一层

React 19 让 startTransition 支持了异步函数,但留了一个反直觉的限制:

// ❌ await 之后的 setState 不在 Transition 中!
startTransition(async () => {
  await updateQuantity(newQuantity);
  setQuantity(savedQuantity);           // 这行走的是 DefaultLane,不是 TransitionLane
});

// ✅ 必须再包一层
startTransition(async () => {
  const savedQuantity = await updateQuantity(newQuantity);
  startTransition(() => {
    setQuantity(savedQuantity);         // 现在才是 TransitionLane
  });
});

原因在 JavaScript 语言层面:await 之后的代码在微任务队列中执行,React 在那个时间点已经退出了 Transition 的作用域。

这和 startTransition 的内部模型有关。你可以把它想象成:

let isInsideTransition = false;

function startTransition(scope) {
  isInsideTransition = true;
  scope();                    // 同步执行
  isInsideTransition = false; // await 之后,这行早就跑完了
}

同步部分结束的那一刻,"贴纸"就撕掉了。await 恢复执行时,贴纸已经不在了。

好消息是:当 TC39 的 AsyncContext 提案落地后,React 将能在异步边界中保持上下文,这个限制会被移除。

七、useTransition vs useDeferredValue

这两个 Hook 经常被一起问。核心区别一句话:

useTransition 包裹的是动作(setState),useDeferredValue 包裹的是值。

维度useTransitionuseDeferredValue
包裹对象setState 函数状态值或 prop
使用前提能访问 set 函数不需要访问 set 函数
提供 isPending✅ 是❌ 否
典型场景按钮点击、表单提交、Tab 切换输入框打字时延迟派生渲染
控制力更强(你决定哪些 setState 降级)更弱(React 自动判断)

选择标准也简单:

• 你能控制 setState 的地方 → useTransition

• 你只拿到一个 prop 或值,管不了上游 → useDeferredValue

八、架构洞察:两层 yield 的对称

如果你读过昨天那篇讲 scheduler.yield() 的文章,现在可以画出一个完整的图景:

层级工具让给谁谁来调度
浏览器层scheduler.yield()主线程上排队的所有任务(输入、绘制、其他脚本)浏览器事件循环
框架层useTransition同一个 React 应用内更高优先级的状态更新React Fiber Scheduler

它们解决的是同一个问题——让紧急的先走——只是作用域不同。

scheduler.yield() 是和整个浏览器说"我让一下,你先处理别的"。useTransition 是在 React 内部说"这个 setState 不急,先渲染那个急的"。

用操作系统的术语来说:

• scheduler.yield() 是进程级调度——操作系统决定哪个进程获得 CPU。

• useTransition 是线程级调度——进程内部决定哪个线程先执行。

两层协作,才是完整的"让路"体系。


如果你只想带走一句话,我建议记这个:

useTransition 不是"延迟渲染",是"我告诉 React 这件事不急,你先顾紧急的"。一个事件里拆两条车道,急的同步走,不急的靠边等——这就是并发渲染的全部直觉。

参考:useTransition — React 官方文档 · React 官方团队维护

qrcode_for_gh_6a9e7f3719d6_344.jpg