React 18 并发特性实战:用 useTransition 和 useDeferredValue 优化列表搜索体验

搜索框输入时页面卡顿是常见痛点。本文记录如何用 React 18 的 useTransition 和 useDeferredValue 将列表搜索的输入延迟从 300ms 降到几乎无感知,包含完整代码、性能测试数据和原理解析。

搜索框输入卡顿,用户以为网断了

我们运营后台有一个用户列表页,支持按姓名、邮箱、手机号搜索。列表数据量不大,后端一次返回全量 2000 条,前端做本地筛选和分页。看着需求简单,但上线后收到一堆投诉:"输入搜索词时,键盘按下去半天才出来字母。"

我用 React DevTools 的 Profiler 录了一段,发现问题:每次输入一个字符,都会触发全量数据重新筛选和 20 条一页的列表重新渲染,整个过程耗时约 280ms。虽然没超过一帧的 16ms 多少倍,但频繁的 commit 导致浏览器来不及绘制,输入框的视觉反馈被严重延迟。

查了一些社区资料,比如 gpt108.com 上整理的前端性能专题,里面提到 React 18 的并发特性正好能解决这类输入框卡顿问题。于是我决定把列表页面改造为并发模式。


问题根源:紧急更新被非紧急更新拖累

简化后的原代码:

jsx 复制代码
function UserList() {
  const [keyword, setKeyword] = useState('');
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  // 每次 keyword 变化,立即筛选并渲染
  const filteredUsers = useMemo(() => {
    return users.filter(u => 
      u.name.includes(keyword) || 
      u.email.includes(keyword)
    );
  }, [keyword, users]);

  return (
    <div>
      <input 
        value={keyword}
        onChange={e => setKeyword(e.target.value)}
        placeholder="搜索用户..."
      />
      <UserTable data={filteredUsers.slice(0, 20)} />
    </div>
  );
}

在 React 18 之前,setKeyword 触发的更新和列表渲染是同一个同步渲染周期,无法中断。用户输入"zhang"这五个字母,React 要依次处理五次完整的筛选和渲染,前四次的结果用户根本看不到(还没来得及绘制就被下一个更新覆盖),白白浪费了主线程时间。

方案一:useTransition 标记低优先级更新

useTransition 返回一个 isPending 状态和一个 startTransition 函数。被 startTransition 包裹的更新会被标记为低优先级,可以被更高优先级的更新(如用户输入)中断。

jsx 复制代码
import { useState, useMemo, useTransition } from 'react';

function UserList() {
  const [keyword, setKeyword] = useState('');
  const [deferredKeyword, setDeferredKeyword] = useState('');
  const [users, setUsers] = useState([]);
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  const handleInputChange = (e) => {
    // 高优先级:立即更新输入框显示
    setKeyword(e.target.value);
    
    // 低优先级:延迟更新筛选结果
    startTransition(() => {
      setDeferredKeyword(e.target.value);
    });
  };

  const filteredUsers = useMemo(() => {
    return users.filter(u => 
      u.name.includes(deferredKeyword) || 
      u.email.includes(deferredKeyword)
    );
  }, [deferredKeyword, users]);

  return (
    <div>
      <input 
        value={keyword}
        onChange={handleInputChange}
        placeholder="搜索用户..."
      />
      {isPending && <span style={{ color: '#999' }}>筛选结果更新中...</span>}
      <UserTable data={filteredUsers.slice(0, 20)} />
    </div>
  );
}

工作原理

  • setKeyword 直接更新 state,触发高优先级渲染,输入框立刻响应。
  • startTransition 内的 setDeferredKeyword 被 React 标记为过渡更新。如果在渲染过程中有新的输入事件(高优先级),React 会中断当前过渡渲染,先处理输入,之后再继续筛选渲染。

性能对比(使用 Performance 面板录制 5 次快速输入):

  • 优化前:输入框首次出现字符延迟平均 280ms,期间有 5 次完整渲染(每次筛选 + 列表)。
  • 优化后:输入框字符瞬间出现,筛选渲染被合并为 1-2 次(中间的被中断了),且过程不阻塞输入。

方案二:useDeferredValue 更简洁的写法

如果只想延迟某个值的更新,useDeferredValueuseTransition 更直接。它接收一个值,返回该值的"滞后版本",滞后版本会在主线程空闲时更新。

jsx 复制代码
import { useState, useMemo, useDeferredValue } from 'react';

function UserList() {
  const [keyword, setKeyword] = useState('');
  const deferredKeyword = useDeferredValue(keyword);
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetchUsers().then(setUsers);
  }, []);

  const handleInputChange = (e) => {
    setKeyword(e.target.value); // 立即更新
  };

  const filteredUsers = useMemo(() => {
    return users.filter(u => 
      u.name.includes(deferredKeyword) || 
      u.email.includes(deferredKeyword)
    );
  }, [deferredKeyword, users]); // 依赖延迟版本

  // 判断是否在等待更新
  const isStale = keyword !== deferredKeyword;

  return (
    <div>
      <input 
        value={keyword}
        onChange={handleInputChange}
        placeholder="搜索用户..."
      />
      {isStale && <span style={{ color: '#999' }}>更新中...</span>}
      <UserTable data={filteredUsers.slice(0, 20)} />
    </div>
  );
}

