小型站点可以从客户端过滤起步,但文章数量和正文体积增长后,构建时索引通常更稳妥。本文用 Astro 接入 Pagefind,实现 ⌘K 搜索弹窗,并处理中文页面、摘要安全与开发环境没有索引的问题。

先判断:客户端过滤还是构建时索引

个人站最简单的搜索,是在构建时输出一份文章 JSON,浏览器加载后用 includes 过滤标题、标签和摘要。这种方案依赖少、容易定制,对几十篇文章完全够用。

问题在于,若要搜索正文,就必须把更多内容传到客户端。文章越多,首次下载、JSON 解析和内存占用越明显;中文搜索还会遇到分词、相关度和高亮等需求,自己实现的维护成本会逐渐上升。

Pagefind 采用另一条路径:站点先生成静态 HTML,再由 Pagefind 扫描输出目录,建立可按需加载的静态索引。搜索仍在浏览器完成,不需要单独部署搜索服务。

方案优点需要注意
客户端过滤 JSON实现直接、规则完全可控正文数据可能过大,排序与高亮要自行处理
Pagefind 构建时索引按需加载索引,提供相关度、摘要和高亮构建链多一步,开发模式默认没有索引
服务端搜索能做复杂查询、权限和实时更新需要数据库、接口及持续运维

如果站点只搜标题,客户端过滤不必急着替换;如果希望搜索文章正文,同时保持纯静态部署,Pagefind 更合适。

在 Astro 构建后生成索引

Pagefind 必须读取已经生成的 HTML,因此执行顺序是 astro build 在前,pagefind 在后。现有 Astro 项目可以安装命令行包:

npm install -D pagefind

然后调整 package.json

{
  "scripts": {
    "dev": "astro dev",
    "build": "astro build && pagefind --site dist",
    "preview": "astro preview"
  },
  "devDependencies": {
    "astro": "latest",
    "pagefind": "latest"
  }
}

执行 npm run build 后,索引和浏览器端模块会写入 dist/pagefind。随后运行 npm run preview,就能在接近生产环境的静态产物上验证搜索。

建议明确标出索引区域,避免导航、页脚和弹窗文字进入结果。下面是一个可直接使用的 Astro 布局:

---
import SearchDialog from "../components/SearchDialog.astro";

const { title } = Astro.props;
---

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
  </head>
  <body>
    <header data-pagefind-ignore>
      <a href="/">我的博客</a>
    </header>

    <main data-pagefind-body>
      <h1 data-pagefind-meta="title">{title}</h1>
      <slot />
    </main>

    <SearchDialog />
  </body>
</html>

data-pagefind-body 表示页面中应被索引的主体,data-pagefind-ignore 用于排除重复出现的界面内容。标题通过 data-pagefind-meta="title" 写入结果元数据,弹窗就不必从正文中猜标题。

实现支持 ⌘K 的搜索弹窗

新建 src/components/SearchDialog.astro。组件使用原生 dialog,同时支持 macOS 的 ⌘K 和 Windows、Linux 常用的 Ctrl+K

<button id="search-open" type="button">搜索 <kbd>⌘K</kbd></button>

<dialog id="search-dialog">
  <form method="dialog">
    <button aria-label="关闭搜索">关闭</button>
  </form>
  <label for="search-input">搜索文章</label>
  <input id="search-input" type="search" autocomplete="off" />
  <p id="search-status" aria-live="polite"></p>
  <ol id="search-results"></ol>
</dialog>

