← 返回
2026-10-16 · 技术文档

这个博客是怎么部署的

从自管 VPS 迁到静态托管:预渲染、任播 DNS、分层缓存、哈希 CSP、无服务器统计与首屏减重,以及背后的取舍。

这篇记录 sinbad.best 现在是怎么部署的,以及为什么这样部署。它从一台自管的 VPS 搬到了静态托管上,过程中做的每个决定,几乎都是被同一个问题推着走的:国内访客打开页面太慢。

一、先看全貌§

写作者
  │  git push main
  ▼
GitHub ──────────────► GitHub Actions:Lint、单元测试、构建、浏览器测试
  │
  ▼
Netlify 构建:Lint → 单元测试 → 生成路由 → 客户端构建 → SSR 构建 → 预渲染 → 字体缺字检查
  │
  ▼
Netlify 边缘网络(任播)
  ├── 静态文件:预渲染好的 HTML、带哈希的 JS/CSS/字体/图片
  ├── 安全头与 CSP:构建时生成的 _headers
  └── 两个函数:/_count(记一次访问)、/_stats(读统计)
                    │
                    ▼
               Netlify Blobs(一份 JSON,带 etag 的条件写)

DNS:Cloudflare,只做解析(灰云,不代理)

一句话:构建时把整站渲染成静态 HTML,交给 CDN 直接发;唯一需要服务端的访问统计,用两个无服务器函数加一个键值存储解决。

二、为什么从 VPS 搬走§

最早的结构是很常见的一套:海外 VPS 上跑 nginx 和一个 Node 进程,前面套 Cloudflare 代理(橙云)。功能上没有问题,问题出在路径上。

用国内网络直连实测,Cloudflare 把请求接到了美国西海岸的节点,而源站在亚洲。更麻烦的是 HTML 不能缓存:当时的内容安全策略(CSP)用的是 nonce,每次请求都要由 nginx 生成一个新的随机数写进页面,于是每一次打开页面,都要从边缘节点跨一次太平洋回源。

指标迁移前迁移后
国内建连 RTT约 200 ms约 65 ms
HTML 首字节约 1.3 s0.4–1.0 s
HTML 能否边缘缓存不能(每次请求的 nonce)能(内容不变)

源站本身并不慢,Node 出一页 HTML 只要几毫秒。瓶颈全在「必须回源」这件事上。所以要解决的不是让服务器更快,而是让访问不需要服务器。

三、构建:把动态站变成静态文件§

站点本身是 Vite + React + MDX 的单页应用。单页应用的老问题是首屏要等 JS 下完、跑完才有内容;对搜索引擎和社交平台的抓取也不友好。这里的做法是构建时预渲染:

  1. gen-routes 读文章元信息,生成路由表、各页标题描述和 sitemap。
  2. Vite 分别构建客户端包和一个 SSR 包。
  3. prerender 用 SSR 包把每个路由渲染成完整 HTML,填好 title、description、canonical 和 Open Graph,再写进 dist/。未知路径单独渲染一张 404.html,带 noindex。
  4. 浏览器拿到的是已经有正文的 HTML,JS 到了之后再接管(hydrate),之后的站内跳转走客户端路由。

为了让接管不闪烁,有两条约束必须守住:首次客户端渲染要和预渲染的标记完全一致,所有读浏览器状态的逻辑(主题、本地存储)都放在挂载之后;文章页的正文分块在 HTML 里用 modulepreload 提前声明,先加载再 hydrate。

四、托管与 DNS§

托管选了 Netlify,原因很朴素:它把「推代码 → 构建 → 发布 → 回滚」一条龙做好了,免费额度够个人博客用,而且在实测中国内到它的任播地址延迟明显更低。

DNS 留在 Cloudflare,但只做解析,记录全部灰云,不走 Cloudflare 代理。理由有两个:

  • 两层 CDN 叠在一起只会多一跳,且两边的缓存、证书、安全头会互相打架。
  • 域名下还有别的子域名在用,整个 NS 迁到 Netlify 会牵连它们。

这里有一个踩过的坑值得单独说:裸域不要用 CNAME 指向托管平台。Cloudflare 对裸域的 CNAME 会做「拍平」,在它自己那边解析成具体 IP 再返回,结果拿到的是某个固定区域的地址,国内建连一下回到两百多毫秒。改成平台提供的任播 A 记录后,用户会被就近接入,延迟降到六十多毫秒。www 则用 CNAME 指向平台,并在平台后台设为别名,自动 301 到裸域。

HTTPS 证书由平台用 Let's Encrypt 自动签发和续期,覆盖裸域和 www,不需要人管。

五、缓存策略§

静态化之后,缓存就可以放心地分层做:

