React 并发渲染实战:用 useTransition 打造流畅 UI

一、为什么需要并发渲染

在 React 18 之前,一次 setState 会同步、不可中断地重渲染整棵受影响子树。当这次更新很重(例如对上千行表格做过滤),主线程被占满,用户输入、动画都会卡住。React 并发渲染(Concurrent Rendering)把渲染拆成可中断、可排优先级的任务:紧急更新(如键盘输入)插队到前面,非紧急更新(如重算列表)可以被暂停、复用或丢弃,从而让界面始终可交互。

并发渲染不是某个开关,而是一组 API:useTransition、useDeferredValue 和 Suspense。下面用可运行示例逐个讲清。

二、useTransition:把非紧急更新降级

useTransition 返回一个 startTransition 函数和 isPending 状态。把“重渲染”包进 startTransition,React 就把它标记为低优先级——如果期间来了紧急更新(输入框变化),低优先级任务会被中断重来,不会拖慢输入。

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

function SearchBox({ rows }) {
  const [text, setText] = useState("");
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  // 重计算放在 useMemo,依赖 query 而非 text
  const result = useMemo(
    () => rows.filter(r => r.toLowerCase().includes(query.toLowerCase())),
    [rows, query]
  );

  return (
    <>
      <input
        value={text}
        onChange={(e) => {
          const v = e.target.value;
          setText(v);                                  // 紧急更新:输入框立刻响应
          startTransition(() => setQuery(v));           // 非紧急:列表重算可被中断
        }}
      />
      {isPending ? <p>计算中...</p> : <Results data={result} />}
    </>
  );
}

关键点:setText(紧急)立刻改输入框,setQuery(非紧急)被放进 transition。哪怕 result 的过滤很慢,输入框也不会掉字;isPending 还能用来显示“计算中…”的友好提示。

三、useDeferredValue:延迟一个“慢”值

如果你只想“延迟某个值本身”而不想手动包一层 transition,用 useDeferredValue 更省事。它返回值的延迟副本:当前值立即更新(用于输入框),延迟值随后跟上(用于重渲染)。

import { useState, useDeferredValue, useMemo } from "react";

function DeferredSearch({ items }) {
  const [text, setText] = useState("");
  const deferredText = useDeferredValue(text);   // 延迟跟随的"慢值"

  const results = useMemo(
    () => items.filter(i => i.title.includes(deferredText)),
    [items, deferredText]
  );

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      {/* 用 deferredText 渲染,输入始终灵敏 */}
      <ResultList text={deferredText} results={results} />
    </>
  );
}

它和 useTransition 本质同源:都是“让紧急的先走”。区别是 useTransition 包的是一段更新动作,useDeferredValue 直接产出可延迟的值,适合在子组件里消费。

四、Suspense 与并发数据加载

Suspense 在并发模式下表现更顺滑:被它包裹的懒加载组件在就绪前只显示 fallback,且不会阻塞页面其余部分的交互。配合 React.lazy 做代码分割,首屏更轻、交互更稳。

import { Suspense, lazy } from "react";

const HeavyChart = lazy(() => import("./HeavyChart")); // 代码分割,按需加载

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      {/* 并发模式下 fallback 仅遮住图表区,不阻塞页面其他输入 */}
      <HeavyChart />
    </Suspense>
  );
}

五、三大特性的适用边界

特性适合的场景不适合的场景
useTransition用户输入后触发的大面积状态更新(筛选、切 Tab、排序)必须同步落地的写操作(如提交订单)
useDeferredValue某值驱动重渲染,但希望输入先响应值需要“立刻精确”(如实时校验红框)
Suspense组件/数据懒加载时的兜底 UI放在过粗的边界,导致整页白屏

六、实战:把卡顿表格改造成并发

下面是一个订单表的真实改造:点击表头按关键字过滤上千行,原本会卡顿,用 transition 包裹后排序操作依旧跟手。

