技术

静态站点为什么快

把渲染、字体与索引都挪到构建期之后

同一篇文章,两个地址。一个点开要等一秒多,白屏之后文字才慢慢浮出来;另一个几乎按下就出现。文案一样长,图也一样多,差别不在内容,在谁先算完。

一、先算,还是后算

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 越久,而访客等的时间不变——成本被前置到了写下文章的那一刻。这条链子上每一环长什么样、哪一步最容易出错,一篇文章的旅程 里从头走了一遍。

三、一次请求要做的事

打开一个页面,浏览器按顺序要做这些:

  1. DNS 查询,把域名换成 IP 地址。
  2. TCP 与 TLS 握手,建立加密连接。
  3. 取回 HTML 文档。
  4. 解析 HTML,取 CSS 与字体。
  5. 取 JS,执行 JS。
  6. 布局、绘制,页面出现。

前两步是网络成本,静态化改写不了,只能靠 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。

Footnotes

  1. TTFB 是 Time To First Byte 的缩写,指从发出请求到收到第一个字节的时间,常被当作服务端响应速度的近似指标。 ↩

  2. WOFF2 使用 Brotli 压缩,同样字重下通常比 WOFF 小一截,是目前浏览器唯一普遍支持且推荐的格式。 ↩

  3. 这里的数字都写成量级,不写具体毫秒数。不同的机器、不同的网络,测出来能差一个数量级,这类数字只适合用来比较相对快慢。 ↩

关联3

知识图谱

局部图谱 · 点节点跳转