跳到主要内容

useTransition 用法与原理:从"非紧急更新"到 React 的 lane 调度机制

· 阅读需 22 分钟

如果一个页面有 1 万个列表项,用户在搜索框每敲一个字符,列表就整体重新过滤、重新渲染一次——输入会卡得没法用。useTransition 就是为了解决这类问题:把"不紧急的渲染"和"用户正在操作的渲染"分开,让输入永远先响应,重的活放到后台慢慢干,还可以随时被新输入打断。

本文先讲用法和简单示例(React 19 的写法),再深入 useTransition 的底层机制——lane 优先级模型、startTransition 内部做了什么、渲染为什么能被中断。


1. 一个场景:为什么需要"非紧急"更新

先看问题。一个受控输入框 + 昂贵过滤:

function SearchList() {
const [query, setQuery] = useState('');

const results = useMemo(() => filterProducts(query), [query]); // 1 万条数据的过滤+渲染

return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</div>
);
}

用户每敲一个字符:onChangesetQuery → 整棵列表重渲染。如果这次渲染要花 200ms,输入框就会滞后 200ms 才回显——因为输入框和列表共用同一个 state、同一次渲染,谁都跑不掉。

问题本质:"输入框回显"(紧急)和"过滤并渲染列表"(不紧急)被绑死在一次更新里了。


2. useTransition 用法:简单代码示例

useTransition 返回两个东西:

const [isPending, startTransition] = useTransition();
  • startTransition(回调):把回调里的 state 更新标记为非紧急(transition 更新);
  • isPending:当前是否有 transition 渲染在后台进行中,可以用来显示"加载中/置灰"。

示例 1:搜索过滤大列表

把"输入"和"过滤结果"拆成两个 state——输入走紧急更新,过滤结果走 transition

import { useState, useTransition, useMemo } from 'react';

function SearchList() {
const [input, setInput] = useState(''); // 紧急:输入框立刻回显
const [query, setQuery] = useState(''); // 非紧急:驱动昂贵列表
const [isPending, startTransition] = useTransition();

const results = useMemo(() => filterProducts(query), [query]);

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setInput(value); // ① 紧急更新:输入框马上响应
startTransition(() => setQuery(value)); // ② 非紧急更新:列表渲染可打断
}

return (
<div>
<input value={input} onChange={handleChange} placeholder="搜索商品…" />
{isPending && <div className="dim">正在过滤…</div>}
<ul>{results.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
</div>
);
}

效果:敲字符时输入框即时回显,列表在后台重算;如果列表渲染到一半你又敲了一个字符,React 会放弃这次没算完的过滤,用最新输入重来——所以 isPending 常用来把结果区置灰,告诉用户"还有更新在路上"。

示例 2:页签 / 路由切换(配合 Suspense)

切换页签、加载下一路由内容,属于"不紧急"——用户点完按钮后,希望旧页面先保持可交互,而不是整页卡住:

import { useState, useTransition, Suspense } from 'react';

function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();

function switchTab(next: string) {
startTransition(() => setTab(next)); // 非紧急:切换页签
}

return (
<div>
<nav>
{['home', 'users', 'settings'].map((t) => (
<button key={t} onClick={() => switchTab(t)}>{t}</button>
))}
</nav>
{isPending && <span className="pending">切换中…</span>}
<Suspense fallback={<div>加载中…</div>}>
<TabContent tab={tab} /> {/* 内含懒加载 / 请求 */}
</Suspense>
</div>
);
}

一个重要的配套行为:在 transition 里切页时,React 会一直显示旧页面(旧 UI),而不是立刻闪出 Suspense 的 fallback——避免"点一下闪一下白屏"。这就是"把导航包进 transition + Suspense"的经典组合。

示例 3:React 19 的异步 action(async + await)

React 19 起,startTransition 支持传入 async 函数isPending 会覆盖整个异步期间(promise 未结束前都是 true,失败也会复位):

