陌上人如玉
公子世无双

5.7 并发特性的性能收益

5.7 并发特性的性能收益

一句话:useTransition / useDeferredValue 不减少工作量——它们改变的是「优先级」,让「昂贵但不紧急的更新」不阻塞「紧急交互(如输入回显)」,从而改善可交互性(INP)。

一、先明确它改变的是什么

❌ 「并发特性会让渲染更快」→ 错
   总工作量没变(该算的还要算,该渲染的还要渲染)

✅ 「并发特性让紧急更新不被阻塞」
   把工作分成「紧急」与「非紧急」,
   非紧急的可以被中断、延后,让紧急的先完成。
手段 改变什么
memo / useMemo / 状态下移 减少工作量
虚拟滚动 减少工作量(DOM 数量)
代码分割 减少首屏工作量
useTransition / useDeferredValue 改变优先级(工作量不变)
Web Worker 把工作移到别的线程
所以它们的定位是:
  「已经无法再减少工作量,但用户抱怨卡」时的手段。
  典型场景:输入驱动一个「必须渲染的大列表」。

二、useTransition 的四个适用场景

1) 输入驱动的昂贵渲染

function Search() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;

    setKeyword(value);                    // ⭐ 紧急:输入框立刻回显
    startTransition(() => {
      setResults(filterHugeList(value));  // ⭐ 非紧急:可以被打断
    });
  };

  return (
    <>
      <input value={keyword} onChange={handleChange} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {results.map((r) => <Row key={r.id} item={r} />)}
      </div>
    </>
  );
}

2) 切换 Tab / 路由

function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(tabs[0]!.key);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <TabNav
        tabs={tabs}
        active={active}
        onChange={(key) => startTransition(() => setActive(key))}
        //            ⭐ 点击「立刻」更新选中态(如果你把选中态也放进 transition,
        //               Tab 的选中高亮就会滞后 —— 通常应该只把「内容」放进 transition)
      />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <TabPanel tabKey={active} />
      </div>
    </>
  );
}
// 更精确的写法:选中态「紧急」,内容「非紧急」
function Tabs2({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(tabs[0]!.key);
  const [content, setContent] = useState(() => loadContent(tabs[0]!.key));
  const [isPending, startTransition] = useTransition();

  const handleChange = (key: string) => {
    setActive(key);                        // ⭐ 紧急:高亮立刻切换
    startTransition(() => {
      setContent(loadContent(key));        // ⭐ 非紧急:内容可以稍后
    });
  };
  // ...
}

3) 路由跳转

function useNavigateTransition() {
  const navigate = useNavigate();
  const [isPending, startTransition] = useTransition();

  const go = useCallback((to: string) => {
    startTransition(() => navigate(to));   // 内容渲染不阻塞「点击反馈」
  }, [navigate]);

  return { go, isPending };
}

4) 昂贵的派生状态更新

// 场景:用户拖动滑块,需要重新计算「一大块数据」
function Slider() {
  const [value, setValue] = useState(50);
  const [preview, setPreview] = useState(() => computePreview(50));
  const [isPending, startTransition] = useTransition();

  const handleChange = (v: number) => {
    setValue(v);                           // 紧急:滑块位置立刻跟手
    startTransition(() => {
      setPreview(computePreview(v));       // 非紧急:预览可以滞后
    });
  };

  return (
    <>
      <input type="range" value={value} onChange={(e) => handleChange(+e.target.value)} />
      <Preview data={preview} dim={isPending} />
    </>
  );
}

三、useDeferredValue 的适用场景

// ① 值来自 props(改不了源头)——这是它不可替代的场景
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);         // ⭐ 只延迟「使用」
  const results = useMemo(() => filter(deferredQuery), [deferredQuery]);

  const isStale = query !== deferredQuery;                // 判断是否在显示旧结果

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      {results.map((r) => <Row key={r.id} item={r} />)}
    </div>
  );
}
// ② 延迟「昂贵的子树」渲染(让其它部分先跟手)
function Page({ data }: { data: Data }) {
  const deferredData = useDeferredValue(data);

  return (
    <>
      <Header value={data.title} />          {/* 紧急:立刻用最新数据 */}
      <Sidebar value={data.summary} />       {/* 紧急 */}
      <ExpensiveChart data={deferredData} />  {/* 非紧急:用滞后数据(可被打断) */}
    </>
  );
}
// ③ 配合 Suspense 的「过渡加载」(避免「切换时闪 loading」)
function Detail({ id }: { id: string }) {
  const deferredId = useDeferredValue(id);
  // 当 id 变化时,先用 deferredId(旧值)继续显示内容
  // → 新内容就绪后再切换 → 避免「立刻显示 loading」的闪烁

  return (
    <Suspense fallback={<Skeleton />}>
      <Content id={deferredId} />
    </Suspense>
  );
}

