写博客的工具选型里有一个常见的误解:静态站 = 不需要服务端 = 简单。真正麻烦的地方在于「哪些东西必须发生在构建期」。这篇文章记录这个站点的几个决定。
为什么不是 SPA
同样的 Nuxt 项目,ssr: false 打包出来只有 20 KB HTML 骨架,看起来更省。但博客的两个核心需求会直接落空:
| 需求 | SPA(ssr: false) | 静态生成(nuxt generate) |
|---|---|---|
| 文章被搜索引擎完整收录 | 取决于爬虫是否执行 JS | 拿到的是完整正文 |
| 分享链接的 OG 卡片 | 只有前端渲染后才有内容 | HTML 里就带着 meta |
| 首屏 | 下载 JS → 执行 → 渲染 | 直接是可见内容 |
博客的生命周期以年计,读者从搜索与聊天窗口进来,这两条都在赌客户端会不会执行 JS。不值得。
内容查询必须留在构建期
@nuxt/content 的查询在服务端执行。纯静态部署没有服务端,所以浏览器里重新执行查询就会请求一个不存在的接口。解决办法是把查询包进 useAsyncData:
export function useArticles() {
return useAsyncData('content:articles', () =>
queryCollection('articles')
.where('draft', '=', false)
.order('date', 'DESC')
.select('title', 'description', 'date', 'category', 'tags', 'path')
.all(),
)
}
两件事容易被忘掉:
select()不写,payload 里就会带上整篇正文的 AST,列表页凭空胖几十 KB;- 详情页的 key 必须带 slug,并且用
definePageMeta({ key: route => route.fullPath })强制重建组件,否则从 A 文章点到 B 文章,setup不会重新执行,页面上还是 A 的内容。
Nginx 上要注意的三件事
1. 目录形式的静态文件
nuxt generate 把 /articles/foo 输出成 articles/foo/index.html。try_files 要按这个顺序找:
location / {
try_files $uri $uri/index.html $uri.html =404;
}
只写 $uri/ 的话,/articles/foo 会先命中目录再落到 index 指令,看起来能用,但大小写敏感的目录列表配置一旦改动就会露馅。
2. 真正的 404 状态码
error_page 404 /404/index.html;
不要写成 error_page 404 = /404/index.html;。加了 = 之后响应状态被改写成 200,搜索引擎会把不存在的链接当成可用页面收录,留下一堆软 404。
3. 哈希产物强缓存,HTML 不缓存
location /_nuxt/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
/_nuxt/ 下的文件名带内容哈希,可以放心 immutable;HTML 一旦缓存,读者点开链接看到的永远是旧构建。
检索是构建的最后一步
搜索用的是 Pagefind:它在生成之后扫描 .output/public 下的 HTML,产出静态索引文件。于是 package.json 里的脚本顺序是固定的:
{
"scripts": {
"dist": "nuxt generate && pagefind --site .output/public"
}
}
只跑 nuxt generate 也能得到一个能访问的站点,但搜索页会因为拿不到 /pagefind/pagefind.js 而报错。这类「构建步骤少一步、线上坏一个功能」的坑,最好在读者的浏览器里也能看见——所以搜索页在这种情况会显示一句明确的提示,而不是空白。
剩下的都交给 Markdown
写完这些之后,日常只需要两种动作:在仓库里改 .md,或者在网页编辑器里改同一批 .md。两者共用 content.config.ts 里的字段定义,保存下来仍然是同一个文件、同一套字段。静态站点的好处正在于此:内容的表示形式只有一种,工具可以换,数据不用迁。
