学完这篇,你能按文档规模选对纯前端全文搜索方案,并知道中文分词、索引缓存这两个坑该怎么绕。
第一步:先算清需求,再选方案
纯前端搜索没有后端兜底,文档量和查询频率直接决定方案。先问三个问题:
- 文档有多少条?100 条以内和 1 万条是两个世界。
- 数据是打包在页面里,还是运行时 fetch 一份 JSON?
- 要不要中文?要中文就绕不开分词。
把数据准备成一个数组,结构统一成 { id, title, body },后面的方案都能直接换。
第二步:朴素方案——includes 直接扫
function search(q, docs) {
const k = q.trim().toLowerCase();
if (!k) return [];
return docs.filter(d => (d.title + d.body).toLowerCase().includes(k));
}
十几行就能跑,几百条文档无压力。缺点也很直接:不能多关键词、不能排序、不能容错(打错一个字就搜不到),文档过千后每次输入都全量扫描会明显卡。
注意:别把这段直接绑在 input 事件上。加 200ms 防抖,否则每敲一个键都跑一遍全表。
第三步:自己写倒排索引(理解成本最低的进阶)
核心思路:建表时把每篇文档切成词项,记录「词项 → 文档 id 集合」,查询时按词项取交集打分。
function tokenize(text) {
const s = text.toLowerCase();
const tokens = [...(s.match(/[a-z0-9]+/g) || [])];
for (const seg of s.match(/[\u4e00-\u9fa5]+/g) || []) {
if (seg.length === 1) tokens.push(seg);
for (let i = 0; i < seg.length - 1; i++) tokens.push(seg.slice(i, i + 2));
}
return tokens;
}
const index = new Map();
function buildIndex(docs) {
for (const doc of docs) {
for (const t of new Set(tokenize(doc.title + ' ' + doc.body))) {
if (!index.has(t)) index.set(t, new Set());
index.get(t).add(doc.id);
}
}
}
function search(q) {
const score = new Map();
for (const t of tokenize(q)) {
const ids = index.get(t);
if (!ids) continue;
for (const id of ids) score.set(id, (score.get(id) || 0) + 1);
}
return [...score].sort((a, b) => b[1] - a[1]).map(([id]) => id);
}
中文这里用二元组(bigram)切分,不做词典分词:把「全文搜索」切成「全文」「文搜」「搜索」。这样无需任何词典,代价是索引膨胀,查询时用同样的切法即可命中。
注意:建索引和查询必须用同一个 tokenize。任何一边改了切分规则,索引就全部作废,记得给索引加一个版本号。
第四步:直接上现成库
自己写的索引没有词频权重、前缀匹配、模糊纠错。生产项目建议直接用库。
MiniSearch(约 7KB gzip,API 最直观):
import MiniSearch from 'minisearch';
const mini = new MiniSearch({ fields: ['title', 'body'], storeFields: ['title'] });
mini.addAll(docs);
const results = mini.search('全文搜索', { prefix: true, fuzzy: 0.2 });
FlexSearch(体积更小、查询最快,但 API 偏底层):
const idx = new FlexSearch.Document({
document: { id: 'id', index: ['title', 'body'] },
tokenize: 'forward'
});
docs.forEach(d => idx.add(d));
const res = idx.search('全文', { index: 'body' });
Lunr 功能全、生态老,但体积最大、构建慢。Fuse.js 是模糊匹配(Bitap 算法),不建倒排索引,适合「命令面板、设置项搜索」这类几百条的小数据,不适合长文全文检索。
| 方案 | 千条文档构建 | 查询 | 体积 | 中文 |
|---|
| includes | 0 | 慢(每次全扫) | 0 | 直接可用 |
| 手写倒排 | 快 | 很快 | 0 | 需自己切分 |
| MiniSearch | 中等 | 快 | ~7KB | 需配置分词 |
| FlexSearch | 快 | 最快 | ~6KB | 需 CJK encoder |
| Fuse.js | 快 | 中等 | ~5KB | 逐字模糊,可用 |
第五步:把索引挪出主线程并缓存
文档上千后,建索引会卡住主线程几百毫秒。两个动作必做:
- 丢进 Web Worker:主线程只负责发查询词,Worker 里建索引和检索,通过
postMessage 回传结果。
- 持久化索引:MiniSearch 有
toJSON() / MiniSearch.loadJSON(),把索引序列化后存进 IndexedDB,下次进站直接加载而不是重建。
const json = JSON.stringify(mini);
localStorage.setItem('idx:v3', json); // 前面带上数据版本号
注意:索引缓存的 key 一定要带数据版本。文档更新了但索引还是旧的,用户会搜到已删除的内容——这比搜不到更难排查。
小结
- 百条以内用
includes 加防抖就够了,别过度设计。
- 中文纯前端搜索用二元组切分最省事,建索引和查询的切分规则必须一致。
- 上千条优先 MiniSearch 或 FlexSearch,Fuse.js 只适合短文本模糊匹配。
- 建索引放 Web Worker,索引序列化进 IndexedDB,key 带版本号。
- 静态站点可以在构建期把索引生成成 JSON,运行时只加载不构建。