博客
RSS 订阅

为什么我把博客做成了纯静态站

约 4 分钟

这个博客没有数据库、没有服务端运行时、没有登录系统。构建产物是一堆 HTML 文件,直接躺在 Cloudflare 的全球边缘网络上。

这不是偷懒,是一系列明确的取舍。这篇文章把分界线画在哪里、为什么画在那里讲清楚。

静态与动态的分界线

一个博客的功能可以拆成两类:

内容类——文章、标签、归档、RSS、站点地图。这些数据在写作时就确定了,读者无论何时访问,看到的内容都一样。它们不需要服务器参与。

交互类——评论、浏览量、点赞、后台编辑。这些数据在阅读时才产生,每个读者看到的不同。它们必须有服务端。

关键洞察是:绝大多数博客 90% 的流量打在内容类页面上。把这一类做成静态,等于让 90% 的请求根本不碰服务器。

说明

这里说的「静态」指构建期就把 HTML 生成好,而不是「没有服务端渲染」。两者完全不是一回事,下面会展开。

成本结构因此改变

Cloudflare Workers 的计费逻辑里有一条决定性规则:

Requests are only billable if a Worker script is invoked.

请求只有触发 Worker 脚本才计费。纯静态站没有 Worker 脚本,所有请求都由静态资源层直接返回。结果是:

静态站混合站
每篇文章的请求边缘直出,0ms 冷启动可能触发 Worker
免费额度风险结构性不存在取决于流量与实现
数据库需要迁移、备份、连接管理
攻击面几乎为零表单、认证、注入

「几乎为零」不是修辞。没有服务端代码,就没有可以注入的查询、可以绕过的认证、可以耗尽的连接池。

SvelteKit 让两者不必二选一

SvelteKit 的渲染开关是按路由粒度的,不是全局的:

// src/routes/+layout.ts —— 全站默认预渲染
export const prerender = true;
export const trailingSlash = 'always';

这三行让所有页面在构建期生成 HTML。而如果将来想加一个动态接口,只需在对应路由里关掉它:

// src/routes/api/comments/+server.ts
export const prerender = false; // 这个端点走运行时

所以「纯静态」是一个当前状态,不是架构上的死路。想加评论时,换一个 adapter、加一段配置即可,路由结构不用动。

预渲染不是「不渲染」

一个常见误解是:预渲染 = 放弃服务端渲染。事实正相反。

ssrcsrprerender 是三个不同层次的概念:

选项默认值含义
ssrtrue服务端渲染页面
csrtrue浏览器接管(hydrate)
prerenderfalse在构建期固化成文件

预渲染的页面,恰恰是通过 SSR 生成的——只不过那个「服务器」是构建期的 Node 进程,而不是线上的 Worker。

三者全开的效果是:构建期用 SSR 产出完整 HTML → 浏览器加载 JS 后 hydrate → 之后的导航由客户端路由接管。这是静态站性能与体验兼得的正常形态。

唯一要永远避免的是 ssr = false:那会渲染出一个空壳,SEO 归零。

entries() 只对动态路由有意义

静态路由(//archive/)SvelteKit 会自动发现。但动态路由 [slug] 无法被枚举,必须显式告知:

// src/routes/posts/[slug]/+page.ts
import { getPosts } from '$lib/content';

export const prerender = true;

export function entries() {
  return getPosts().map((post) => ({ slug: post.slug }));
}

这段代码返回的每个对象都会生成一个页面。漏写它,该路由一个页面都不会产出——而且 adapter-staticstrict: true(默认开启)会直接让构建失败,正好抓出这类疏忽。

建议保留 strict: true。它是纯静态方案里唯一的护栏,关掉它意味着漏掉的路由会静默消失。

那些看似动态的东西,也可以静态

RSS、站点地图、robots.txt,以及 /llms.txt 这类给大模型看的索引,全都可以是构建期生成的静态文件。做法是在 SvelteKit 里用端点路由:

src/routes/rss.xml/+server.ts       →  GET 返回 RSS
src/routes/sitemap.xml/+server.ts   →  GET 返回 XML
src/routes/llms.txt/+server.ts      →  GET 返回纯文本
src/routes/search-index.json/+server.ts → GET 返回 JSON

有个容易搞混的点:/rss.xml 返回 XML,不是因为文件名带了 .xml。路由段名只决定 URL 路径,与 MIME 无关。必须显式设置响应头:

return new Response(xml, {
  headers: { 'content-type': 'application/xml; charset=utf-8' }
});

不设这一行,浏览器会把 RSS 当纯文本显示出来。

代价是什么

诚实地说,纯静态也有代价:

  • 发布必须重新构建。改一个错别字要跑一次完整构建。对个人博客这无所谓,对协作频繁的团队就是问题。
  • 无法按读者定制。没有服务端,就没有「根据登录状态显示不同内容」,除非退化成客户端渲染。
  • 文章数量有上限。所有文章都编译进 JS bundle,几千篇之后构建时间和体积都会成问题。到那时再迁到运行时读取是合理选择。

本站目前的取舍是:接受这些代价,换取零运维与结构性的低成本。

部署配置的一个坑

纯静态站部署到 Cloudflare Workers 时,trailingSlashhtml_handling 必须成对配置,配错的表现是大量 404:

// wrangler.jsonc
{
  "assets": {
    "directory": "./build",
    "not_found_handling": "404-page",
    "html_handling": "force-trailing-slash"
  }
}

对应关系是这样的:

SvelteKit trailingSlash产物形态Cloudflare html_handling
'never'(默认)posts/foo.htmlauto-trailing-slash
'always'posts/foo/index.htmlforce-trailing-slash

Cloudflare 的 auto-trailing-slash 规则是「文件不带斜杠、目录索引带斜杠」,它并不理解 trailingSlash: 'always' 的语义。所以选了 'always' 就必须显式改成 force-trailing-slash,否则带斜杠的 URL 会全部落空。

© 2026 博客 RSS Sitemap llms.txt

由 SvelteKit 构建,Material 3 Expressive 设计,部署于 Cloudflare Workers