资源规则理由
/assets/*(JS、CSS、字体、封面)缓存一年,immutable文件名带内容哈希,内容一变名字就变
/img/*(不带哈希的小图)缓存一天偶尔会更新,又不值得加哈希
HTML浏览器每次向边缘确认发布后平台自动让边缘缓存失效,用户总能拿到新版本
/_stats边缘与浏览器各 60 秒统计不需要实时,挡住大部分重复请求

HTML 的处理是这次结构里最关键的一点:它在边缘节点上是缓存的,只是浏览器每次都要问一声「变了没有」。这一问发生在就近的边缘节点,不再跨洋。

六、安全头与 CSP§

常规的安全头(HSTS、nosniff、X-Frame-Options、Referrer-Policy)写在 netlify.toml 里,全站生效。

CSP 单独处理。要让 HTML 可缓存,就不能用每次请求都变的 nonce,改用内联脚本的哈希:预渲染结束后,脚本扫描所有产出页面里的内联脚本,计算 SHA-256,写进 dist/_headers。只有构建时确实存在的脚本能执行,同时 HTML 内容是确定的,可以缓存。

代价是新增或修改内联脚本后必须重新构建才能生效——这本来就是每次发布都会做的事。另外需要关掉平台在页面里注入的「Powered by」角标,它是一个被严格 CSP 拦下的内嵌框架。

七、访问统计:静态站上唯一的「后端」§

站点需要一个简单的访问统计,这是唯一绕不开服务端的地方。做法是两个无服务器函数加一份存储:

  • POST /_count:每次路由访问上报一次,只收小体积的 JSON,非法数据、超长、错误媒体类型、写入失败分别返回 400、413、415、503。
  • GET /_stats:返回聚合后的统计,供关于页展示。
  • 两个接口都按 IP 做了平台级限流。

数据是 Netlify Blobs 里的一份 JSON。函数可能被并发调用,写入用 etag 做条件写:读出时记下版本,写回时要求版本没变,冲突就带随机退避重试。新键只允许创建,避免两个请求同时「初始化」互相覆盖。

去重放在浏览器侧,用本地存储记住「今天是否已经算过」,服务端不保存任何浏览器身份标识。日界按北京时间零点划分。这样做的局限也写在这里:匿名统计不能防伪造,多标签页首次访问在不支持 Web Locks 的浏览器上可能重复计数。对一个个人博客来说,这个精度够了。

八、首屏减重§

链路变短之后,剩下的时间主要花在下载上。实测时一张长文页面里,中文字体占了六成以上的字节,于是做了三件事:

  1. 字体按用处切片。思源宋体按构建产物里实际渲染出的字取子集(不扫源码,注释里的字不会进字体),再切成三类:所有页面通用的 core(预载)、两篇以上文章共用的 shared、只属于某一篇文章的独立切片。每片一个带 unicode-range 的 @font-face,浏览器只下载当前页面真正用到的片。
  2. 封面图换格式。首页封面用 AVIF,WebP 兜底,两档宽度;内页的背景压在半透明的阅读层下面,看不清细节,只用一张几 KB 的预模糊小图;大图到达之前,CSS 里内联一张极小的缩略图垫底。
  3. 视频推迟并在弱网跳过。首页的背景视频要等页面和封面都加载完、浏览器空闲时才开始下载;开启省流量、估计带宽很低或封面本身就下得慢时,干脆不下,只留静态封面。

九、发布流程§

日常写作只有一步:把文章放进仓库,推到 main。之后发生的事情:

  1. GitHub Actions 跑完整检查:Lint、单元测试、构建、Playwright 浏览器测试和 axe 可访问性检查。
  2. Netlify 收到推送,自己再跑一遍 Lint、单元测试和构建,任何一步失败都不会发布,线上保持旧版本。
  3. 构建成功后原子切换到新版本;出问题时可以在后台一键回滚到任意历史部署。

新文章如果用到了字体里没有的字,构建末尾的缺字检查会列出来。这时在本地重切一次字体、再构建,然后一起提交:

npm run fonts && npm run build

免费额度有一个需要注意的地方:每次正式部署都会消耗积分,月度积分用完,账号下的站点会被暂停。所以习惯是攒一批改动再推,而不是改一个字推一次。推之前先在本地 npm run build && npm run serve 验收,serve.mjs 会按线上的方式处理预渲染路由、真实 404 和 CSP。

十、取舍§

这套结构放弃了一些东西:

  • 没有服务端渲染。所有页面在构建时就定型,想要按请求变化的内容只能交给函数或客户端。对博客来说正合适。
  • 统计是近似的。浏览器侧去重、匿名、可伪造,只用来看个大概。
  • 依赖单一平台。好在产物就是一个 dist/ 目录加一份 _headers,换到任何静态托管都只需要重写这份头部规则和两个函数。

换来的是:没有服务器要打补丁,没有进程要守护,证书不用续,发布就是一次 git push。而国内访客打开一页文章的时间,从「明显能感觉到等待」降到了「大致还行」。

能不要的服务器,就不要。