Pagefind 静态站站内搜索实战:分片索引、按需加载、前端接入与「搜不到新文章」排查全流程

静态博客为什么需要「站内搜索」,又为什么大多数方案都不合适

用 Hugo、Hexo、Astro 这类静态站点生成器建站的人,早晚会撞上同一个问题:没有站内搜索。静态站本质是一堆 HTML 文件,没有数据库、没有后端,搜索这个在动态站里理所当然的功能,到了静态站就成了需要专门设计的东西。而读者一旦习惯了搜索,找不到搜索框就会直接用 site:yourdomain.com 关键词 去 Google,你就白白丢掉了宝贵的页面停留和站内遍历。

常见的几种「土办法」各有各的痛:

  • 直接上 Algolia / Meilisearch 云服务:Algolia 免费额度小、超出按量计费,对一个个人博客来说是持续支出;Meilisearch 自建要跑一个常驻服务,又多一台组件要维护,而且它是运行时查询后端——每敲一个字都要请求服务器,网络一抖体验就断。
  • 用 Google 的自定义搜索(CSE):需要挂 Google 的脚本、有广告、加载慢,且在国内基本不可用,对中文站是致命的。
  • 自己写一个 JSON 索引 + 前端全文匹配:思路对,但当文章到几百篇,索引文件动辄几 MB,用户打开页面就得下载整个索引,首屏和搜索都慢。

Pagefind 正是为「静态站需要站内搜索」这个精确场景而生的。它是一个在构建之后运行的索引器:扫描你生成的 HTML 目录,产出一个分片的、按需加载的静态索引,再配一个极小的前端 JS 在浏览器里完成搜索。没有后端、没有外部服务、没有月度费用,而且首屏只需要下载几十 KB 的元数据,用户开始输入时才按需拉取相关分片。

Pagefind 的核心设计:分片索引 + 按需加载

理解 Pagefind 为什么快,关键在它的索引结构。传统「一个 JSON 装所有文章」的搜索,成本是线性的:N 篇文章,用户就得下载 N 篇的索引,浪费 99% 的流量在用户根本不会搜到的内容上。Pagefind 的做法是:

  • 构建时把索引切成许多小分片(chunk),并把「词 → 分片」的映射压成一个紧凑的元数据文件(通常几十 KB)。
  • 浏览器加载页面时只下载这个小元数据;用户输入关键词时,前端查表得知「哪些分片可能包含这个词」,只拉取那几个分片做实际匹配。
  • 索引基于 WASM 在浏览器里运行,支持词干化(stemming)和中文分词,不依赖任何服务器。

效果就是:一个拥有几百篇长文的博客,搜索引擎本体可能几十 MB,但任何一次搜索只会拉取几十到几百 KB。这个「大规模内容 + 零服务器成本 + 秒开」的组合,是它相对云搜索服务的根本优势。

安装与索引:构建产物目录上跑一条命令

Pagefind 是 Node 包,通过 npx 直接用,不需要写配置文件:

# 在站点构建完成之后运行(例如 Hugo 的 public/ 目录)
npx pagefind --site public

# 指定输出子目录(默认 pagefind/)
npx pagefind --site public --output-subdir pagefind

# 只索引特定语言,跳过草稿
npx pagefind --site public --glob "**/*.html"

运行后,public/pagefind/ 下会多出一堆文件:pagefind.js、pagefind-ui.js、wasm.*、一系列 *.pf_fragment 分片和 *.pf_meta 元数据。关键点是它必须在你每次构建之后重新运行,否则新文章搜不到、旧文章索引还留着。把它接进构建流程:

# 一个典型的构建脚本 build.sh
#!/usr/bin/env bash
set -euo pipefail
hugo --minify                    # 1. 生成静态站
npx pagefind --site public       # 2. 基于产物建立索引
# 3. 可选:清理 Pagefind 自带的 UI 演示文件,减小体积
rm -f public/pagefind/pagefind-ui* 2>/dev/null || true

对 Hugo 用户,最干净的做法是把它做成一个 npm script 或用 Git 钩子,别手工记得跑。忘跑索引 = 搜索不到最新文章,这是最常见的「明明发了文却搜不到」的原因。

前端接入:两种方式,十分钟搞定

Pagefind 提供两种接入途径。方式一是它自带的 UI 组件(pagefind-ui.js),几行 HTML 就能得到一个带样式的搜索框;方式二是用它的 JS API 自己写结果渲染,适合要和站点主题深度结合的场合。

先用最简单的自带 UI 验证整条链路。在你模板的搜索页面里放一个容器:

<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({
      element: "#search",
      showSubResults: true,
      translations: {
        placeholder: "搜索文章…",
        zero_results: "没有找到「[SEARCH_TERM]」相关的内容"
      }
    });
  });
</script>

打开页面,输入关键词,如果能看到带高亮摘要的结果,说明索引 + 前端都通了。如果要和主题视觉统一,改用 API:

<script type="module">
  // 动态引入,避免所有页面都加载搜索代码
  const pagefind = await import("/pagefind/pagefind.js");
  await pagefind.options({ excerptLength: 30 });

  const results = await pagefind.search("Nginx 缓存");
  for (const r of results.results.slice(0, 8)) {
    const data = await r.data();
    // data.meta.title / data.excerpt / data.url
    console.log(data.meta.title, data.url);
  }
</script>

注意 pagefind.js 必须用 <script type="module"> 或动态 import 引入,它是 ES Module,用传统 script 标签会报错——这是新手最常见的接入失败点。另外 r.data() 是异步的、按需拉取分片,所以不要在循环外一次性 await Promise.all 全量数据,只取你要展示的前几条即可,这正是「按需加载」的用法。

Nginx 配置:让分片被正确缓存,又不缓存错版本

