一、要做什么:一个极速文档站
这一篇做完整实战:用 Astro 从零搭一个文档站——侧边栏导航、全文搜索、静态托管部署,一条链走完。前置认知(Islands 架构、content collections 的 API 形态)在上一篇《Astro 入门》讲过,本篇直接用。
先把”极速”定义清楚,不然它是句空话。极速不是玄学优化,是结构带来的默认结果:页面是构建期定稿的纯 HTML,浏览器默认下载零 JS——“内容站这个约束,让每个页面都值得是纯静态的”。指标和测法开工前定死:
| 指标 | 目标 | 怎么测 |
|---|---|---|
| Lighthouse Performance(移动端) | 95 以上 | npx astro preview 起本地预览或部署后,Chrome DevTools 的 Lighthouse 面板选移动端跑(目标值为设计目标,非本站实测承诺;以你自己跑出的分数为准) |
| 首页 JS 传输量 | 接近零(只有搜索框这类岛) | 浏览器 Network 面板按 JS 过滤,看首页总传输 |
| 搜索覆盖 | 任意文档标题可命中 | 部署后搜三五个文档标题关键词抽查 |
| 坏数据拦截 | frontmatter 字段错误在构建期报错 | 故意把某篇 order 写成字符串,跑构建,应失败而非出坏页 |
| 部署可访问 | 正式域名 200 | curl -I https://docs.example.com/ |
二、决策链:内容站为什么选 Astro 不选 Next
站内已有一篇《用 Next.js 做一个真的能被搜到的内容站》,那套栈当时是合理的——那个项目里内容与应用行为混合,Next 的全栈能力用得上。这次需求不同:纯文档、无账号、无评论、无实时数据,交互只剩搜索框和深色切换。两个候选摆开:
| 维度 | Astro | Next.js(内容站用法) |
|---|---|---|
| 默认 JS 产物 | 零,按岛增加;文档站通常一两个岛 | 静态导出也有 React 运行时与注水,除非整站 Server Components 且无交互件 |
| 内容 schema | content collections 内置,frontmatter 构建期校验 | MDX + 文件系统,schema 要自己接(zod 手动管线或第三方方案) |
| 构建链路 | 产物是纯 HTML,链路轻;Astro 5 的 Content Layer 把内容条目纳入构建缓存,内容没改的集合不再重复处理,大站构建更快(公开数据,Astro 官方发布说明) | 应用框架构建链路更重,纯内容站背着用不上的能力 |
| 学习面 | .astro 语法接近 HTML,概念少 | App Router 概念栈(Server/Client Components、缓存语义)对纯文档站是超额的 |
| UI 组件复用 | React/Vue 组件都能当岛用 | 仅 React |
| 需要服务端时 | 加 adapter 开 SSR,按路由混排 | 原生就是全栈 |
结论:这个项目选 Astro。为什么不选 Next:不是因为 Next 不好,是因为它解决问题的重心(应用行为)在这个项目里几乎不存在——为 5% 的可能性付 100% 的复杂度,不划算。反过来,如果文档站要内嵌控制台、按角色显示内容、评论互动,表格里最后两行立刻反转,请回 Next 阵营。判断依据始终是”应用部分占几成”,不是偏好。
三、内容建模:用 content collections 建 schema
文档站的目录结构决定导航结构,所以 schema 从第一天就要设计对。内容放 src/content/docs/,按分区分目录,frontmatter 只放”导航需要的元数据”:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const docs = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/docs' }),
schema: z.object({
title: z.string(), // 侧边栏与 <h1> 都用它,改一处全站生效
section: z.string(), // 分区名,侧边栏按它分组
order: z.number().default(99),// 分区内排序;目录顺序是文档站的体验核心
draft: z.boolean().default(false),
}),
});
export const collections = { docs };
侧边栏不手写——手写目录必然和文件现状脱节,这是文档站腐烂的头号起点。从集合数据生成:
// src/lib/sidebar.ts
import { getCollection } from 'astro:content';
export async function getSidebar() {
const docs = (await getCollection('docs', (e) => !e.data.draft))
.sort((a, b) => a.data.order - b.data.order); // 排序只做一次,后面都吃现成的
const groups = new Map<string, typeof docs>();
for (const entry of docs) {
const list = groups.get(entry.data.section) ?? [];
list.push(entry);
groups.set(entry.data.section, list);
}
// Map 保持插入顺序:第一个出现的 section 排最上,目录可控
return [...groups.entries()].map(([section, entries]) => ({ section, entries }));
}
文档页本体(动态路由 + 正文渲染,API 细节上一篇讲过,这里只列分工):
---
// src/pages/docs/[...id].astro
import { getCollection, render } from 'astro:content';
import Layout from '../../components/Layout.astro';
import { getSidebar } from '../../lib/sidebar';
export async function getStaticPaths() {
const docs = await getCollection('docs', (e) => !e.data.draft);
return docs.map((entry) => ({ params: { id: entry.id }, props: { entry } }));
}
const { entry } = Astro.props;
const { Content } = await render(entry);
const sidebar = await getSidebar();
// 当前页高亮靠比较 entry.id,纯构建期逻辑——不注水也能高亮
---
<Layout title={entry.data.title}>
<nav>{/* sidebar 渲染略:两层循环,a 标签 href 指向 /docs/ + entry.id */}</nav>
<article><h1>{entry.data.title}</h1><Content /></article>
</Layout>
反模式一:手写侧边栏。文档一多,目录文件就成了”最后一个愿意维护的人”的负担;schema 里的 section/order 加一个从集合生成的导航,目录永远和内容同步,代价是首次多写二十行。反模式二:为了”当前页高亮”给整个导航组件注水。高亮是纯构建期可解的问题(比较 entry.id),挂 client:load 等于为零收益买整页 JS——注水成本的账,站内《React 渲染与重渲染》算得很细,结论在 Astro 里同样成立:能构建期算的就不留给浏览器。
四、搜索:pagefind 还是 fuse
文档站没有搜索等于劝退新读者。候选就两个主流:Pagefind(Rust 写的静态搜索,构建后对 HTML 产物建索引,浏览器里跑 WASM,索引分块按需加载)和 Fuse.js(模糊搜索库,索引和 UI 全部自己来)。对比:
| 维度 | Pagefind | Fuse.js |
|---|---|---|
| 索引怎么来 | 构建后扫 dist/ 自动建,不用写索引代码 | 自己从集合生成 JSON 索引 |
| 索引怎么到浏览器 | 分块(chunked)按需拉取,大站首屏只加载用到的块 | 整份 JSON 要先发给浏览器,站越大越疼 |
| UI | 自带搜索 UI,几行接入 | 输入框、结果列表、高亮全部自己写 |
| 匹配质量 | 开箱即用,多语言处理好 | 模糊匹配与相关性调参空间大——Hugo 社区的公开对比认为 Fuse 的词条匹配更准,代价是配置成本 |
| 适合 | 几百到上万页的静态文档站 | 小站、或需要完全定制排序与样式的站 |
这个项目选 Pagefind,理由是文档站搜索要的是”索引始终新鲜 + 接入成本最低”,两者它都占。接入只有两步。第一步,构建产物上建索引:
npx astro build # 先出产物,顺序不能反:pagefind 索引的是构建后的 HTML
npx pagefind --site dist # 在 dist/ 里生成 pagefind/ 目录(索引与 UI 资源)
第二步,搜索页放一个容器加官方脚本,九行逻辑就是完整搜索:
<!-- src/pages/search.astro 的正文部分;pagefind 全局变量由官方脚本注入 -->
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
<div id="search"></div>
<script is:inline src="/pagefind/pagefind-ui.js"></script>
<script is:inline>
// 官方 UI 一行接管输入、防抖、结果渲染;需要深度定制时再换成 pagefind.search() 手写
new PagefindUI({ element: '#search', showImages: false });
</script>
想在开发时也有搜索,可以装 astro-pagefind 集成(社区维护,dev 模式提供预建索引);也可以接受”开发时搜索页 404、部署后才有”,更简单。反模式三:选了 Fuse 又把整份索引打进首屏 bundle。fuse 索引跟着 JS 走,几百篇文档就是几百 KB 的首屏税——fuse 的正确姿势是异步按需加载索引,但那就把”省事”这个选它的理由也丢掉了。
五、构建与部署:静态托管
文档站输出保持默认的静态模式(不装 SSR adapter 就是纯静态),产物是 dist/ 目录——它就是一堆 HTML/CSS/少量 JS 文件,任何静态托管都接得住。配置只需给域名:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://docs.example.com', // 影响 sitemap 与 canonical URL,静态托管前必须填
});
CI 里把”构建、索引、发布”串成一条流水线(示例用 GitHub Actions,托管任选 Cloudflare Pages / Netlify / Vercel / GitHub Pages,纯静态产物对它们无差别):
name: deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 } # Astro 5 基线 18.17+/20+,这里用 22 LTS
- run: npm ci
- run: npx astro build
- run: npx pagefind --site dist # 搜索索引必须在构建产物上生成,先后顺序是正确性的前提
1. 把 dist/ 交给你的静态托管:各家 Actions 一行发布,产物纯静态、无运行时要求
部署侧的一个经验:静态托管的”极速”是双份的——构建期零注水产物 + 托管侧 CDN 边缘缓存,两层都不需要你做任何额外配置。这也是决策链里”不选 Next”的另一半原因:静态托管对纯 HTML 的缓存策略最简单粗暴,没有缓存失效的心智负担。
六、验收:怎么确认这套站站住了
| 验收项 | 目标 | 怎么测 |
|---|---|---|
| 构建健康 | 全量文档构建成功 | npx astro build 零报错;故意写坏一个 frontmatter 字段,确认构建失败 |
| 路由完整 | 每篇文档有独立 URL | dist/ 里逐页 curl 本地预览抽查 |
| 导航同步 | 侧边栏 = 集合现状 | 新增一篇文档带新 section,重建后侧边栏自动出现分组 |
| 搜索可用 | 标题关键词可命中 | 部署后搜若干文档标题;空关键词不崩 |
| 性能口径 | Lighthouse 移动端 95 以上 | 正式域名跑 Lighthouse(移动端模拟),自己跑出来的数才算数 |
| JS 传输量 | 首页接近零 | Network 面板看 JS 总量,与决策预期对账 |
七、诚实结论与边界
这套方案的收益全部来自一个结构性事实:文档站的内容在构建期即可定稿。所以它的边界也划在同一处——评论、账号体系、按角色显隐的内容、实时数据,任何一条出现,”纯静态”的根基就动摇了,届时要么给个别路由加 adapter 开 SSR,要么直接回到 Next.js 那套全栈栈(站内Next.js 内容站实战是为那种场景准备的)。另一个不吹的部分:本文没有本站实测的 Lighthouse 分数,”95 以上”是设计目标与验收线,不是承诺;结构上零 JS 的站拿高分的把握很大,但数字必须自己跑。下一步想继续折腾的,可以给文档站加版本化(多套集合 + URL 前缀)或全文索引的中英文分词策略,这两块 Pagefind 官方文档都有现成方案。
版本核实(2025-03-01 撰写时检索):Astro 当前主版本 5.x(5.0 于 2024-12 发布,引入 Content Layer API;撰写时最新为 5.2.x);glob() loader、render(entry)、entry.id 为现行 API,源为官方文档;Node 基线为 18.17+ / 20+;Pagefind 为独立开源项目(pagefind.app),astro-pagefind 为社区集成;Hugo 社区对 Fuse.js 匹配质量的对比为公开讨论。本文无本站实测性能数字,Lighthouse 目标值需读者自测;安装命令不钉版本,以官方文档为准。