四、与防抖/节流的区别与组合

维度 防抖(debounce) useDeferredValue
控制维度 时间 优先级
是否有固定延迟 ✅(如 300ms) ❌(由浏览器空闲决定)
减少执行次数 ✅(跳过中间输入) ❌(每次都算,只是可被打断)
适合 发请求 同步的昂贵渲染
是否「跟手」 有延迟感 更跟手(紧急部分立即响应)
// ✅ 最佳组合:请求用防抖 + 渲染用 deferred
function Search() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState<Item[]>([]);

  // ① 请求:防抖(减少网络请求次数)
  const debouncedKeyword = useDebounce(keyword, 300);
  useEffect(() => {
    if (!debouncedKeyword) return;
    let cancelled = false;
    api.search(debouncedKeyword).then((data) => { if (!cancelled) setResults(data); });
    return () => { cancelled = true; };
  }, [debouncedKeyword]);

  // ② 渲染:deferred(保持输入流畅)
  const deferredResults = useDeferredValue(results);
  const isStale = results !== deferredResults;

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ResultList results={deferredResults} dim={isStale} />
    </>
  );
}
一句话区分:
  防抖 = 「等你停下来我再算」(减少次数)
  deferred = 「你继续打字,我稍后补上」(不阻塞)
  两者解决不同问题,可以叠加使用。

五、isPending 的 UI 设计原则

// ✅ 轻量提示:降低透明度
<div style={{ opacity: isPending ? 0.6 : 1 }}>{content}</div>

// ✅ 细进度条(顶部)
{isPending && <div className="top-progress-bar" />}

// ✅ 小 spinner(局部)
{isPending && <Spinner size="small" />}

// ❌ 全屏 loading 遮罩(会让用户觉得「更慢」)
{isPending && <FullScreenOverlay />}
三条原则:
① 「轻」——不要遮挡内容,不要用全屏遮罩
② 「局部」——只在「正在更新的那块区域」提示
③ 「不打断」——避免明显的位置跳动(否则看起来更卡)

⚠️ 因为 transition 更新通常很快就完成(几十~几百毫秒),
   所以提示要「轻到几乎察觉不到」才合适。

六、什么时候它们无效

① 「紧急更新本身就很重」
   如果输入框所在的组件必须渲染一个大列表,
   那 setKeyword 本身就会触发长渲染 —— transition 帮不上忙。
   解法:拆分(把大列表隔离到独立组件)+ memo + 虚拟滚动。

② 卡顿来自非 React 的原因
   DOM 数量、布局抖动、网络等待、第三方库同步计算
   → 这些不属于 React 的渲染调度范围。

③ 用错了地方
   把「输入受控值」「hover 反馈」「loading 状态」放进 transition
   → 输入会卡、反馈会迟。

④ 没有 createRoot(legacy 模式)
   → 并发特性不生效。

七、怎么度量收益

① 用 Chrome DevTools 的 Performance 面板看「交互延迟」
   · 找到「输入」对应的长任务
   · 优化前:一次按键触发的 JS 执行可能 100ms+(阻塞下一帧)
   · 优化后:紧急更新很短,非紧急更新被拆开

② 用 Web Vitals 的 INP(Interaction to Next Paint)
   · INP 衡量「用户交互到界面响应」的延迟
   · transition 的直接收益就是「降低 INP」

③ 用 React DevTools Profiler 看「一次交互触发的渲染」
   · 优化后,你会看到渲染被「分成了多次小片段」
     (因为非紧急部分可以被打断)

⚠️ 注意:transition 不会让「总耗时」变短,所以不要用「总渲染时间」当指标。
   正确的指标是「用户感知的响应延迟」。

八、常见坑

// ① 用 transition 包「受控输入的值」
startTransition(() => setInputValue(v));        // ❌ 输入卡顿

// ② 用 transition 包「loading 提示 / hover 反馈」
startTransition(() => setLoading(true));        // ❌ 用户看不到 loading

// ③ 认为 transition 能减少计算量
// → 它只是「改变优先级」

// ④ 用 transition 替代「减少渲染的工作」(应该先做后者)
// ⑤ 忘记 createRoot → 特性不生效
// ⑥ 用全屏遮罩展示 isPending(看起来更慢)
// ⑦ 没给「过渡中的内容」降低透明度(用户看不到「正在更新」)
// ⑧ 在 transition 里做副作用(如发请求)
startTransition(() => { api.post(data); });     // ❌ 副作用不该放这里

