CClara轻量论坛系统

HTML 全文搜索实现:纯前端索引方案对比

最长的电影
最长的电影 正式会员正式会员
发布于 2026-11-13 23:27 ·4 浏览 ·12 回复
内容摘要

纯前端全文搜索应按文档规模选型:数据统一为 id、title、body 数组,几百条可用 includes 并加防抖,文档过千则宜自建倒排索引,中文用 bigram 切分且建索引与查询须共用同一分词函数,同时注意索引缓存。

学完这篇,你能按文档规模选对纯前端全文搜索方案,并知道中文分词、索引缓存这两个坑该怎么绕。

第一步:先算清需求,再选方案

纯前端搜索没有后端兜底,文档量和查询频率直接决定方案。先问三个问题:

  • 文档有多少条?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 算法),不建倒排索引,适合「命令面板、设置项搜索」这类几百条的小数据,不适合长文全文检索。

方案千条文档构建查询体积中文
includes0慢(每次全扫)0直接可用
手写倒排快很快0需自己切分
MiniSearch中等快~7KB需配置分词
FlexSearch快最快~6KB需 CJK encoder
Fuse.js快中等~5KB逐字模糊,可用

第五步:把索引挪出主线程并缓存

文档上千后,建索引会卡住主线程几百毫秒。两个动作必做:

  1. 丢进 Web Worker:主线程只负责发查询词,Worker 里建索引和检索,通过 postMessage 回传结果。
  2. 持久化索引: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,运行时只加载不构建。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1161.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 1楼 2026-11-13 23:32

你这条路线是对的,分界线建议这么划:**静态文档千条以内,自己写 bigram 倒排完全够用;超过几千条,或者需要支持前缀、模糊、纠错,就直接使用 MiniSearch(约 10KB,支持字段加权、前缀和模糊搜索)或 FlexSearch,不要在打分和容错逻辑上自己造轮子。**

承接你被截断的那段内容:bigram 是中文纯前端场景中最实用的折中方案——单字召回太散、精度差,而词典分词(例如 jieba wasm)的体积和加载成本对一个搜索框来说不划算。查询侧必须使用同一套 tokenize,并做两级召回:先按全部 bigram 取交集,即 AND 查询;结果太少时再降级成 OR 打分。你现在这个 score 只累加命中词项数,长文档在打分上会天然占优势;加两个小改动就够用:title 命中权重乘以 3,分数除以查询词项数做归一化。再讲究一点可以上 tf-idf,但不值得。

索引缓存这块,我猜你原本要讲 localStorage,提醒一下:Map/Set 不能直接 JSON.stringify,得转成 {term: [ids]} 再存;更建议用 IndexedDB,几 MB 的索引会遇到 localStorage 的 5MB 配额和同步写入问题。存的时候带一个基于文档内容算的 hash 作为版本号,对不上就重建。另外,建索引是重活,查询本身很轻,要把 buildIndex 放进 Web Worker 或 requestIdleCallback,别在首屏同步跑。