Pagefind 的分片是内容寻址的(文件名带哈希),天然适合长缓存。但 pagefind.js 和元数据文件可能随每次构建变化,需要短缓存或不缓存,否则用户拿到旧索引查不到新文章。Nginx 里这样区分:

# 分片和 WASM:文件名带哈希,可以长缓存
location ~ ^/pagefind/.*\.(pf_fragment|pf_meta|wasm)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
}

# JS 和 CSS:短缓存,保证索引更新后能拿到新版
location ~ ^/pagefind/.*\.(js|css)$ {
    expires 1h;
    add_header Cache-Control "public";
}

还有一个容易被忽略的性能点:搜索结果用到的分片是前端按需请求的,请确认它们能被 gzip / Brotli 压缩。.pf_meta 这类文件压缩后能小一大截。如果你在 Nginx 里按扩展名配 gzip,记得把 pf_meta pf_fragment 加进 gzip_types,否则默认只有 text/* 会被压缩,二进制分片压缩不到。

gzip on;
gzip_types text/plain text/css application/javascript
           application/json application/wasm
           application/octet-stream;

中文搜索的坑:分词与高亮

Pagefind 对中文的支持在持续改进,但对中文站来说有两个必须自己确认的点:(1)分词:英文靠空格天然分词,中文没有空格,Pagefind 会做 n-gram 式切分,你不必额外配分词器,但要实际测试——输入一个中文词,看能否命中包含该词的页面。(2)高亮摘要:结果里的摘要应该把命中词标出来,showSubResults 打开后摘要会更细。

如果发现中文搜不到,先排查是不是索引文件本身没更新(重跑 npx pagefind --site public),再排查页面是不是被 data-pagefind-ignore 排除掉了。Pagefind 默认索引 <body> 内容,但你可以用属性精细控制:

<!-- 排除导航、页脚、广告等噪音,只索引正文 -->
<nav data-pagefind-ignore>...</nav>
<div data-pagefind-body>
  <h1 data-pagefind-meta="title">文章标题</h1>
  ...正文...
</div>
<footer data-pagefind-ignore>...</footer>

data-pagefind-body 告诉它「只索引这块」,data-pagefind-ignore 则排除具体元素。对中文站尤其重要:不排除导航和页脚的话,每个页面的搜索结果摘要里都会混进「首页 关于 标签 订阅」这类噪声,既影响相关性又难看。另外 data-pagefind-meta 可以把标题、日期、分类等元信息挂到结果里,前端渲染时能直接显示「标题 + 日期 + 摘要」,体验更接近专业搜索。

替代方案对比:Pagefind vs Meilisearch vs 纯前端 JSON

把三个方案摆在一起看,取舍就很清楚了:

  • 纯前端 JSON 索引:实现最简单,但索引随文章数线性增长,几百篇就是几 MB,用户打开搜索页要等整个索引下载完,首屏和搜索都慢,且没有词干化、没有相关度排序。只适合 50 篇以内的小站。
  • Meilisearch / Typesense 自建:搜索质量最高(真分词、拼写容错、输入即出),但代价是必须常驻一个后端服务——多占内存、多一个要升级要备份要防挂的组件,而且每次搜索都要网络往返。适合「搜索是核心功能」的大型内容站。
  • Pagefind:零后端、零月费、零网络往返、按需加载、打开即用。搜索质量不如专业引擎(没有拼写纠错和复杂相关度调优),但对「几百篇的个人博客」这个场景,它提供的「够好 + 零成本 + 秒开」是性价比最高的平衡点。

对个人站长而言,选型的判断标准很简单:如果你的站是静态生成、文章量在几千篇以内、追求零服务器成本和简单运维,Pagefind 几乎是默认答案;只有当站内搜索成为核心竞争力、且你愿意长期维护一个搜索后端时,才值得上 Meilisearch 这类方案。

构建集成与 CI:别让「忘了跑索引」毁掉搜索

Pagefind 最容易出错的地方不在配置,而在流程:它是一个构建后步骤,一旦某次部署忘了执行,站上的搜索就停在上一次的索引——新文章搜不到,删掉的文章还能搜到。把它固化进 CI / 部署脚本是唯一可靠的办法:

# GitHub Actions 片段
- name: Build site
  run: hugo --minify
- name: Build search index
  run: npx pagefind --site public
- name: Deploy
  run: rsync -az --delete public/ deploy@server:/var/www/blog/

如果是本地构建 + rsync 的传统方式,把索引命令写进 Makefile 或 deploy.sh,并加一行校验:部署前确认 public/pagefind/ 里存在当天新生成的分片,否则中止部署并报错。这样「忘了跑索引」就从「线上悄悄坏掉」变成一个明显的构建失败,能在发布前拦住。

还有一个发布后的验证习惯:部署完成后随机搜一个刚发布的文章标题里的词,确认能命中。这个动作只要十秒,却能立刻发现索引未更新——而如果只靠肉眼浏览页面,这个问题可能几周都发现不了,因为搜索框本身看起来一切正常。

小结

静态站的站内搜索一直是个两难:上云搜索要花钱、自建后端要维护、纯前端 JSON 又撑不住规模。Pagefind 用「构建时分片索引 + 浏览器 WASM 按需加载」这套设计,刚好卡在性价比最优的位置——零后端、零月费、首屏几十 KB、搜索时只拉相关分片。落地上真正要盯住的是四件事:把索引命令固化进构建流程(别忘跑)、前端用 ESM 引入 pagefind.js(别用传统 script 标签)、Nginx 里对分片长缓存对 JS 短缓存、以及给中文站排除导航页脚的噪声。做到这四点,你的静态博客就拥有了一个不花钱、不依赖任何外部服务、打开即用的站内搜索。

Last modification:October 15th, 2026 at 02:12 pm

Leave a Comment