useTransition 与 React Concurrent Rendering

5 阅读2分钟

一句话

Concurrent Rendering 让你标记某些状态更新为「非关键」,使 React 在「后台」处理它们而不阻塞 UI——但会用两次重渲染作为代价。

问题:慢状态更新冻结一切

很多人以为状态更新是异步的。不是。 触发虽然是异步的(来自回调),但一旦触发,React 会同步地计算所有重渲染、提交到 DOM,然后才把控制权还给浏览器。

如果一个重型组件渲染要 1 秒,这 1 秒内整个 UI 冻结,用户点击被排队等主任务结束。

Concurrent Rendering 的本质

传统模式:
  setState → 同步渲染(阻塞主线程)→ 渲染完成 → 浏览器响应

Concurrent 模式:
  startTransition(setState) → 后台渲染(定期检查主队列)
                              ↓ 如果有「关键」更新进来
                              → 暂停后台工作 → 处理关键更新 → 再继续/放弃

"后台"是心智模型——JS 是单线程的,React 只是在工作间隙检查主队列。

useTransition 用法

const [isPending, startTransition] = useTransition();

// 包裹状态更新,标记为非关键
startTransition(() => {
  setTab('projects');
});
  • startTransition:包裹状态更新,标记为非关键
  • isPending:布尔值,表示过渡正在进行中(可用于 loading 指示器)
  • 关键更新(普通 setState)可以中断过渡

⚠️ 核心陷阱:双重重渲染

useTransition 会导致两次重渲染

  1. 立即的「关键」重渲染——旧 state 不变,只是 isPending 变为 true
  2. 后台的「非关键」更新——state 才真正改变

后果:如果所有 tab 切换都用 startTransition,从一个重型页面点走时,第一次重渲染仍然会渲染那个重型页面(旧 state),反而比不用更慢

正确用法

✅ 策略一:确保所有受影响组件已 memo 化

const ProjectsMemo = React.memo(Projects);
// 确保重型组件被 memo 包裹 → 第一次重渲染时跳过重型计算

前提条件(全部满足才有效):

  • 所有重型组件用 React.memo 包裹
  • 所有 props 用 useMemo / useCallback memo 化
  • isPending 不要作为 prop 传给 memo 组件

✅ 策略二:只从「轻」到「重」时使用

// ✅ 从 loading 空状态 → 重型数据页(第一次重渲染是轻量的)
useEffect(() => {
  fetch('/api').then(data => {
    startTransition(() => setData(data));
  });
}, []);

不要从重型 → 重型使用。

useDeferredValue

const TabContent = ({ tab }) => {
  const tabDeferred = useDeferredValue(tab);
  // 用 tabDeferred 渲染,更新被延迟
};
  • 适用场景:拿不到 setState 函数(值来自 props)
  • 同样存在双重重渲染问题
  • 同样的解决方案

❌ 不能替代 debounce

useTransition 做 input 防抖不生效——React 太快了,能在两次按键间就完成后台计算和提交,每个值变化都会触发。

决策树

需要 useTransition 吗?
├─ 有重型渲染阻塞 UI? 继续
   ├─ 能 memo 化所有受影响组件?  可以用
   ├─ 是从」→「过渡?  可以用
   └─ 两个都不满足?  别用,会变慢
├─ 想替代 debounce?  用 lodash.debounce
└─ 所有状态更新都包一层?  绝对不要