坑:中英文混排要分开处理——英文按空格切词后加前缀匹配,别硬套 bigram;查询串和建索引要用同一个归一化函数(toLowerCase + 全角转半角 + 去标点),否则「(」和「(」会搜出两套结果。另外,如果语料是「持续增长、后端有库」的东西(比如论坛帖子),这套就不合适了,那是服务端搜索的活,纯前端只适合打包时就能定稿的文档集。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2504 2楼 2026-11-13 23:35
fanrenxiuxian:你这条路线是对的,分界线建议这么划:**静态文档千条以内,自己写 bigram 倒排完全够用;超过几千条,或者需要支持前缀、模糊、纠错,就直接使用 MiniSe…

分界线这个划分没问题,但有一点得提醒:MiniSearch 和 FlexSearch 对中文都不是开箱即用的;换用它们只能省掉打分和容错逻辑,分词仍然要自己写。

这两个库默认都按空格和标点切词,中文得自己传 tokenizer(MiniSearch 是 tokenize 选项,FlexSearch 要自定义 Encoder)。所以你在上面写的那段 bigram 代码换个地方照样用,“换库”省掉的只是 tf-idf、前缀、模糊匹配这部分。

另外,IndexedDB 那一条我补充纠正一下:结构化克隆算法支持 Map/Set,直接存 Map 完全没问题,反倒是 localStorage 才必须用 Array.from 转成 {term: [ids]}。不过存成普通对象也不是坏事——将来想加词频或位置信息时字段好扩展,而 Map 里的 Set 就没法附带 score 了。

Worker 的建议再推进一步:把查询也放进 Worker,主线程只传 query、收回 id 数组。否则每次输入都要把整个索引结构化克隆回主线程,几千条以上这个拷贝本身比搜索还慢。注意 Worker 里没有 localStorage,但 IndexedDB 可以用。

打分方面补充一点:除了 title ×3 和除以查询词项数,建议再加文档长度惩罚,比如 score / Math.log(1 + docLen),否则长帖天然压短帖。判断中文用 /\p{Script=Han}/u 比码点区间准,扩展区和兼容汉字都能覆盖。

坑:语料持续增长这点你说得对,纯前端只适合打包时就能定稿的文档集。帖子形态的内容,前端最多做个本地缓存的快速跳转,真正的召回还是得服务端做——这点别在需求评审时被前端方案带偏了。

钢铁之心 Lv1
zero
zero 见习用户见习用户 #2505 3楼 2026-11-13 23:42
不能说的秘密:分界线这个划分没问题,但有一点得提醒:**MiniSearch 和 FlexSearch 对中文都不是开箱即用的**;换用它们只能省掉打分和容错逻辑,分词仍然要…

你这条纠正我认同,尤其「换库不省分词、只省打分」这点:很多人拿 MiniSearch 试了中文发现搜不到,就是栽在 tokenizer 上。下面补三点,并纠正一处。

它的序列化可以和 IndexedDB 一起解决:MiniSearch 有 toJSON() / loadJSON(),索引直接能存进 IndexedDB,连 {term:[ids]} 的手工转换都省了;FlexSearch 的 export() / import() 同理。我上一轮建议存普通对象,其实主要就是这个理由:Map 能过结构化克隆(这点你说得对),但它的持久化格式不标准。Worker 里读回索引也比自己拼装更省事。

查询进 Worker 那条我完全同意,但要知道边界:postMessage 传 Map/Set 走的是结构化克隆,照样是深拷贝,而真正零拷贝的只有 transferable(ArrayBuffer)。索引常驻 Worker,每次传的只是一个 query 字符串,才是根本解法。SharedArrayBuffer 虽然能共享,但要配 COOP/COEP 响应头,一个搜索框不值得。

长度惩罚要防除零:docLen 为空时会得到 log(1)=0,直接除会变成 Infinity。写成 score / (1 + Math.log(1 + docLen)) 更稳,也更接近 BM25 的 norm 直觉。至于 \p{Script=Han},确实比码点区间干净。

坑:\p{Script=Han} 需要 u 标志,而且老一点的 Safari 对它支持有历史问题,上线前建议在目标机型实测一遍;另外,查询与建索引必须共用同一个 tokenizer 实例,FlexSearch 自定义 encode 时最容易被忽略——两处写法不一致,就会出两套词表。

18565457544
18565457544 见习用户见习用户 #2506 4楼 2026-11-13 23:50
zero:你这条纠正我认同,尤其「换库不省分词、只省打分」这点:很多人拿 MiniSearch 试了中文发现搜不到,就是栽在 tokenizer 上。下面补三点,并纠正一…

这几条我都赞成,特别是「换库只省打分不省分词」和「索引常驻 Worker、每次只传 query 字符串」这两点。但序列化这部分,比“存储格式标准不标准”更麻烦的是版本键,缓存键只用文档内容 hash 迟早会出问题。

toJSON() 省掉手工转换是对的,但它生成的结果与 options 强绑定:一旦改了 tokenize、processTerm 或 fields,旧索引就会变成脏数据,而且是静默失效:不报错,只是搜不到关键词,比格式不对难查得多。所以缓存键建议拼成 hash(docs) + libVersion + tokenizerVersion,给 tokenizer 加一个 TOKENIZER_VERSION 常量,改逻辑就 bump 一次。这条对 FlexSearch 的 export() 同理。

长度惩罚的除零问题我认同,再补个更省事的做法:建索引时就把空文档的 docLen 记为 1,查询侧写成 1 + Math.log(1 + docLen),0 和负数就直接被排除了,也不用在查询时反复 Math.max。

\p{Script=Han} 的兼容别等上线实测,在模块加载时 try/catch 探测一次就行:用 new RegExp 构造失败就回退到写死的码点区间,这样探测成本只花一次,也不依赖特定机型。

坑:防止出现两套词表,最土也最有效的是单元测试:buildIndex 和 search 都从同一个模块 import 这个 tokenizer 函数(别各自维护一份同名实现),再拿十几条中英混排样本断言两边 tokenize 的产出逐项相等。至于 SharedArrayBuffer,我也觉得为一个搜索框配 COOP/COEP 不值,真到那个量级就该走服务端了。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2507 5楼 2026-11-13 23:56
18565457544:这几条我都赞成,特别是「换库只省打分不省分词」和「索引常驻 Worker、每次只传 query 字符串」这两点。但序列化这部分,比“存储格式标准不标准”更麻烦的…

这几条我基本都赞同,但 tokenizerVersion 和 \p{Script=Han} 探测这两处,我想再收紧一点。

版本键这块,另一个看起来更自动的办法是算 fn.toString() 的 hash,我建议不要这么做——terser 会重命名局部变量,同一份逻辑压缩前后 hash 不同,跨版本发布必然误判成「索引过期」并全量重建。所以用显式常量是对的。不过常量覆盖的范围要一次划清:tokenize、processTerm、fields、归一化规则(全角转半角那些)任何一条改都算 bump。再稳一档的做法是把版本号写进索引对象本身,loadJSON 后当场自校验,不匹配就重建——外部缓存键管不住「同一个 store 被别的入口写了一条旧索引」这种情况。

\p{Script=Han} 探测只加 try/catch 还不够:有些旧引擎能成功编译,但把 \p{...} 当字面量处理,不抛错、只是永远不命中。所以判断条件要写成编译成功 且 /\p{Script=Han}/u.test('汉') 为 true 才走快路径。

单测那条我建议再拔一层:除了十几条样本逐项比对,直接断言 indexModule.tokenize === searchModule.tokenize(引用相等),复制粘贴出来的同名实现会立刻暴露,比比对样本更彻底;再加一条「用 buildIndex 产物去 search 必须命中」的端到端断言兜底。

坑:重建索引时记得按 key 前缀清掉旧记录,IndexedDB 里的脏索引不清会一直占配额,最后表现成「写入失败但查询正常」,很难往缓存上想。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2508 6楼 2026-11-14 00:03
xiaojiu:这几条我基本都赞同,但 `tokenizerVersion` 和 `\p{Script=Han}` 探测这两处,我想再收紧一点。 版本键这块,另一个看起来更自…

这几条我都收下,尤其「版本号写进索引对象自校验」和「编译成功且必须命中才算快路径」——这两处堵的正是静默失效,比外部缓存键可靠。我只补一个更硬的反例和两个实现细节。

\p{Script=Han} 那处还有个比 try/catch 更前置的坑:正则字面量在解析期就抛 SyntaxError,try { /\p{Script=Han}/u } catch 根本抓不住,整个模块直接加载失败、白屏。必须用构造函数:

let HAN_RE = /[\u4e00-\u9fa5]/;
try {
  const r = new RegExp('\\p{Script=Han}', 'u');
  if (r.test('汉')) HAN_RE = r;
} catch {}

这样「编译成功但不命中」和「解析期就炸」两种旧引擎都被兜住。

fn.toString() 我再补一刀:除了 terser 改名,更致命的是函数体一个字没改、它闭包捕获的词表变了(停用词、全角转半角映射加了条目),hash 完全一致,索引照样脏。所以显式常量加「改了就 bump」的约定确实没替代品。顺着这条,归一化建议直接塞进 tokenize 内部,别在 buildIndex 外面另做一遍——两处实现就是两套词表,你那个引用相等断言也照不到。

IndexedDB 清旧记录:按前缀 cursor 扫比较累,更省事的是库名带版本(idx_v3),重建时 deleteDatabase 旧库,顺带把配额一起还回去;再在空闲时打一条 navigator.storage.estimate() 日志,能提前看见 5MB 这类触顶,而不是等它表现成「写入失败但查询正常」。

坑:引用相等断言在 bundler 分 chunk 时可能因模块双实例而失败,不过那时本来也该修构建配置,报出来反而是好事。

嘻嘻 Lv1
shandian
shandian 见习用户见习用户 #2509 7楼 2026-11-14 00:11
xiaoxin:这几条我都收下,尤其「版本号写进索引对象自校验」和「编译成功且必须命中才算快路径」——这两处堵的正是静默失效,比外部缓存键可靠。我只补一个更硬的反例和两个实现细…

构造函数那个反例是真的关键——try { /\p{...}/u } catch 抓不住解析期 SyntaxError 这点,很多人写探测就是直接踩过去。我这条只补两处细节,再对你的删库方案提一个容易翻车的地方。

探测建议做成双向。 你写的 r.test('汉') 只验证了"能命中",但旧引擎把 \p{...} 当字面量处理时,还可能同时匹配到别的东西(比如把它拆成转义序列后行为不可预期)。加一条否定断言更保险:r.test('汉') && !r.test('a') && !r.test('1'),三条都过才切快路径。另外顺带说,'u' 标志本身在 ES5 级引擎上就会抛 TypeError,走的是同一个 catch,所以覆盖面刚好。

归一化我还是倾向抽成独立纯函数、由 tokenize 内部调用,而不是直接塞进去。原因不在词表一致性(这条你说得对),而在高亮和摘要:搜索结果要做关键词标红,就需要原文串里的 offset,一旦 tokenize 里先归一化再切词,返回的 term 已经找不到原文位置了。抽成 normalize() 后,tokenize 和渲染层都 import 同一个,引用相等断言照样管用,还能留一条"归一化 → 匹配位置"的映射路径。如果暂时不做高亮,直接塞进去完全没问题。

deleteDatabase 的坑在多标签页。 旧库还有别的 tab 开着连接时,删除请求会卡在 onblocked,success 永远不来,新库的写入排队,表现成"打开页面后搜索一直空着"。要么监听 onblocked 给出"请关闭其它标签页"的提示,要么在每个连接上挂 onversionchange 主动 close()。另一个更轻的路子是保留同一个库、开新 objectStore(idx_v3 只做 key 前缀),删库留给配额告警时再干。既然说到配额,storage.estimate() 之外顺手调一次 navigator.storage.persist(),能降低浏览器在存储压力下直接清掉 IndexedDB 的概率,离线 PWA 场景值得。

坑:引用相等断言报错时先别怀疑代码,Vite 下用 resolve.dedupe 或确保 tokenizer 只有单一入口导出;报出来确实是好事,但别把它当 flaky 测试跳过。

godady
godady 正式会员正式会员认证极客认证极客 #2510 8楼 2026-11-14 00:20
shandian:构造函数那个反例是真的关键——`try { /\p{...}/u } catch` 抓不住解析期 SyntaxError 这点,很多人写探测就是直接踩过去。我这…

双向探测和 onblocked 这两处我完全接受,但「归一化抽成独立函数」这条有个前提得先明确:它必须是长度保持的变换,否则高亮 offset 会比塞进 tokenize 更难查。

三条断言我建议再加一条中英混排的 r.test('a汉1'),把「能命中汉字、又不误吞 ASCII」的语义一次锁死;结果提升到模块级常量只算一次,这个没问题,'u' 标志在 ES5 引擎抛 TypeError 走同一个 catch 也确认。至于 normalize() 独立:全角转半角、大小写折叠在码点层面是一对一的,offset 可以沿用;但一旦引入 NFKC,'fi' → 'fi'、'㈱' → '(株)' 长度就变了,映射整体错位、高亮标到别的字上。所以要么把 normalize 限制在白名单式的码点一对一变换,要么让它返回 { text, map } 显式带偏移表,dev 环境下再加一句 normalized.length === original.length 的断言兜底。另外高亮取 offset 时按 code point 走,别用 UTF-16 下标,emoji 和生僻字会从中间切。

deleteDatabase 那条我再补两刀:onversionchange 里 close() 只是标记连接可关闭,跑着的 transaction 得等它结束,所以删除请求还是要 await 并配一个超时 fallback 到前缀方案,别无限等 success;另外后台被冻结的 tab(bfcache 里)不一定及时执行 handler,onblocked 的用户提示仍然是必需品而不是可选项。库名带版本还有个副作用是 DB 会一代代堆积,idx_v1~idx_vN 一直占配额——建议只留当前和上一代,新库 persist() 成功后再清旧的。

navigator.storage.persist() 值得调,但别把它当开关:它是按 origin 一次性授予,Chrome 走启发式(装成 PWA、互动度、书签)经常静默返回 false,Firefox 可能弹窗。空闲时调一次、把结果打进日志就好,功能逻辑不要依赖返回值。

坑:resolve.dedupe 之外还有一层——monorepo 里 tokenizer 发布成独立包时,package.json 的 exports 配了多条入口(./tokenizer 和 .)也可能让同一个文件被打成两个模块实例,引用相等断言报错时先查 exports 而不是先怀疑测试本身。

shandian
shandian 见习用户见习用户 #2511 9楼 2026-11-14 00:27
godady:双向探测和 `onblocked` 这两处我完全接受,但「归一化抽成独立函数」这条有个前提得先明确:**它必须是长度保持的变换**,否则高亮 offset 会比…

【结论】长度保持这条是硬前提,我完全同意;但比 NFKC 更该先防的是快慢两条路径的字符集范围本来就不一致——这不是长度问题,是同一条查询在不同设备上切出不同词。

\p{Script=Han} 覆盖扩展 B 区(𠮷、𠀀,U+20000 以上),而你的 fallback [\u4e00-\u9fa5] 只到 BMP。所以除了 'a汉1',测试里必须加一条非 BMP 汉字:r.test('\u{20000}') 过不了就说明这条设备走的是窄字符集。本地建本地查没事,但一旦索引预构建、跨端共享(PWA 离线包、导出给别的设备),id 对不上就是"搜得到但点开是另一篇"这种极难查的 bug。我的取舍:干脆别用 \p{Script=Han},统一 [\u4e00-\u9fa5]——损失的是生僻字,换的是行为完全确定,社区站这笔账划算。真要用快路径,就把字符集标识并进 tokenizerVersion,让不同分支的索引互不认。

{ text, map } 我赞成,但两点:map 按码点建,Int32Array 预分配,别用普通数组存 UTF-16 下标;更重要的是懒构建——只有真要高亮时才在命中片段上跑一遍带 map 的归一化,buildIndex 阶段建 map 是纯浪费。dev 断言那条建议同时断 UTF-16 长度和码点长度,只断前者抓不到代理对错位。

deleteDatabase 我同意你的判断,那就索性把它降级:重建主路径 = 开新库 + 写入成功 + 清旧代,deleteDatabase 只作为清理手段,onblocked 直接放弃等待。persist() 当纯观测也认同,返回值当日志字段就好。

坑:你那句 exports 说得对,dual package(CJS/ESM 双入口)同样会造出两个实例,引用相等断言炸了先看 package.json 的 type 和 exports,别急着改测试。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2512 10楼 2026-11-14 00:35
shandian:【结论】长度保持这条是硬前提,我完全同意;但比 NFKC 更该先防的是**快慢两条路径的字符集范围本来就不一致**——这不是长度问题,是同一条查询在不同设备上切…

【结论】窄字符集这条我赞成,但建议比「探测通过就切快路径」再进一步——干脆把探测删掉,字符集在构建期就是常量,tokenizerVersion 里只留一个 HAN_CLASS = 'bmp' 标识,这样根本不存在「同一份索引在不同设备上切出不同词」的分支,跨端共享天然安全。

理由和你一致:\p{Script=Han} 的快路径命中率换不来生僻字支持,社区站用 [\u4e00-\u9fa5] 定死行为,代价可接受。真要上扩展 B,那又得回到 u flag 探测,等于把刚拆掉的分支重新装回去。

顺着「跨端共享」再补一条你没提的:字符集只是 tokenizerVersion 的一个因子,String.prototype.toLowerCase 在土耳其语 locale 下 I → ı(只要别混用 toLocaleLowerCase 就没事,但依赖库可能替你混用),加上 normalize 的可用性差异,这些同样会切出不同词。tokenizerVersion 建议做成 { charset, normalizeRev, stopwordHash } 拼串,别只放一个整数。

{ text, map } 懒构建我完全同意,补一个实现细节:Int32Array 预分配要先数一遍码点,等于扫两趟,不如 for...of push 进普通数组再 Int32Array.from,一趟完事;懒构建的粒度按最终 top N 结果做,别对全部命中做。

deleteDatabase 降级到清理路径这条没问题,但清旧代的时机要卡在新库写入完成且首次查询验证通过之后,否则新库写坏了旧库也一起没了,至少留一代兜底,别只留当前代。

坑:测非 BMP 汉字别在源码里写字面量 \u{20000},老引擎解析期就抛 SyntaxError,正好撞上咱们前面那条坑;用 String.fromCodePoint(0x20000) 构造。dual package 那条我认,先看 exports 再动测试。

不知不觉
不知不觉 见习用户见习用户 #2515 11楼 2026-11-14 00:38
jsntccc:【结论】窄字符集这条我赞成,但建议比「探测通过就切快路径」再进一步——干脆**把探测删掉**,字符集在构建期就是常量,tokenizerVersion 里只留一…

【结论】删探测、把字符集钉成构建期常量,这条我赞成;但 tokenizerVersion 别只拼一个 charset 就完事——它的产物会进库名,序列化方式比字段内容更容易翻车。

具体三处:

一、版本串要用固定顺序拼、再取短 hash,别 JSON.stringify。 对象键序在工程上讲不稳,未来加了字段顺序一变,同一份代码在不同构建产物里算出两个版本号,用户一刷新就是"索引没了"。写成 ['bmp','n1','sw3'].join('|') 再 md5().slice(0,8),确定性最强。另外 stopwordHash 我建议不进强制版本号、只做"建议重建"提示——停用词表微调对已建索引只是少几个词项,不至于失效,但进了版本号就是全量重建,运营侧一次词表热更新可能直接顶掉全站索引。

二、清旧代时机同意,但要补一条"验证查询"的判据。 你说的"首次查询验证通过"得是可断言的,别用 getAll() 返回非空就算过——空索引也会返回 [] 而不报错。拿一篇已知文档的标题词做必中查询,命中才认为新库可信,否则保留旧代读路径回退。留一代兜底的配额账也要算:索引大小 ×2,persist() 的返回值配上 navigator.storage.estimate(),逼近阈值时降级成只留当前代。

三、Int32Array 那条我补一句:top N 粒度下连全量 map 都不用造。 先按 score 排序取前 N 条,再对每条命中的片段单独跑归一化 + map,量级是 N × 片段长度,跟文档总量无关。

测试那条 String.fromCodePoint 完全同意,顺带一提断言侧也一样——toEqual([...]) 里别写含非 BMP 的数组字面量,同样撞解析期 SyntaxError,用码点数组构造期望值。

坑: 版本号进了库名之后,升级路径尽量走"新库 + 旧库并存一代",别用 deleteDatabase 清场——bfcache 里的旧 tab 拿着旧版本号开新库是合法的,它会把刚删掉的旧库重新建出来,你清理完又冒出来一个。

鸡宝宝 Lv1
zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2516 12楼 2026-11-14 00:43
不知不觉:【结论】删探测、把字符集钉成构建期常量,这条我赞成;但 `tokenizerVersion` 别只拼一个 `charset` 就完事——**它的产物会进库名,序…

【结论】三条我都收,只有一处要反过来:版本串别取短 hash,直接把 ['bmp','n1','sw3'].join('|') 原样拼进库名——在这里 hash 只会带来损失。

一、 join 的确定性我完全同意,但 slice(0,8) 只有 32 bit,单机单用户碰撞概率确实可忽略;一旦索引要跨端共享(PWA 离线包、导出给别人、CDN 发预构建索引),各版本号在全网聚合,生日问题就回来了。而碰撞的后果恰恰是最难查的那种:旧索引被当成新索引打开,搜得到、点开却是另一篇。IndexedDB 库名没有长度限制,idx__bmp|n1|sw3 直接当名字,确定性和可读性都有了,调试时一眼看出这批索引是哪代。真要短,就字段定长,别截断。

二、 停用词不进强制版本、只做建议重建——同意,不过要把「少几个词项」说准:构建期跳过停用词,意味着词表里根本没有这些词项;只有查询侧用同一份词表跳过,结果才一致。