function CreatePost() {
const [message, setMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();

function onSubmit(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
try {
setError(null);
await savePost({ message }); // 异步期间 isPending 保持 true
setMessage('');
} catch (err) {
setError(String(err));
}
});
}

return (
<form onSubmit={onSubmit}>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="submit" disabled={isPending}>
{isPending ? '发布中…' : '发布'}
</button>
{error && <div className="error">{error}</div>}
</form>
);
}

一个 React 19 的坑await 之后的 state 更新,不再自动属于 transition(React 会丢失异步作用域,这是 JS 语言限制)。如果你希望 await 之后的更新也是非紧急的,需要再包一层 startTransition

startTransition(async () => {
await someApi();
startTransition(() => setPage('/about')); // await 后的更新要再包一层
});

注:React 19 的表单场景更推荐直接用 useActionState——它把 state、action、isPending 打包成一个 hook,底层同样是 transition 机制。


3. 使用规则

  • 只包非紧急更新:紧急的东西(输入、点击、拖拽)千万别包进 transition,否则用户操作也会被推迟;
  • 别用 setTimeout 手动降级:transition 让 React 自动管理"推迟 + 打断 + 丢弃过期结果",比"setTimeout(() => setState()) 强行延后"可靠得多;
  • isPending,别自己猜:需要"更新中"反馈时,用它而不是手写 loading flag(尤其 async action,它精确覆盖异步期间,不会出现"按钮一直转"的 bug);
  • startTransition 是函数组件外的逃生舱useTransition 只能用在组件里;库代码 / 全局场景可以直接 import { startTransition } from 'react' 用同一个函数。

4. 原理:useTransition 的机制

示例看完了,来回答"它到底是怎么做到的"。核心是 React 的优先级调度。我们从"一次更新"出发,拆成四步。

4.1 一切的起点:把更新分成"紧急"和"非紧急"

React 内部给每次更新都打一个优先级。紧急的(SyncLane、InputContinuousLane)必须立刻渲染、不能被夺走;非紧急的(TransitionLane)可以等、可以被打断、甚至可以作废重来:

4.2 Lane:优先级是一串二进制位

React 的优先级模型叫 lane(车道)——每个 lane 是二进制数里的一个 bit,位越靠左优先级越高。用位运算判断、合并、取交集,快且省内存:

includesSomeLane(a, b) => (a & b) !== NoLanes // 有没有重叠的 lane
mergeLanes(a, b) => a | b // 合并优先级

特别的是,transition 一共有 16 个 laneTransitionLane1 ~ TransitionLane16),循环使用——这样多个并发的 transition 可以并行存在,而普通优先级只占一个 lane。

4.3 startTransition 内部:翻转一个全局标记,然后分配 TransitionLane

startTransition 做的第一件事极其简单——设置一个全局标记

// 简化版
ReactCurrentBatchConfig.transition = {}; // 告诉 React:接下来在 transition 上下文里
fn(); // 执行回调里的 setState

当回调里的 setState 执行时,会调用核心函数 requestUpdateLane(fiber) 决定这次更新用哪个 lane,判断顺序:

  1. 如果 requestCurrentTransition() 非空(正处在 transition 上下文)→ 返回一个 TransitionLane
  2. 否则看当前事件优先级:点击/输入等用户事件 → SyncLane / InputContinuousLane
  3. 兜底(setTimeoutpromise.then 等)→ DefaultLane

所以"输入框紧急、列表非紧急"的本质是:两次 setState 因为所处上下文不同,被贴上了不同优先级的 lane。

4.4 更新带着 lane 流转:从 hook 队列一路合并到根

拿到 lane 之后,这次更新就开始"带标签"流动:

  1. 创建 Update 对象,挂上 lane;
  2. enqueueConcurrentHookUpdate 把它推进对应 fiber 的更新队列;
  3. markUpdateLaneFromFiberToRoot 把 lane 合并fiber.lanes、所有祖先的 childLanes,最终汇入 root.pendingLanes——这样 React 从根上一眼就能看到"还有哪些优先级的工作没做完";
  4. scheduleUpdateOnFiber 交给调度器(Scheduler)排期执行。