function OrderTable({ orders, keyword }) {
  const [isPending, startTransition] = useTransition();
  const [view, setView] = useState(() =>
    orders.filter(o => o.owner.includes(keyword))
  );

  function onChange(kw) {
    // 把"重排整张表"包进 transition,点击表头依然跟手
    startTransition(() => {
      setView(orders.filter(o => o.owner.includes(kw)));
    });
  }

  return (
    <div>
      {isPending && <span className="tip">刷新中...</span>}
      <table>{/* 渲染 view 中的上千行 */}</table>
    </div>
  );
}

经验法则:凡是“用户操作后、界面要重算一大片”的地方,优先考虑 useTransition;凡是要把某个值喂给重渲染组件、又不想卡输入框的地方,用 useDeferredValue。

七、它和 React Server Components 是什么关系

很多人把并发渲染和 React Server Components(RSC) 混为一谈,其实它们是两层东西:RSC 解决“组件在哪执行、如何减少客户端 JS”;并发渲染解决“客户端更新时如何不卡”。二者互补——RSC 负责把首屏数据在服务端算好,并发特性负责交互时的流畅度,并不互相替代。

八、常见陷阱

1)把紧急更新也塞进 transition:结果就是输入也变慢,本末倒置。2)滥用导致“双份渲染”:transition 内的状态若和紧急状态耦合不清,会出现两次计算。3)Suspense 边界过粗:一个边界包住整页,加载时整页白屏,应下沉到具体懒加载区块。4)以为加了就自动快:并发只解决“调度优先级”,重计算本身该优化的(如虚拟列表、记忆化)仍要做。

九、上线前用性能数据验证

并发特性的收益要用真实指标说话,而不是“感觉更快”。建议结合 前端性能优化 一文里的 LCP、INP 指标,在输入高频场景下对比启用前后的交互延迟,确认 isPending 真正兜住了重渲染。也可参考 React 性能优化 Hooks 做记忆化配合。

十、小结

React 并发渲染把“渲染可中断、可排优先级”变成了日常可用的 API。记住三件套:useTransition 降级非紧急更新、useDeferredValue 延迟慢值、Suspense 提供不阻塞的兜底。把重交互的地方交给它们,再把底层计算和指标优化做扎实,你的页面就能从“卡一下”进化到“丝般顺滑”。

十一、并发渲染不是“自动批处理”

React 18 同时引入了自动批处理(Automatic Batching):同一个事件回调里的多次 setState 会自动合并成一次渲染。很多人把它和并发渲染混为一谈,其实两者目标不同。自动批处理解决“合并”,让更新更省;并发渲染解决“优先级与可中断”,让更新不卡。一个判断标准:如果某次更新“慢一点也行,但绝不能卡住输入”,它就是 transition 的候选;如果“必须立刻、精确反映”,就不要放进 transition。边界判断清楚了,并发特性才会真正发挥作用。

维度自动批处理并发渲染(transition)
解决什么合并多次 setState,减少渲染次数给更新排优先级、可中断
是否影响卡顿间接(渲染次数变少)直接(输入始终优先)
使用方式默认开启,无需改动需手动 useTransition / useDeferredValue

十二、落地 Checklist

1)先找出界面里“输入后大片重渲染”的交互点,比如长列表筛选、Tab 切换、大数据排序;2)把对应的状态更新包进 useTransition,并用 isPending 给出“刷新中”之类的反馈;3)对“直接喂给重渲染组件的值”改用 useDeferredValue;4)给懒加载区块单独套一层 Suspense,避免整页兜底白屏;5)用 Lighthouse 或真实设备的 Web Vitals 对比 INP 指标,确认收益真实存在,而不是“感觉更快”。

做到这五步,重交互页面就能稳定保持灵敏的输入响应,用户体验会有质的提升。并发渲染不是银弹,它只接管“更新如何被调度”,底层该做的虚拟列表、记忆化、数据裁剪依然不能少——把这两层叠加,页面才真正丝般顺滑。

上一篇 HikariCP 连接池调优实战:从连接泄漏到科学配置
下一篇 chezmoi 实战:用 Git 管理 dotfiles
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]