Search搜索功能的前端实现与性能优化实战经验
优化前:卡得不行
上周改一个老项目的搜索组件,用户反馈“输个字等半天,结果还没出来”,我本地跑了一下——好家伙,输入框每敲一个字母,页面直接卡住 1 秒多,连续打几个字,浏览器直接白屏转圈。Chrome DevTools 的 Performance 面板里全是红色的 Long Task,主线程被占得死死的。
这个搜索是传统的“边输边查”模式,每次 input 事件都发请求到 https://jztheme.com/api/search?q=xxx,返回几十条结果渲染成列表。数据量不大(一次最多 50 条),但问题出在没做任何防抖,加上渲染逻辑又重,导致性能雪崩。
找到瓶颈了!
先开 DevTools 看 Performance 录制。输入 “react” 四个字,录下来一看:
- 4 次网络请求几乎同时发出(因为没防抖)
- 每个请求回来后都触发一次完整列表 rerender
- 列表项用了复杂的组件嵌套,每个 item 都重新创建新对象
- 最要命的是,每次渲染都强制触发 layout(因为用了 offsetHeight 这种同步读取布局的 API)
另外 Network 面板显示,虽然单个请求很快(200ms 左右),但并发太多反而让服务器响应变慢,有些请求甚至被 cancel 掉了——典型的资源浪费。
折腾了半天发现,核心问题就三个:没防抖、渲染太重、重复请求没取消。
优化方案:三板斧搞定
试了几种方案,最后组合拳效果最好。下面一个个说。
第一斧:防抖 + 请求取消
防抖是基本操作,但很多人只做了防抖,忘了取消上一个 pending 的请求。结果就是:用户输 “a”、“ab”、“abc”,虽然只发最后一个请求,但前两个还在后台跑,浪费带宽还可能污染状态。
用 AbortController 解决:
let abortController = null;
function handleInput(e) {
const query = e.target.value;
if (abortController) {
abortController.abort(); // 取消上一个请求
}
if (!query.trim()) {
setSearchResults([]);
return;
}
abortController = new AbortController();
const signal = abortController.signal;
// 防抖 300ms
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => {
try {
const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(query)}, { signal });
const data = await res.json();
// 注意:如果请求被 abort,这里会抛 DOMException,所以要用 try/catch
setSearchResults(data.items);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Search error:', err);
}
}
}, 300);
}
这里踩过坑:一开始没加 signal,abort 无效;后来忘了 catch AbortError,控制台一堆红。现在稳了。
第二斧:虚拟滚动 + 列表优化
搜索结果最多 50 条,按理说不算多,但每条 item 渲染时都做了图标解析、高亮匹配文本、动态 class 计算,导致单次 render 耗时 60ms+。50 条一起上,直接卡死。
其实用户一次只能看到 8~10 条,没必要全渲染。上虚拟滚动(Virtualized List):
我用的是 react-window(如果是 Vue 就用 vue-virtual-scroller),核心就几行:
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style} className="search-item">
{/* 高亮逻辑抽成纯函数,避免每次 re-create */}
{highlightMatch(results[index].title, query)}
</div>
);
// 在组件里
<List
height={400}
itemCount={results.length}
itemSize={56}
width="100%"
>
{Row}
</List>
关键点:把高亮逻辑写成 memoized 函数,避免每次 render 都新建正则和字符串处理:
const highlightMatch = useMemo(() => {
if (!query) return (text) => text;
const regex = new RegExp((${escapeRegExp(query)}), 'gi');
return (text) =>
text.split(regex).map((part, i) =>
regex.test(part) ? <mark key={i}>{part}</mark> : part
);
}, [query]);
注意 escapeRegExp 是自己写的工具函数,防止用户输入正则特殊字符炸掉页面。
第三斧:缓存 + 避免重复渲染
用户经常删删改改,比如搜 “react”,删成 “reac”,再打个 “t” 变回 “react”。这时候其实可以直接用缓存,不用再请求。
搞个简单的 Map 缓存:
const searchCache = new Map();
// 在请求前先查缓存
if (searchCache.has(query)) {
setSearchResults(searchCache.get(query));
return;
}
// 请求成功后存缓存
searchCache.set(query, data.items);
缓存大小有限制(比如最多存 20 条),避免内存爆炸。实测缓存命中率能到 30%+,尤其用户反复修改时特别有用。
另外,整个搜索组件用 React.memo 包一层,避免父组件 rerender 带动它无谓更新:
const SearchBox = React.memo(({ onResultSelect }) => {
// ...内部逻辑
});
优化后:流畅多了
改完之后,本地测试:
- 输入不再卡顿,打字跟手
- Network 面板里请求明显减少,且不会并发多个
- 列表滚动丝滑,即使快速滚动也不掉帧
最关键的是数据:
| 指标 | 优化前 | 优化后 |
| 首词输入响应时间 | 1200ms | 320ms |
| 连续输入卡顿次数(10 秒内) | 7 次 | 0 次 |
| 主线程阻塞时间(平均) | 850ms | 90ms |
实际用户体验提升巨大。之前产品经理天天催,现在终于能睡安稳觉了。
还有一些小细节
当然,也不是 100% 完美:
- 缓存策略比较简单,复杂场景可能需要 LRU
- 虚拟滚动在 SSR 下有点麻烦,我们项目是 CSR 所以没管
- 高亮如果匹配项太多(比如搜空格),还是会卡,加了个 maxLength 限制匹配数
但这些都不影响主流程,属于可接受范围。毕竟优化不是追求理论最优,而是解决实际痛点。
以上是我的实战经验
这次优化核心就三点:防抖+取消请求、虚拟滚动减渲染、简单缓存提体验。代码改动不大,但效果立竿见影。
以上是我对 Search 搜索组件的性能优化实战总结,有更优的实现方式欢迎评论区交流。比如你们怎么处理拼音搜索、模糊匹配这些更重的场景?我打算下一篇就写这个。
