小型站点可以从客户端过滤起步,但文章数量和正文体积增长后,构建时索引通常更稳妥。本文用 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-body、data-pagefind-ignore和标题元数据控制索引质量; - 通过动态导入处理开发环境缺少索引的问题,以生产预览作为最终验证;
- 中文页面声明正确语言,并用真实的中文短语、中英文混排内容测试结果;
- Pagefind 摘要不要直接写入
innerHTML,采用允许列表保留mark高亮。
这套方案不依赖常驻服务,搜索界面也保持在自己的组件体系内,适合作为 Astro 静态博客从简单过滤升级到全文检索的中间路线。
评论