4.5 渲染时:位运算决定"这轮该算谁的"

开始渲染时,React 从 root.pendingLanes挑最高优先级作为 renderLanes,然后逐条处理更新:

// processUpdateQueue 里,只有 lane 命中的更新才会被计算
if ((renderLanes & update.lane) === update.lane) {
// 处理这个更新
} else {
// 优先级低于当前渲染 → 跳过,留到后面更低优先级的 pass 再算
}

关键就在这个 &如果正在渲染一个紧急更新(比如输入),transition 的 lane 和它 & 不上,transition 更新就被跳过,不会夹在紧急渲染中间捣乱。

4.6 可中断:紧急更新如何"抢走" transition 渲染

现在把整个过程串起来。用户的操作时间线是这样的:

机制上:transition 渲染由 Scheduler 调度,一旦紧急更新到达,React 会在中断点让出当前 transition 渲染,放弃(abandon)没算完的工作树,先提交紧急更新,再用最新状态重启 transition。因为每次都会用最新的数据重来,中途那次"过期"的结果会被直接丢弃,用户永远不会看到卡顿的中间态

这也是为什么过滤示例里 isPending 会频繁闪 true——它反映的就是"有一棵 transition 工作树正在后台渲染"。

一个必须讲清的精度的:中断的粒度是"一个 fiber",不是"组件渲染中途"。

// ReactFiberWorkLoop.js(并发渲染的 work loop)
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress); // 处理完一个 fiber,才轮到下一次检查
}
}
  • shouldYield() 只在这个 while条件里被检查——也就是每处理完一个 fiber(一次 performUnitOfWorkbeginWork + 需要时的 completeWork)才检查一次
  • 一次 performUnitOfWork原子的:一旦开始处理某个 fiber,就要把它算完,中途不会被打断。你记的"下一个可以不用算先跳出"正是这个 while 的行为——shouldYield() 返回 true 时,还没开始的下一个工作单元被跳过,把控制权交还浏览器;
  • 所以"可中断"的准确表述是:中断只能发生在两个工作单元(fiber)之间。如果一个组件自身的渲染特别重(比如单个组件里同步 map 出 1 万行),那这一次组件渲染依然会阻塞主线程,React 也没法把它切片。

换句话说:transition 保证的是"重的活放后台 + 可放弃重来",但它切分的最小单位是 fiber,不是"每行代码"或"每 1ms"。想让一个巨型列表真正被切片,思路还是把它拆成多个组件/fiber,让 React 有机会在它们之间让出——这也是"组件拆分"在并发模式下的另一个意义。

但"放弃重来"凭什么敢这么干?——双缓冲 + baseState。

"放弃半成品从头算"听起来浪费,之所以可行且正确,靠的是两个机制:

① 双缓冲:屏幕上永远有一棵"安全"的树。 React 同时维护两棵 fiber 树:current(已提交、正在显示)和 workInProgress(后台构建中,alternate 互指)。渲染阶段只改 workInProgresscurrent 从头到尾不被碰;只有 commit 时才 root.current = finishedWork 完成"交换",让 WIP 转正。所以被打断时扔掉的只是可丢弃的半成品,屏幕上的 current 安然无恙——放弃的是树,不是数据

② baseState:重启时从"已知正确"的起点重算。 被打断后,React 用 prepareFreshStackcurrent fiber 的属性重置 WIP 节点,等于从"当前显示的那棵树"重新出发。关键是新产生的 update 并不是存在 WIP 上,而是存在 currentFiber 的队列里(shared.pendingprocessUpdateQueue 开头被合并进 firstBaseUpdate / lastBaseUpdate)——所以 WIP 被重置也丢不了更新。而 processUpdateQueue 每次都从 baseState(上一次已提交的 state)开始,按序重放整条更新队列:

