4.3 站内搜索:静态站也能有搜索
站上线两个月,内容从五页涨到三十多页:产品介绍、定价说明、常见问题、更新日志、团队故事。问题随之而来:用户问我们某个功能到底在哪一页,我们自己有时也得翻一会。创业页内容一多,没有搜索就等于没有导航。这一篇讲我们怎么在不买搜索服务的前提下,给静态站做出一个够用的站内搜索。
场景:Ctrl+F 救不了移动端
PC 用户还能用 Ctrl+F 单页找关键词,移动端连这个选项都没有。而且用户要搜的是全站内容,不是当前这一页。需求很清楚:一个搜索框,输入关键词,列出相关页面和摘要,点击直接跳过去,结果要快、要准、要能离线用。要求只有两条——不要服务器,不要额外花钱。
静态站的搜索套路其实很成熟:构建时生成一份全站内容的索引文件,前端加载索引后在浏览器里完成搜索。搜索库我们选 Fuse.js,轻量、支持模糊匹配、对中文也算友好。内容多到上万篇才需要考虑服务端方案,我们远没到那个量级。先做方案对比:
| 搜索方案 | 索引位置 | 体验 | 成本 | 适用规模 |
|---|---|---|---|---|
| 浏览器内 Fuse.js | 本地 JSON | 毫秒级,离线可用 | 零 | 中小型内容站 |
| 服务端搜索服务 | 云端索引 | 功能全,高亮分页都有 | 按量付费 | 大型站点 |
| 搜索引擎站点搜索 | 第三方 | 要跳走,带广告 | 免费起步 | 凑合用 |
选 Fuse.js 的理由很直白:索引在构建时生成,运行时零成本,用户搜索不产生任何网络请求,这在弱网环境下体验优势非常明显。搜索引擎站点搜索那一行也值得一提:最省事,把站交给搜索引擎现成的搜索能力,但用户点结果会跳到站外,带广告且没法定制样式,对创业页的品牌形象是减分项。综合下来 Fuse.js 是这个体量下的最优解,等哪天索引涨到上 MB、搜索响应慢到可感知,再换方案也来得及。
方案:构建时生成索引
索引生成的逻辑放在构建脚本里,和打 HTML 同一步完成。思路是遍历内容目录,读出每篇的标题、正文和链接,拼接成索引数组,写成 search-index.json 放到输出目录。核心脚本如下:
// scripts/build-search.ts:生成站内搜索索引
import fg from "fast-glob";
import fs from "fs-extra";
interface SearchItem {
title: string;
path: string;
content: string;
}
async function buildIndex() {
const files = await fg("content/**/*.md");
const items: SearchItem[] = [];
for (const file of files) {
const raw = await fs.readFile(file, "utf-8");
// 去掉 Markdown 标记,只留正文
const text = raw
.replace(/[#>*_`-]/g, " ")
.replace(/\s+/g, " ")
.trim();
const title = raw.match(/^#\s+(.+)$/m)?.[1] || file;
const url = "/" + file.replace(/^content/, "").replace(/\.md$/, "");
items.push({ title, path: url, content: text.slice(0, 2000) });
}
await fs.writeJSON("dist/search-index.json", items);
console.log(`搜索索引已生成,共 ${items.length} 条`);
}
buildIndex();
每条索引只截正文前 2000 字。这是踩坑之后改的:一开始存全文,索引文件膨胀到 1.2MB,移动网络下首屏要多拉一秒。摘要够定位就行,搜索结果里人看的是标题和高亮片段,不是全文。这一点在图片处理那篇也有同样的教训:体积是体验的一部分。
前端部分更简单。构建产物里引入 Fuse.js,加载索引,输入时查询,把结果渲染成列表。无论 React 还是 Vue都是几十行的事,这里只列核心调用:
import Fuse from "fuse.js";
const res = await fetch("/search-index.json").then((r) => r.json());
const fuse = new Fuse(res, {
keys: ["title", "content"],
includeScore: true,
threshold: 0.4, // 阈值越大匹配越松,中文建议略微调松
});
// 用户输入时调用
const hits = fuse.search("定价").slice(0, 10);
这里有个交互细节值得多说一句:索引不要在页面一打开就加载。等用户第一次聚焦搜索框再懒加载,首屏体积完全不受影响。对内容站来说,九成用户根本不用搜索,没必要让所有人陪着下载索引。
搜索框的 UI 也有讲究。我们第一版做成点击按钮跳到独立搜索页,结果搜索使用率极低,几乎没人点。第二版改成顶部的悬浮搜索框,输入时直接下拉出结果列表,选中即跳转。这个改动让搜索量翻了几倍——功能一样,路径少了两次点击,用户的耐心比我们想象的更稀缺。搜索结果里同时展示标题和正文摘要片段,命中关键词高亮标出,让人一眼判断这条结果是不是自己要的。
还有一处工程细节:索引里每条记录我们多存了一个字段记录页面权重,比如产品介绍和定价页权重高,更新日志权重低,同等相关度时优先展示核心页面。这个权重表维护在构建脚本的配置文件里,新人加页面时顺手填一行,属于一次配置长期受益的小设计。
最后说一下索引的更新策略。全量重建最省心,每次构建都扫一遍内容目录重新生成,三十页内容整个流程不到一秒,_CI 每次部署自动跑一遍,线上索引和内容永远同步。如果将来内容涨到上千篇,再考虑按内容哈希做增量更新:比对文件的哈希,只把变化的页面重新提取。这是典型的用复杂度换时间,规模没到之前不要提前优化,全量重建的简单代码能省掉一堆状态维护的麻烦。
搜索结果的高亮也有现成轮子:Fuse.js 返回的每条结果里带着匹配项的位置区间,前端按区间把关键词包一层高亮样式即可,不用自己写正则切分。注意高亮只标第一个匹配项还是全部,我们选了全部,用户扫一眼摘要就能判断值不值得点进去。
踩坑与排查
第一个坑是中文分词。Fuse.js 做的是模糊匹配而不是分词,搜创业两个字能命中创业页,但搜启动可能命不中,因为索引里没有词的概念。缓解办法是把标题和正文拆成字段分别配权重,标题命中排在前面,正文只做补充匹配。彻底解决要上分词库,对三十页的站没必要,什么时候值得上更重的方案,我们记录在腾讯云实践合集里。
同样配置下中文站的索引体积会比英文站大一些,因为没法靠空格分词,只能整段存正文,这也是要做字段截断的原因之一。从三十页到三百页,这套方案都能跑,真正会逼你换方案的信号是索引体积超过 2MB 或者搜索结果开始明显卡顿。
第二个坑是索引过期。索引是构建时生成的,如果线上搜索结果指向旧链接,多半是部署后旧索引被 CDN 缓存住了。解决办法和上一篇的刷新是同一个套路:索引文件按内容哈希命名,HTML 里引用新文件名,缓存策略交给 CDN 规则统一处理。我们的做法是 dist 目录下的产物一视同仁,搜索索引也算是构建流水线里的一等公民,而不是事后补的手工文件。
第三个坑是结果排序。用户期望最相关的排前面,Fuse 按相似度打分返回,有时标题完全不匹配但正文里大量出现某个词,反而排第一。我们的调法是标题匹配权重给到 0.7、正文给 0.3,再过滤掉得分过低的结果。阈值要对着自己的语料调,没有万能参数,只能看搜索日志慢慢收敛。
第四个坑是空结果。展示一句没有找到相关内容并附上几个常用词条推荐,比甩一个空白页体验好太多,这在移动端尤其明显。这是个零成本的功能,但用户感知差别很大,我们的留言反馈里专门有人提到这一点。空结果时还可以顺手记录一下搜了什么词,这些词就是真实需求清单:经常被搜却没有对应页面的内容,就是下一篇该写的选题。这个搜索框反过来成了我们的需求收集器,是做这个功能之前没想到的意外收获。搜索日志比任何用户访谈都诚实,用户搜什么就缺什么。
小结
静态站做搜索的核心思路是空间换时间:构建时把全站内容压成一份索引,运行时全部在浏览器里完成。看似神奇的体验背后全是朴素的工程取舍。最终索引 80KB,搜三十页内容稳定在 10 毫秒内,零服务器、零费用。功能虽然小,但它把一个展示式的宣传站往产品方向推了一把:用户能搜,意味着站点开始以用户为中心组织内容,而不只是按老板喜好排页面。下一篇讲 SEO,让站不仅对用户可发现,对搜索引擎也一样友好。
延伸阅读
- Node.js 网站托管实战目录:全书章节导航
- 腾讯云实践合集:更多构建与部署技巧
- 轻量服务器建站指南:服务端搜索方案的对照参考
