纯静态站的全文搜索:构建期索引,运行时零后端
静态站做搜索不一定非得接 Algolia。把索引放到构建期生成,搜索完全跑在浏览器里,代价只有构建慢几秒。
静态站最容易卡住的一个需求是搜索:站里没有服务端,查询谁来算? 我的答案是别算——把索引在构建时生成好,查询交给浏览器。用的工具是 Pagefind,一行命令,零后端。
为什么不接一个搜索服务
先说被排除的选项。
| 方案 | 代价 |
|---|---|
| Algolia / DocSearch 这类托管搜索 | 要账号、要配额、要往页面里塞第三方脚本;访客的每次输入都经过别人的服务器 |
| 自己写一份 JSON 索引,前端过滤 | 每个访客都要下载全部正文。十篇文章没事,一百篇就是几 MB |
| 不提供搜索 | 那这个站只能靠导航找东西 |
前两条的共同问题是把成本推给了访客:一条推给第三方请求,一条推给下行流量。而这个站的正文在构建时就已经是静态文件了,索引没有任何理由不在同一个时刻生成。
Pagefind 的位置
它在整条流水线里的位置很靠后:
npm run build
# = astro build && pagefind --site dist
先让 Astro 把 HTML 全部产出,Pagefind 再扫一遍 dist/,把这些切碎、压缩、分片,写进 dist/pagefind/。之后浏览器加载一个很小的运行时,按需取它真正要的那几个分片。
所以它不是“搜索服务”,而是构建产物的一部分。上线时和 HTML、CSS、图片一起传上去就完事了。
构建日志最后一行是这样的:
Indexed 3 languages
Indexed 94 pages
Indexed 2883 words
Indexed 3 filters
索引什么、不索引什么
默认是把页面里所有可读文本都吃掉,但那通常不是你要的 —— 页头、页脚、语言切换这些在每个页面上重复出现,索引进去只会让结果集里全是“首页”“博客”这种噪音。
两个属性就够用了:
data-pagefind-body:标了这个属性,索引就只在这个范围内取,页头页脚自动排除。data-pagefind-filter="lang:zh":给页面打标签,搜索时可以按它做筛选。
这个站上,两个布局的 <main> 各带一次这两个属性,于是:
<main id="main" data-pagefind-body data-pagefind-filter="lang:zh">
那行 Indexed 3 filters 指的就是三套语言筛选。
搜索的质量取决于你没索引什么,跟索引了什么一样重要。
四个必须记住的坑
① 它在 npm run dev 下不存在。 索引是构建产物,dev 只跑 Astro。dev 环境里搜不到东西是预期行为,不是 bug;要验搜索必须 build 之后 preview。我一开始以为搜索坏了,查了半天。
② 顺序不能反。 Pagefind 读的是 dist/,所以它必须在 Astro 构建之后跑(&& 不能写错),而且它只认最终产出的 HTML —— 源码里搜不到的标记,产物里可能已经被内联进去了。
③ 靠 JavaScript 渲染的页面几乎索引不到。 Pagefind 读的是 HTML 文件。游戏和工具页的正文是客户端挂载的,能进索引的只有静态外壳。如果要让它们可搜,得把关键文本在构建期就渲染进 HTML —— 这也是我把应用页做成「静态空壳 + 内容也静态」的原因之一。
④ 别用 grep 去产物里找标记。 Astro 会把小段的样式和脚本内联进 HTML,压缩之后的产物跟你写的源码不是一回事。验证搜索只能靠真跑一次 build + preview。
什么时候它不够用
- 需要跨语言统一检索(一个查询同时出中英日结果):Pagefind 是每语言一套索引,得自己做合并。
- 需要词干/同义词/错拼容忍:它是纯前缀匹配,不做语义。
- 需要实时索引用户内容:那本来就不是静态站该干的事。
三条我都不需要。这个站的正文是几十篇不会天天变的手写文章 —— 这个规模下,“构建时把索引做好”是唯一合理的形态。
如果一个查询能不经过服务器就算出来,那它就不该经过服务器。

评论
…