<script>
  const dialog = document.querySelector("#search-dialog");
  const openButton = document.querySelector("#search-open");
  const input = document.querySelector("#search-input");
  const status = document.querySelector("#search-status");
  const list = document.querySelector("#search-results");

  let apiPromise;
  let requestId = 0;

  function getPagefind() {
    apiPromise ??= import(/* @vite-ignore */ "/pagefind/pagefind.js")
      .then(async (pagefind) => {
        await pagefind.init();
        return pagefind;
      });
    return apiPromise;
  }

  function renderExcerpt(container, html) {
    const parsed = new DOMParser().parseFromString(html, "text/html");
    container.replaceChildren();

    function copy(node, parent) {
      if (node.nodeType === Node.TEXT_NODE) {
        parent.append(document.createTextNode(node.textContent ?? ""));
        return;
      }
      if (!(node instanceof HTMLElement)) return;

      if (node.tagName === "MARK") {
        const mark = document.createElement("mark");
        parent.append(mark);
        node.childNodes.forEach((child) => copy(child, mark));
      } else {
        node.childNodes.forEach((child) => copy(child, parent));
      }
    }

    parsed.body.childNodes.forEach((node) => copy(node, container));
  }

  async function runSearch() {
    const query = input.value.trim();
    const currentRequest = ++requestId;
    list.replaceChildren();

    if (!query) {
      status.textContent = "输入关键词开始搜索";
      return;
    }

    status.textContent = "正在搜索…";

    try {
      const pagefind = await getPagefind();
      const search = await pagefind.search(query);
      const items = await Promise.all(
        search.results.slice(0, 10).map((result) => result.data())
      );

      if (currentRequest !== requestId) return;
      status.textContent = items.length ? `找到 ${items.length} 条结果` : "没有结果";

      for (const item of items) {
        const url = new URL(item.url, location.origin);
        if (url.origin !== location.origin) continue;

        const li = document.createElement("li");
        const link = document.createElement("a");
        const excerpt = document.createElement("p");

        link.href = url.pathname + url.search + url.hash;
        link.textContent = item.meta.title || item.url;
        renderExcerpt(excerpt, item.excerpt);
        li.append(link, excerpt);
        list.append(li);
      }
    } catch (error) {
      console.error(error);
      status.textContent = import.meta.env.DEV
        ? "开发模式尚未生成索引,请构建后使用 preview 验证"
        : "搜索加载失败,请稍后重试";
    }
  }

  openButton.addEventListener("click", () => {
    dialog.showModal();
    input.focus();
  });

  document.addEventListener("keydown", (event) => {
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
      event.preventDefault();
      if (!dialog.open) dialog.showModal();
      input.focus();
    }
  });

  input.addEventListener("input", runSearch);
</script>

这里用 requestId 避免较早发出的搜索覆盖新结果。实际项目还可以增加 100 至 200 毫秒防抖,但是否需要应根据输入体验判断,不必预先复杂化。

中文、摘要安全与环境差异

中文页面首先要正确设置 <html lang="zh-CN">。Pagefind 会依据页面语言处理索引,但中文没有天然空格边界,专有名词、中英文混排和版本号仍应使用真实内容测试。例如同时检查“全文检索”“Pagefind”“Astro 5”以及文章中的连续中文短语,不要只验证英文关键词。

索引是在构建结束后生成的,所以 astro dev 提供的页面中不存在 /pagefind/pagefind.js。上面的组件采用按需动态导入,并在开发环境显示明确提示;生产构建若仍加载失败,则说明部署产物可能遗漏了 dist/pagefind。验证时应先运行:

npm run build
npm run preview

另一个容易忽略的问题是 excerpt。Pagefind 返回的摘要包含用于高亮的 HTML,直接赋给 innerHTML 虽然方便,却会把内容来源和渲染权限绑定在一起。示例中的 renderExcerpt 使用 DOMParser 解析后重新创建节点,只保留文本与 mark 标签,其余标签被剥离。这样既保留关键词高亮,也不会执行摘要里的脚本、事件属性或未知元素。

如果博客内容全部由自己维护,风险相对可控;但只要内容来自 CMS、评论导入或多人提交,就不应把“索引由自己生成”等同于“所有 HTML 都可以无条件执行”。

总结

Pagefind 适合需要正文检索、又不想引入后端搜索服务的静态个人站。接入时可以记住以下几点:

  • 先确认标题过滤是否已经够用,不为小数据量过早增加构建步骤;
  • astro build 后运行 Pagefind,并确保部署包含生成的 pagefind 目录;
  • data-pagefind-bodydata-pagefind-ignore 和标题元数据控制索引质量;
  • 通过动态导入处理开发环境缺少索引的问题,以生产预览作为最终验证;
  • 中文页面声明正确语言,并用真实的中文短语、中英文混排内容测试结果;
  • Pagefind 摘要不要直接写入 innerHTML,采用允许列表保留 mark 高亮。

这套方案不依赖常驻服务,搜索界面也保持在自己的组件体系内,适合作为 Astro 静态博客从简单过滤升级到全文检索的中间路线。