Search搜索功能的前端实现与性能优化实战经验

涵舒的笔记 组件 阅读 1,266
赞 12 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周改一个老项目的搜索组件,用户反馈“输个字等半天,结果还没出来”,我本地跑了一下——好家伙,输入框每敲一个字母,页面直接卡住 1 秒多,连续打几个字,浏览器直接白屏转圈。Chrome DevTools 的 Performance 面板里全是红色的 Long Task,主线程被占得死死的。

Search搜索功能的前端实现与性能优化实战经验

这个搜索是传统的“边输边查”模式,每次 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 搜索组件的性能优化实战总结,有更优的实现方式欢迎评论区交流。比如你们怎么处理拼音搜索、模糊匹配这些更重的场景?我打算下一篇就写这个。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
甜茜~
甜茜~ Lv1
之前试了很多方法都没解决,看了这篇文章后很快就搞定了,节省了我大量时间。
点赞
2026-03-02 13:25