// ⑨ 把「切换选中态」也放进 transition(高亮滞后)
// ⑩ 期待 transition 解决「DOM 数量多」的问题(应该用虚拟滚动)
// ⑪ 用「总渲染耗时」衡量收益(应该看交互延迟/INP)
// ⑫ 在 transition 内部读「更新后的 state」(拿到的是当前快照)
startTransition(() => {
  setList(filter(keyword));    // ⚠️ keyword 是当前渲染的闭包值
});

九、面试延伸

  1. 「useTransition 能提升性能吗?」
    严格说它不提升「性能」但提升「响应性」——它不减少总工作量(该渲染的还要渲染),而是把更新分成「紧急」与「非紧急」,让非紧急的可以被中断、延后,从而让紧急更新(如输入回显、滑块位置)立即响应。所以正确的说法是「它改善可交互性(INP / 交互延迟)」,而不是「让页面渲染更快」。

  2. 「什么场景适合用 useTransition?」
    四种:① 输入驱动昂贵渲染(搜索框过滤大列表——输入值紧急、列表非紧急);② Tab / 路由切换(选中态紧急、内容非紧急);③ 昂贵的派生计算(拖动滑块时重算预览);④ 路由跳转(点击反馈紧急、内容渲染非紧急)。共同特征是「有一个紧急的交互反馈 + 一个昂贵的后续工作」。

  3. 「useTransition 和 useDeferredValue 怎么选?」
    判断标准是「你能不能控制「更新在哪里发生」」:① 能控制(你自己的 setState)→ 用 useTransition(还能拿到 isPending);② 不能控制(值来自 props / 外部)→ 用 useDeferredValue(它只延迟「使用」,不改更新逻辑)。此外 useDeferredValue 更适合「延迟渲染某个子树」或「配合 Suspense 避免闪 loading」。

  4. 「它和防抖有什么不同?」
    防抖是「时间维度」的控制(延迟固定毫秒数),能减少执行次数(跳过中间输入)——适合发请求;useTransition/useDeferredValue 是「优先级维度」的控制(让紧急的先完成),不减少执行次数——适合同步的昂贵渲染。两者定位不同,实践中常组合使用(请求防抖 + 渲染 deferred)。一句话:「防抖减少次数,transition 改变优先级」。

  5. 「isPending 应该怎么用?」
    三条原则:① 轻——不要遮挡内容(用「降低透明度」或「细进度条」或「小 spinner」);② 局部——只在「正在更新的那块区域」提示;③ 不打断——避免明显的位置跳动。不要用全屏 loading 遮罩——因为 transition 的更新通常在几十到几百毫秒内完成,用重遮罩会让用户觉得「更慢」,效果适得其反。

  6. 「什么情况下 useTransition 帮不上忙?」
    四种:① 紧急更新本身就很重——比如输入框所在的组件必须渲染大列表,那么 setKeyword 自己就会触发长渲染;解法是「拆分组件 + memo + 虚拟滚动」;② 卡顿来自非 React 原因——DOM 数量、布局抖动、网络等待、第三方库的同步计算;③ 用错地方——把输入受控值、hover 反馈、loading 状态放进 transition;④ 没有用 createRoot——legacy 模式下并发特性不生效。

  7. 「并发特性的收益怎么度量?」
    不能用「总渲染耗时」(它不会变短)。正确的指标是「用户感知的响应延迟」:① INP(Interaction to Next Paint)——Web Vitals 的核心指标之一,衡量「交互到界面响应」的延迟,transition 的直接收益就体现在它上面;② Chrome Performance 面板里的长任务——优化后「单次按键触发的长任务」会被拆成多个片段;③ 主观体验——输入是否跟手、滑块是否跟手。总之要看「感知」而不是「总量」。

  8. 「什么时候优先用『减少工作量』而不是『改变优先级』?」
    几乎总是优先「减少工作量」。因为:① 它从根本上变快(少渲染、少 DOM、少计算);② 它不依赖用户如何交互;③ 它不会引入「滞后感」。只有当「工作量确实无法再减少」(比如必须渲染这 1000 行、必须等这个数据)时,才用 transition 去「改善响应性」。正确顺序是「先减少工作量 → 再改变优先级 → 最后考虑 Web Worker / 虚拟滚动等结构性手段」。

一句话速记

useTransition/useDeferredValue 改变的是「优先级」不是「工作量」,收益是可交互性(INP);useTransition 用于「你能控制的 setState」(配合 isPending)、useDeferredValue 用于「你控制不了的值(props)」;适合「紧急反馈 + 昂贵后续」的场景(搜索过滤、Tab 切换、滑块预览、路由跳转);不要包「输入受控值 / hover / loading」(会适得其反);与防抖的分工是「防抖减少次数(请求)、transition 改变优先级(渲染)」;isPending 要轻量、局部、不打断(别用全屏遮罩);没有 createRoot 就不生效;度量看 INP 与交互延迟,而不是总渲染耗时。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.7 并发特性的性能收益

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