一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战
author: 大布布将军
前言:万恶之源
本故事有虚构成分。但来源于现实开发场景。 事情是这样的。
某个周五下午 4 点,产品经理(PM)迈着六亲不认的步伐向我们前端走来。他满面春风地说:“哎,那个列表页,用户反馈说找不到想要的数据,加个实时搜索功能吧?要那种一边打字一边过滤的,丝般顺滑的感觉,懂我意思吧?”
前端心想:这还不简单?input 绑定 onChange,拿到 value 往列表里一 filter,完事儿。半小时搞定,还能赶上 6 点的地铁。
于是,前端写下了那段让我后来后悔不已的代码。
第一阶段:由于过度自信导致的翻车
为了还原案发现场,前端写了一个简化版的 Demo。假设我们有一个包含 10,000 条数据的列表(别问为什么前端要处理一万条数据,问就是后端甩锅)。
也就是这几行“天真”的代码:
// 假装这里有一万个商品
const generateProducts = () => {
return Array.from({ length: 10000 }, (_, i) => `超级无敌好用的商品 #${i}`);
};
const dummyProducts = generateProducts();
export default function SearchList() {
const [query, setQuery] = useState('');
// 🔴 罪魁祸首在这里:每次 render 都要遍历一万次
const filteredProducts = dummyProducts.filter(p =>
p.toLowerCase().includes(query.toLowerCase())
);
const handleChange = (e) => {
setQuery(e.target.value); // 这一步更新 state,触发重渲染
};
return (
<div className="p-4">
<input
type="text"
value={query}
onChange={handleChange}
placeholder="搜索..."
className="border p-2 w-full"
/>
<ul className="mt-4">
{filteredProducts.map((p, index) => (
<li key={index}>{p}</li>
))}
</ul>
</div>
);
}
结果如何?
在前端的 ThinkBook 上跑了一下,输入的时候感觉像是在 PPT 里打字。每一个字符敲下去,都要顿个几百毫秒才会显示在输入框里。
为什么? 这是 React 的基本原理:
- 用户输入 'a' -> 触发
setQuery。 - React 甚至还没来得及把 'a' 更新到 input 框里,就被迫去执行组件的
render函数。 -
render函数里有一个极其昂贵的filter操作(遍历 10k 次)。 - 即使
filter完了,React 还要把生成的几千个 DOM 节点和之前的做 Diff,然后挂载到页面上。 - JS 线程被堵死,UI 渲染被阻塞,用户看到的就是:卡顿。
第二阶段:万金油防抖 (Debounce) —— 治标不治本
作为老油条,第一反应当然是:“切,防抖一下不就行了?”
只要让用户打字的时候不触发计算,停下来再计算,不就完了?
import { debounce } from 'lodash';
// ... 省略部分代码
const handleChange = debounce((e) => {
setQuery(e.target.value);
}, 300);
效果: 输入框确实不卡了,打字很流畅。但是,当你停止打字 300ms 后,页面会突然“冻结”一下,然后列表瞬间刷新。
痛点: 这种体验就像是便秘。虽然没有一直在用力,但最后那一下还是很痛苦。而且,UI 的响应滞后感很强,依然没有达到 PM 要求的“丝般顺滑”。
第三阶段:祭出神器 useDeferredValue
React 18 发布这么久了,是时候让它出来干点活了。
这时候作为前端的我们需要引入一个概念:并发模式 (Concurrent Features) 。
简单来说,就是把更新任务分为“大哥”和“小弟”。
- 大哥(紧急更新) :用户的打字输入、点击反馈。这玩意儿必须马上响应,不然用户会以为死机了。
- 小弟(非紧急更新) :列表的过滤渲染。晚个几百毫秒没人在意。
React 18 给了我们一个 Hook 叫 useDeferredValue,专门用来处理这种场景。
改造后的代码:
export default function OptimizedSearchList() {
const [query, setQuery] = useState('');
// 魔法在这里:创建一个“滞后”的副本
// React 会在空闲的时候更新这个值
const deferredQuery = useDeferredValue(query);
const handleChange = (e) => {
// 这里依然是紧急更新,保证 input 框打字流畅
setQuery(e.target.value);
};
// 只有当 deferredQuery 变了,才去跑这个昂贵的 filter
// 注意:这里要配合 useMemo,不然也没用
const filteredProducts = useMemo(() => {
return dummyProducts.filter(p =>
p.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [deferredQuery]);
return (
<div className="p-4">
<input
type="text"
value={query} // 绑定实时的 query
onChange={handleChange}
className="border p-2 w-full"
/>
{/* 甚至可以加个 loading 状态,判断 query 和 deferredQuery 是否同步 */}
<div style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}>
<ul className="mt-4">
{filteredProducts.map((p, index) => (
<li key={index}>{p}</li>
))}
</ul>
</div>
</div>
);
}
这一波操作到底发生了什么?
-
输入 'a' :React 此时收到了两个任务。
- 任务 A(高优先级):更新
query,让 input 框显示 'a'。 - 任务 B(低优先级):更新
deferredQuery,并重新计算列表。
- 任务 A(高优先级):更新
-
React 的调度:
- React 说:“任务 A 甚至关乎到我的尊严,马上执行!” -> Input 框瞬间变了。
- React 接着说:“任务 B 嘛,我先切片执行一点点... 哎?用户又输入 'b' 了?那任务 B 先暂停,我去执行新的任务 A!”
-
结果:
- JS 线程没有被长列表渲染锁死。
- 输入框始终保持 60fps 的响应速度。
- 列表会在资源空闲时“不知不觉”地更新完成。
深度解析:React 原理是咋搞的?
为了不显得只是个 API 调用侠,这里必须装一波,讲讲原理。
1. 以前的 React (Stack Reconciler)
想象你在厨房切洋葱(渲染组件)。老板(浏览器)跟你说:“客人要加单!”。以前的 React 是个死脑筋,一旦开始切洋葱(比如那 10,000 条数据),天王老子来了它也得切完才肯抬头。这时候浏览器就卡死了,用户点击没反应。
2. 现在的 React (Fiber & Concurrency)
现在的 React 学聪明了,它把切洋葱分成了无数个小步骤(Time Slicing)。
- 切两刀,抬头看看:“老板,有急事吗?”
- 老板:“没事,你继续。” -> 继续切。
- 切两刀,抬头:“老板?”
- 老板:“有!客人要喝水(用户输入了)!”
- React:“好嘞!”(放下菜刀,先去倒水,倒完水回来再继续切洋葱,或者如果洋葱不用切了直接扔掉)。
useDeferredValue 本质上就是告诉 React:“这个 state 的更新是切洋葱,可以往后稍稍,先去给客人倒水。”
总结 & 避坑指南
这波优化上线后,PM 拍了拍前端的肩膀说:“行啊,有点东西。”
但是,兄弟们,请注意以下几点(防杠声明):
-
不要滥用:这玩意儿是有 overhead(开销)的。如果你的列表只有 50 条数据,用
useDeferredValue纯属脱裤子放屁,反而更慢。 -
配合 useMemo:就像代码里写的,过滤逻辑必须包裹在
useMemo里。否则每次 Parent Render,列表过滤还是会执行,useDeferredValue就白用了。 -
性能优化的尽头是虚拟列表:如果数据量真到了 10 万级,别折腾 Concurrent Mode 了,直接上
react-window或react-virtualized吧,DOM 节点的数量才是真正的瓶颈。
好了,今天的摸鱼时间结束,撤退 。 我是大布布将军,一个前端开发。
下一步建议:如果你的项目里也有这种“输入卡顿”或者“Tab 切换卡顿”的场景,别急着重构,先试着把那个导致卡顿的状态用
useDeferredValue包一下,说不定有奇效。