// processUpdateQueue(简化):从 baseState 开始,按序重放
let newState = queue.baseState; // 上次已提交的 state,起点永远正确
let update = queue.firstBaseUpdate;
while (update !== null) {
newState = getStateFromUpdate(update, newState);
update = update.next;
}

优先级只影响"这轮算不算这个 update"(renderLanes & update.lane),不影响最终结果——结果永远由"baseState + 完整更新队列"按顺序决定。所以即便一次 transition 渲染被打断三次、重启三次,每次都从同一个 baseState 重放,算出来的结果依然一致、不会把中断时的"半成品状态"混进去。

一句话:React 放弃的从来只是"半棵没算完的树",状态更新始终完整保存在 current 树 + 更新队列里,所以怎么中断、怎么重来,都能算出正确结果。

4.7 isPending 到底从哪来

isPending 不是某个组件 state,而是 React 内部全局跟踪的 pending 状态

  • React 内部维护当前"进行中的 transition 渲染"状态,组件通过内部订阅机制(类似 useSyncExternalStore)拿到这个状态 → 渲染期间就是 true
  • React 19 的 async actionstartTransition(async () => {...}) 时,React 会等待 promise,isPending 在 promise settle(resolve 或 reject)之前一直为 true——所以它天然可以当"提交中"按钮状态,不用自己写 loading

5. 相关 Hook / API 对比

API管什么与 transition 的关系
useTransition把"一段更新"标记为非紧急本文主角
startTransition脱离组件调用同一个能力useTransition 的底层函数
useDeferredValue把"一个值"推迟更新底层共用 transition lane 机制,适合"传值给子组件"的场景
useActionState表单 action 的 state + isPending 打包React 19,内部基于 transition
<Suspense>异步渲染的占位 UItransition 期间已展示过的边界不显示 fallback、保持旧 UI(新边界仍会显示,见 §6.4)
flushSync强制同步刷新反方向——显式把某些更新提为紧急

useTransition vs useDeferredValue 一句话:前者包"代码/更新",后者推迟"一个值"。能分开状态就用 useTransition;不方便拆 state、只想把某个 props 延迟传下去时用 useDeferredValue


6. 深入:lazy 组件为什么能被 Suspense 捕捉到?聊聊代数效应

§5 里提到"transition 期间 Suspense 保持旧 UI"。但有一个更底层的问题值得追:React.lazy(() => import('./Page')) 这个异步组件,React 是怎么"知道"它还没加载完、并且精准地"捕捉"到它、交给 <Suspense> 处理的? 答案藏在一个反直觉的机制里——渲染期间"抛一个 promise"。而这套设计背后的理论模型,就是代数效应(Algebraic Effects)

6.1 一条链路:React.lazy 是怎么"暂停"的

React.lazy() 返回的其实不是组件,而是一个特殊对象:{ $$typeof: REACT_LAZY_TYPE, _payload, _init }。当 React 渲染到它时,核心逻辑在 resolveLazy

// ReactLazy.js(简化)
function resolveLazy(lazyType) {
try {
return lazyType._init(lazyType._payload); // 内部调用 import() 拿模块
} catch (x) {
if (typeof x?.then === 'function') { // 抛出来的"东西"是一个 thenable?
suspendedThenable = x; // → 它其实是"挂起信号"
throw SuspenseException; // → 转成内部异常,向上找 Suspense
}
throw x; // 不是 thenable → 正常报错
}
}

_init(lazyInitializer)在 import 的 promise 还没 resolve 时,直接把这个 promise throw 出去。于是发生了下面这条完整的链路:

