搜索框输入时页面卡顿是常见痛点。本文记录如何用 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 更简洁的写法
如果只想延迟某个值的更新,useDeferredValue 比 useTransition 更直接。它接收一个值,返回该值的"滞后版本",滞后版本会在主线程空闲时更新。
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。
踩坑记录
-
不要在 startTransition 里放副作用 :
startTransition只应包含状态更新,不能有网络请求、DOM 操作等副作用。副作用应该放在useEffect中,并且利用isPending控制。 -
useDeferredValue 的滞后版本不能用于受控组件 :比如
<input value={deferredKeyword}>,这会导致输入框也延迟响应,完全违背初衷。输入框必须绑定原始值,只将延迟值传给展示组件。 -
并发特性仅在启用 Concurrent Mode 的根节点生效 :React 18 的
createRoot默认开启,确保你的入口是createRoot(document.getElementById('root')).render(<App />),而不是旧版的ReactDOM.render。
总结
React 18 的并发特性不是"魔法",但确实解决了前端长期存在的"输入卡顿"痛点。它的核心思想是:让紧急更新优先,非紧急更新可中断 。用 useTransition 或 useDeferredValue 替换掉手写的防抖,代码更少,体验更好。
如果你的项目还在用 React 16 或 17,升级到 18 后优先尝试这两个 API。几十行代码的改动,就能让用户感受到明显的流畅度提升。
你在项目中用过 React 18 的并发特性吗?遇到了哪些问题或惊喜?欢迎评论区交流。