与 useTransition 的区别

  • useTransition 需要你主动包裹 startTransition,适合控制某个具体的更新。
  • useDeferredValue 只需声明一个延迟值,适合将某个频繁变化的值(如搜索关键词)传递给下游组件,让下游自动延迟渲染。

在我们这个场景,useDeferredValue 代码量更少,效果几乎一样。我最终采用了 useDeferredValue 方案。

进一步优化:配合 React.memo 阻断无意义渲染

并发特性解决了"更新可中断"的问题,但如果子组件没做记忆化,高优先级的输入更新仍然会触发子组件的同步重渲染。需要给列表组件加上 React.memo

jsx 复制代码
const UserTable = React.memo(function UserTable({ data }) {
  return (
    <table>
      <thead>...</thead>
      <tbody>
        {data.map(user => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>{user.phone}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
});

但光有 memo 还不够,因为每次渲染都会传入新的 data 数组引用(filteredUsers.slice(0, 20) 每次返回新数组)。需要再包一层 useMemo 来稳定引用:

jsx 复制代码
const slicedUsers = useMemo(() => filteredUsers.slice(0, 20), [filteredUsers]);
// ...
<UserTable data={slicedUsers} />

这样,当 deferredKeyword 没变时,filteredUsers 不变,slicedUsers 不变,UserTable 不会重新渲染。

测试数据:从卡顿到丝滑

在 Chrome Performance 面板中,模拟低端移动设备 CPU(6x slowdown),连续快速输入"zhangsan":

指标 优化前 优化后 (useDeferredValue)
输入框字符出现延迟 280ms <16ms(无感知)
筛选渲染次数 10次(每字符一次) 2次(开头一次,结尾一次)
期间掉帧数 6次 0次
JS 主线程占用总时长 2.8s 0.4s

用户感知:输入框不再"吃字",列表在停止输入后几乎瞬间更新。

何时用 useTransition vs useDeferredValue vs 节流?

  • 节流/防抖 :传统方案,用 setTimeout 延迟状态更新。缺点是延迟时间固定,快速输入时要么太慢要么过滤不全。并发特性会根据主线程空闲情况动态调整,体验更佳。
  • useDeferredValue:适合将某个频繁变化的值(搜索词、标签切换)延迟传递给大量依赖它的子组件。
  • useTransition:适合需要明确控制哪个更新是"过渡"的场景,比如路由切换时标记页面渲染为过渡,或提交表单后标记结果展示为过渡。

一个判断原则:如果你的代码里能清晰区分"紧急更新"和"非紧急更新",用 useTransition;如果你只是想把某个值的变化变为可中断的低优先级,用 useDeferredValue

踩坑记录

  1. 不要在 startTransition 里放副作用startTransition 只应包含状态更新,不能有网络请求、DOM 操作等副作用。副作用应该放在 useEffect 中,并且利用 isPending 控制。

  2. useDeferredValue 的滞后版本不能用于受控组件 :比如 <input value={deferredKeyword}>,这会导致输入框也延迟响应,完全违背初衷。输入框必须绑定原始值,只将延迟值传给展示组件。

  3. 并发特性仅在启用 Concurrent Mode 的根节点生效 :React 18 的 createRoot 默认开启,确保你的入口是 createRoot(document.getElementById('root')).render(<App />),而不是旧版的 ReactDOM.render

总结

React 18 的并发特性不是"魔法",但确实解决了前端长期存在的"输入卡顿"痛点。它的核心思想是:让紧急更新优先,非紧急更新可中断 。用 useTransitionuseDeferredValue 替换掉手写的防抖,代码更少,体验更好。

如果你的项目还在用 React 16 或 17,升级到 18 后优先尝试这两个 API。几十行代码的改动,就能让用户感受到明显的流畅度提升。


你在项目中用过 React 18 的并发特性吗?遇到了哪些问题或惊喜?欢迎评论区交流。

相关推荐
文心快码BaiduComate1 小时前
Comate搭载MiniMax M3:支持超长百万上下文
前端·人工智能·后端
WebInfra1 小时前
TanStack Start 框架正式支持 Rsbuild
前端·javascript·前端框架
Demon1_Coder1 小时前
智能体的自定义工具
java·linux·前端
老王以为1 小时前
单仓库下的四十模块 —— React Monorepo 工程架构拆解
前端·react native·react.js
lichenyang4531 小时前
鸿蒙路由研读:为什么公司项目用 HMRouterMgr 而不用原生 Navigation
前端
gf13211111 小时前
【精确查找python脚本是否在运行】
linux·前端·python
mCell1 小时前
别急着骂运营商,你家路由器里可能藏着一台 PCDN 盒子
前端·http·cdn
PILIPALAPENG1 小时前
Skills篇-findskills:告别手动迁移Skill!跨AI工具通用能力,才是真高效
前端·人工智能·后端
假如让我当三天老蒯1 小时前
Composables和Utils的区别(自学用)
前端