三个关键点:

  1. "抛 promise"不是报错,是"我还没准备好"的声明SuspenseException 是 React 内部专用异常,只为"向上传递挂起信号"而存在——它会被 React 在最近的 <Suspense> 处"接住",而不会被 Error Boundary 当错误处理;
  2. Suspense 边界只捕获"挂起"(thenable),不捕获错误。import 加载失败(promise reject)时抛的是错误,交给最近的 Error Boundary。所以生产代码里 lazy 组件外面通常要 ErrorBoundary → Suspense → lazy 这样叠;
  3. 穿透性:中间的组件不需要任何改动,就像 throw 穿透中间函数直达 catch 一样——这正是它和代数效应最神似的地方(下面说)。

6.2 代数效应:Suspense 背后的理论模型

代数效应是一个编程语言概念,一句话定义:"可恢复的异常"。普通函数是"要么跑完、要么抛错退出";代数效应让一个函数可以在中间暂停(perform 一个 effect),把控制权交给"调用链上任一父级安装的 handler",handler 再 resume with 值 跳回暂停点继续执行

function getName(user) {
let name = user.name;
if (!name) {
name = perform 'ask_name'; // ← 暂停点:发起一个"效果"
}
return name;
}
// 上层某处安装了 handler:
try / handle (effect 'ask_name') {
resume with 'Arya Stark'; // ← 把值送回暂停点,getName 继续执行
}

关键在 resume with——它把执行权精确交回 perform 的那一行,并带一个值回去。这正是普通 try/catch 做不到的:catch 只能终止,不能"回去接着算"。

和几种机制的对比(这也是面试高频的横向题):

