我给静态站做了个不用后端的搜索
静态站点有个绕不开的短板:没有后端,搜索怎么办。
内容少的时候这不是问题,翻归档就行。到几十篇之后,读者找不到东西,就只能靠外部搜索引擎——而外部搜索引擎未必收录得及时,收录了也未必排在前面。
三条路
一、接第三方搜索服务。 功能最全,能拼写纠错、能同义词、能统计。代价是引入外部依赖:多一个域名请求(国内可用性要实际测)、多一份数据交给第三方、多一个可能涨价或停服的东西。对一个内容站来说,这个代价经常不划算。
二、跳出去搜。 搜索框直接跳转到搜索引擎的结果页,限定站点。省事,但读者被带离了站点,体验割裂。
三、自建索引。 构建的时候把所有内容抽成一份 JSON,前端下载后在浏览器里搜。没有任何外部依赖,代价是索引体积和检索质量要自己控制。
我选的是第三条。
自建方案的拆解
第一步:构建期生成索引。
把每篇文章的标题、正文纯文本、分类、标签抽出来,写成一个 JSON 文件。正文要剥掉 HTML 标签,但保留一些结构信息(比如章节标题也可以在检索里加权)。
这一步是在构建时做的,所以索引和内容永远一致——不会出现”文章改了但搜索结果是旧的”这种事。
第二步:控制体积。
这是最需要提前想清楚的地方。全文索引的体积跟内容量成正比:
- 几十篇的规模,索引大概几十 KB,压缩之后更小,直接加载没有压力
- 到了几百篇,索引可能到几百 KB 甚至更多,就得考虑按需加载或者分片
我的做法是让索引文件独立于首屏——只在用户点击搜索框的时候才加载。这样即使索引有三四百 KB,也不会拖慢正常浏览。
第三步:前端检索。
这一步的难点几乎全在中文上。
英文按空格切词就够了,中文没有空格:如果按词切,就要引入分词库(体积和词表都是负担);如果按字符处理,就得解决”相邻两字算不算匹配”的问题。我用的是最简单的方案——子串匹配。中文里这个方案效果出奇地好,因为中文的词普遍不长,用户输入的部分关键词往往就是连续子串。
代价是排序。子串匹配能告诉你”命中了”,但不擅长告诉你”哪个更相关”。我的排序规则很土:标题命中权重最高,然后是标签和分类,最后是正文;同一层级里,命中位置越靠前分数越高。
第四步:结果展示。
命中之后的处理有两件事:给出上下文片段(而不是让读者自己回到文章里找),以及高亮关键词。这两件都不难,但缺了会很影响体验——搜出一篇文章然后让读者自己在三千字里找,等于没搜。
索引里放什么、不放什么
索引不是把整篇文章塞进去就完事。放什么,直接决定搜索质量和文件体积。
要放的:标题、正文纯文本、分类、标签,以及章节标题。章节标题的命中往往比正文更有价值——它代表这篇文章的核心议题。
可以不放的:代码块里的完整内容(体积大、命中噪声多)、页脚和导航这类模板文本、以及附件文件名。
要小心处理的:代码、公式、特殊符号。剥 HTML 标签的时候这些内容很容易被一起剥掉或者转义出错,结果是明明该命中却搜不到。
一个实用的小技巧:把每篇文章的摘要单独存一个字段并加权。摘要是作者自己写的概括,用词通常比正文更接近读者会搜的词。
性能
索引加载完之后,检索本身很快:几十 KB 的文本做子串匹配,单次查询在毫秒级,不需要任何索引结构优化。真正要注意的是别在每次按键时都跑一遍全量匹配——加个防抖,等输入停下来再算。
我踩过的几个坑
转义。 把正文塞进 JSON 的时候,引号和换行要处理干净,否则前端解析直接失败。
剥标签把代码块也剥掉了。 代码里的内容被当成 HTML 标签处理,导致搜索搜不到代码里的关键词。后来改成先提取文本节点再拼接。
索引里的链接要是绝对路径。 相对路径在搜索结果是页面的情况下会解析错,尤其是文章带层级的时候。
空搜索的处理。 输入框里只有一个字符时,如果直接放行,结果会有几百条。加个最小长度限制,体验好很多。
索引文件的缓存。 索引文件名如果固定,浏览器和 CDN 可能一直用旧的,读者搜到的还是改之前的内容。我的做法是在文件名里带上构建时间戳或内容哈希,每次发布就是一个新地址。
什么时候该换方案
我给自己定的换方案条件是三条,满足任意一条就换:
- 内容规模上千篇,索引体积到了必须分片的程度
- 需要同义词、拼写纠错这类语义能力(子串匹配做不到)
- 需要搜索统计,看读者到底在找什么
在这三条出现之前,自建索引的性价比很难被击败——它没有外部依赖、没有额外费用、没有隐私顾虑,而且构建的时候顺手就生成了,几乎不需要维护。