静态站点为什么快
把渲染、字体与索引都挪到构建期之后
同一篇文章,两个地址。一个点开要等一秒多,白屏之后文字才慢慢浮出来;另一个几乎按下就出现。文案一样长,图也一样多,差别不在内容,在谁先算完。
一、先算,还是后算
Astro 这类框架默认在构建时把 Markdown 变成 HTML:解析语法、给代码上色、把公式转成 HTML、把 Mermaid 留成占位——这些都在 astro build 被调用时完成一次,产出的是纯静态文件。用户请求的时候,服务器或 CDN 的边缘节点只是把文件递出去:没有数据库查询,没有模板渲染,没有等待上游接口。
这就是首字节时间(TTFB)的差别1。动态渲染的站点,TTFB 里装着服务端全部的计算;静态文件理论上只剩一次网络往返。
但要说明白:动态站点也可以把结果缓存成静态文件,很多大站就是这么做的。静态站点的真正优势不是「快一点点」,而是没有需要维护的慢路径——不存在缓存未命中,不存在冷启动,不存在某个接口今天慢了三百毫秒。
二、这段代码什么时候跑
// src/pages/posts/[...slug].astro —— 只在构建期执行一次
export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
这段代码的产物是 N 个 HTML 文件。请求进来时它不会再跑,一次也不会。所以内容越多,astro build 越久,而访客等的时间不变——成本被前置到了写下文章的那一刻。这条链子上每一环长什么样、哪一步最容易出错,一篇文章的旅程 里从头走了一遍。
三、一次请求要做的事
打开一个页面,浏览器按顺序要做这些:
- DNS 查询,把域名换成 IP 地址。
- TCP 与 TLS 握手,建立加密连接。
- 取回 HTML 文档。
- 解析 HTML,取 CSS 与字体。
- 取 JS,执行 JS。
- 布局、绘制,页面出现。
前两步是网络成本,静态化改写不了,只能靠 CDN 的边缘节点缩短物理距离。第三步是 HTML 体积,静态页压缩后通常只有十几 KB,一个往返就能拿全。最容易被忽略的是第四步:字体。
中文站点的字体是重灾区。一套完整的中文字体动辄几 MB,直接引用等于让访客先下载一座山。
四、字体切片
做法分两层。第一层是子集化:扫一遍全站正文,只保留真正出现过的字。一本随笔集用到的不同汉字大概三千出头,做成 WOFF2 只有几百 KB2;再按 unicode-range 切成几十片,浏览器只下载当前页需要的那几片。第二层是显示策略:font-display: swap 让文字先用系统字体顶上,字体到了再换。换的时候若度量差太多,文字会跳一下,所以要么用 size-adjust 对齐,要么挑一个笔画粗细接近的回退字体。
# 看产物的体积构成,按大小排序取最后五个
du -sh dist/_astro/*.woff2 | sort -h | tail -5
# 看一页的响应头里缓存与压缩是怎么写的
curl -sI https://example.com/posts/why-static-is-fast/ \
| grep -Ei 'cache-control|content-encoding|etag'
子集化省了体积,也埋了一个坑
字体里只留下站内出现过的字。新文章带进来一个生字,它就缺着——所以加完文章要重跑一次npm run fonts。
站内换了字体、或者多了一门外语引文,同理。
五、带指纹的文件名
构建产物的文件名里带内容哈希,例如 _astro/Page.7f3a91.css。内容一变,名字就变,于是可以放心给它一年的强缓存:
Cache-Control: public, max-age=31536000, immutable
HTML 本身不能长缓存,否则读者会一直看到旧页面。它用 max-age=0, must-revalidate 或很短的缓存,配合 CDN 回源。这样一来,老访客再来的成本接近零,新访客也只会多一次往返。
六、搜索也放在构建期
Pagefind 在 astro build 之后扫描 dist:把每页正文切成小段,切词、建立倒排、压缩分片。查询在浏览器里用 WASM 完成,不需要服务器。第一次搜的时候只下载与关键词高度相关的几个分片,剩下的按需取。
展开看构建日志里实际发生了什么
$ npm run build
astro build
├─ 收集内容集合:7 页
├─ 渲染 Markdown(KaTeX、Shiki 高亮、Mermaid 占位)
└─ 写出 dist/(HTML + 切片字体 + 带哈希的资源)
pagefind --site dist
├─ 索引 7 个页面,切成 96 个片段
└─ 写出 dist/pagefind/(分片索引 + WASM 查询器)
两步之间没有服务器参与,构建机上有多少核就用多少核。
七、它的代价
| 环节 | 运行时渲染 | 构建期完成 |
|---|---|---|
| Markdown 解析 | 每次请求 | 一次 |
| 语法高亮 | 每次请求,或推到客户端 | 一次 |
| 数学公式 | 客户端下载 KaTeX | 一次,输出纯 HTML |
| 全文索引 | 查数据库 | 静态分片 |
| 字体 | 引用整包 | 子集加切片 |
| 首字节 | 含服务端计算 | 只剩网络往返 |
不快的地方也得说清楚:
- 内容一改就要重新构建。一千页的站,可能要几分钟。
- 个性化内容做不了,除非再加一层水合逻辑,那就又慢回去了。
- 评论、点赞这类交互,要么接第三方,要么自己写接口。
- 客户端 JS 仍然存在——主题切换、批注、图表渲染都是脚本。静态只保证首屏文字最快可用,不保证零脚本。
判断标准其实很简单:这片内容对每个访客都一样吗?一样,就该在构建期算完;不一样,才值得在请求时再算一次3。