机制能暂停能恢复handler 在哪中间层要不要改
try/catch✅(throw)❌ 只能终止最近的 catch不用(穿透)
generator / yield消费方(局部,得有人 next()不用,但要驱动
async/await调用链必须整条是 async传染("函数有了颜色")
代数效应(perform/resume)最近 handler(动态作用域)不用(穿透)
React Suspense(throw promise)✅(重渲染)最近的 <Suspense>不用(穿透)

这张表说明了两件事:

  1. 为什么中间组件不用改:代数效应的 handler 是动态作用域——由"调用链上任意位置的父级"安装,中间层自然穿透。async/await 做不到这点(会传染整条链,这就是"函数有颜色"的问题)。React 用"throw promise + 向上找最近 Suspense"完美复刻了这种穿透性;
  2. resume 在 React 里就是"重渲染":因为 React 的渲染函数是幂等的纯函数,同一个输入必然得到同一个输出。所以当 promise resolve 后,React 重新渲染整棵子树,效果上就等于"从暂停点继续执行"——Dan Abramov 在《Algebraic Effects for the Rest of Us》里的原话:"当 promise resolve 时重新渲染,几乎和 resume 是同一件事——因为如果你的编程模型假设了幂等性,你就可以这样作弊。"

6.3 连接起来:为什么这套东西解释了 transition

现在把前三节串成一张完整的图景:

  • 渲染函数是"幂等 + 可暂停 + 可恢复"的(代数效应模型),所以 React 才敢"放弃半成品 WIP 从头算"(§4.6 的双缓冲 + baseState)——反正重渲染和继续算结果一样;
  • 组件只声明"我还差一个数据"(throw promise),不关心"谁给我、怎么给"——这和数据获取方式(内存缓存、请求、lazy chunk)完全解耦;
  • transition + Suspense 的合体startTransition 切页时,旧 UI 先保持(因为新页的数据可能没到),一旦新数据在 transition 里"挂起"(throw promise),<Suspense> 捕获并保持旧 UI,而不是闪 fallback;等数据到了,在后台把新页渲染好一次性提交——这就是"代数效应式的暂停/恢复"在前端最完整的一次落地。

一句话:React.lazy 能被 Suspense 捕捉到,是因为渲染期间"抛 promise"就是 React 的"暂停"原语;而它敢这样设计,是因为背后套用了代数效应"可恢复 + 动态作用域 handler"的心智模型——JS 没有真正的代数效应,React 用"throw + 重渲染"模拟了它。

6.4 细节补充:fallback 到底什么时候触发

一句话核心判据:fallback 只在"这个 Suspense 边界没有内容可展示"时立即出现;一旦边界已经展示过内容,要不要转 fallback,取决于这次更新是紧急的还是 transition。

场景fallback 会不会出现
首次渲染(边界从未展示过内容)立即显示
紧急更新(没包 transition),边界已有内容✅ 直接替换成 fallback(整块 UI 可能变 loading,闪屏)
transition 更新,边界已有内容保持旧内容,不显示 fallback
transition 里新出现的边界(没有旧内容)✅ 仍立即显示
给边界换 key(React 视为全新边界)✅ 强制显示
useDeferredValue❌ 用旧值顶着,不给 fallback

机制层面的四条要点:

  1. 首渲染为什么一定显示 fallback:因为这时边界没有 current——呼应 §4.6 的双缓冲,"没有 current 就没有旧内容可保留",只能 fallback。这也是为什么页面第一次进入总是先看到 fallback(或骨架屏),即使你用了 useTransition
  2. 紧急更新为什么会转 fallback:用户操作(点击、输入)要求"这次状态必须立刻落地",但新数据还没到——React 既不能保留旧内容(旧内容不代表新状态),又渲染不了新内容,于是只能转 fallback。这就是"导航包进 transition"的动机:避免每次切页整屏闪 loading;
  3. transition 为什么不转 fallback:React 允许"这次更新先不落地",旧内容继续显示,等数据够了在后台渲染好再一次性提交。注意——transition 不是等所有数据加载完,而是"等到足够避免隐藏已经展示的内容"即可。所以已经展示过的边界不闪 fallback,但这次更新里新出现的边界没有旧内容,依旧立即 fallback(比如切过去的新页面内部又套了一个 Suspense 包着没加载过的区域);
  4. key 是主动重置的手段<ProfilePage key={userId} /> 切换用户时换 key,React 把边界当作"全新",强制显示 fallback——适合"不同用户的详情页是不同内容,不该保留旧用户界面"的场景。

一个实用建议:别把 Suspense 只放在根上。根级 Suspense 会让任何一次挂起都把整页换成 loading;把边界放得尽量局部(配嵌套),fallback 只影响它包裹的那一小块,已展示的其余部分在 transition 里都稳定不闪。


7. 一句话总结

useTransition 把 state 更新分为"紧急"和"非紧急":startTransition 内部通过设置全局 ReactCurrentBatchConfig.transition,让回调里的 setStaterequestUpdateLane 阶段被贴上 TransitionLane(16 个 lane 轮换),而紧急的用户事件贴 SyncLane/InputContinuousLane。更新带着 lane 一路合并到 root.pendingLanes,渲染时用 (renderLanes & update.lane) 位运算决定这轮算谁——所以紧急更新一到,React 就放弃未完成的 transition 渲染、先提交输入、再用最新数据重启 transition,用户操作永远不被阻塞。React 19 进一步支持 startTransition(async () => …),让 isPending 精确覆盖异步期间;配合 Suspense,切页时还保持旧 UI 不闪 fallback。


参考

  • React 官方文档:useTransition
  • React 官方博客:React v19(async Actions 支持)
  • React 源码 ReactFiberHooks.jsuseTransition / requestUpdateLaneReactFiberLane.js:lane 位图与 claimNextTransitionLane
  • React 源码 ReactFiberWorkLoop.jsworkLoopConcurrent / performUnitOfWork(并发渲染的中断粒度 = fiber 工作单元);prepareFreshStack(用 current 重置 WIP 的双缓冲机制)
  • React 源码 ReactFiberUpdateQueue.jsprocessUpdateQueuebaseState + firstBaseUpdate 按序重放更新)
  • React 源码 ReactLazy.jsresolveLazy / lazyInitializer(throw thenable → SuspenseException
  • Dan Abramov:Algebraic Effects for the Rest of Us(React Suspense 背后的理论模型)
  • React 官方文档:lazy / Suspense
  • React 官方:React APIs: startTransition