新博客为什么这么快?从技术选型到每一个字节
目录
博客重做了一遍。最直观的变化是:点开任何一页,内容几乎是瞬间出现的。这不是换了更贵的服务器,而是从技术选型开始,把每一层"没必要的东西"都拿掉了。这篇文章把背后的取舍讲清楚:为什么选静态站点和 Astro,构建管线长什么样,CSS、JavaScript、字体、图片、页面切换、SEO 各做了什么,以及用数字说明效果。
先看结果
下面是一篇典型文章页在生产环境的数据(移动端 Lighthouse,模拟慢速网络):
| 指标 | 数值 |
|---|---|
| HTML(gzip 后) | 16–18 KB,样式已包含在内 |
| CSS | 约 20 KB,内联在页面里,零额外请求 |
| 首屏 JavaScript | 约 6 KB(主题切换、搜索框、复制按钮、链接预取) |
| Web 字体 | 0 字节 |
| 首屏第三方请求 | 0 个 |
| Lighthouse | 性能 100 · 无障碍 100 · 最佳实践 100 · SEO 100 |
| CLS(布局偏移) | 0 |
这些数字不是靠某一个"黑科技",而是每一层都少做一点事的累积结果。
为什么是静态站点,为什么是 Astro
博客的本质是"写好的文章给人读",内容在我提交的那一刻就确定了。那就没有理由让服务器在每次访问时重新渲染,更没有理由让读者的浏览器下载一个应用框架再把文章"水合"一遍。所以第一个决定是:全部在构建时生成静态 HTML,服务器(其实是 CDN)只负责把文件发出去。
选型时我实际构建了几个候选方案,量了它们在一个"预渲染好的文章页"上下发多少 JavaScript:
| 方案 | 文章页下发的 JS(gzip) | 说明 |
|---|---|---|
| Astro 7(静态输出) | 0 B | 默认零 JS,需要交互时按需加载"岛屿" |
| TanStack Start 1.0(React) | 约 108 KB | 预渲染之后仍然要整页水合,没有零 JS 模式 |
| TanStack Start(Solid) | 约 48 KB | 同上,只是运行时更小 |
| Next.js App Router | 数十 KB + RSC 负载 | 某知名博客的存档页 HTML 达 645 KB,其中 61% 是内联的组件数据 |
TanStack 系列做应用很好,但它的 Markdown 包刚到 1.0、官方示例还是在浏览器里解析 Markdown,这和"文章型站点"的目标相反。Astro 的内容集合、图片优化、Shiki 高亮、sitemap 与 RSS 都是一等公民,而且页面默认一个字节 JS 都不带——这正是我要的。
HeroUI 这类 React 组件库也测过:一个按钮岛屿要 96 KB(其中 React 约 70 KB)。最终只借用了它的视觉语言,所有按钮、标签、卡片都用零 JS 的 Astro 组件实现。
构建管线
graph LR A[content/posts/*.md] --> B[内容集合 + Zod 校验] B --> C[remark 插件<br/>摘要 · 阅读时间] C --> D[rehype 插件<br/>标题锚点 · 代码块外壳 · 表格 · 章节] D --> E[Shiki 高亮<br/>浅色 + 深色] E --> F[页面 HTML<br/>CSS 内联] F --> G[dist/ + Pagefind 索引 + OG 图 + sitemap]
文章就是 content/posts/ 下的 Markdown 文件,frontmatter 由 Zod 校验:日期格式错了、slug 里混进中文或空格,构建直接失败,而不是上线后才发现。Markdown 经过 remark/rehype 管线:<!-- more --> 之前的内容成为摘要;阅读时间按"中文 300 字/分钟 + 英文 200 词/分钟"分别计算;每个标题带稳定的 id 与锚点;代码块被包进带语言标签和复制按钮的外壳;每个二级标题的章节包成一个 <section>,并开启 content-visibility: auto,长文章的布局计算只做可见部分。
代码高亮在构建时完成。Shiki 同时输出浅色和深色两套颜色,以 CSS 变量的形式写在每个 token 上,切换主题只是换一组变量,浏览器里没有任何高亮程序在跑。
CSS:20 KB,内联,不闪
样式用 Tailwind v4,设计令牌(颜色、字号、圆角)写在 @theme 里,所有组件只引用令牌。构建时 Tailwind 只输出真正用到的工具类,整站样式约 20 KB,并通过 inlineStylesheets: 'always' 直接内联进每个页面:少一次请求,首屏渲染不需要等任何外部文件。
我给 CSS 定了一条硬预算:文章页内联样式不超过 20 KiB,每次改样式都重新测量。有两次差点超标,原因很有代表性:Tailwind 会扫描源码里所有像类名的词,组件样式里写的 transition、grid 这种属性名也会被当成工具类打进全局;解决方法是把这些词加进扫描排除表。另一次是把只有文章页需要的样式放进了全局样式表——Astro 按导入关系打包组件样式,而不是按是否渲染。
深色模式由 <head> 里一段不到 1 KB 的内联脚本在首次绘制前设置 data-theme,所以不会出现先白后黑的闪烁;深色底色用的是接近黑的 #121214,而不是纯黑,长文阅读不刺眼,层级靠"更亮的表面"而不是描边来表达。
JavaScript:按需,而且很小
文章页只有四个小脚本:主题切换(1.0 KB)、搜索框启动器(1.4 KB)、代码复制(0.5 KB)、链接预取(2.4 KB)。真正"重"的东西全部延迟:
- 评论(Disqus):评论区距离视口 600 px 以内才注入 embed 脚本。
- 搜索(Pagefind):静态索引在构建时生成,点开搜索框才加载 UI 和索引分片。
- 流程图(Mermaid):库有几 MB,只在含图表的页面、图表接近视口时才动态
import(),并随主题重新渲染。
没有 React,没有客户端路由,没有在浏览器里解析 Markdown。
字体:一个字节都不下载
中文 Web 字体动辄几 MB,再怎么子集化也是首屏的负担。这里直接用设备自带的字体栈:Apple 平台上是 SF Pro 和苹方,Windows 上是微软雅黑,Linux/Android 上是 Noto Sans CJK。图标用内联 SVG,不用图标字体。
图片:构建时处理好
文章目录里的图片在构建时由 sharp 转成 WebP,并按 640–1280 px 等多个宽度生成 srcset;sizes 按正文栏宽(832 px)设置,手机不会去下载桌面尺寸。每张图都带固有宽高,页面不会因为图片加载而跳动,这是 CLS 为 0 的关键。封面是首屏最大元素(LCP),所以用 eager 加载并加 fetchpriority="high";正文图片一律原生懒加载。
页面切换:像翻书
三件事叠在一起:
- 鼠标悬停时预取链接(Astro 的 prefetch,2.4 KB)。
- Chromium 的 Speculation Rules:一段 JSON,浏览器在悬停时直接在后台预渲染下一页,零 JS。
- 跨文档 View Transition:纯 CSS 的
@view-transition { navigation: auto },切换时淡入淡出。
第三点踩过一个坑:浏览器会在新页面"第一次有机会渲染"时截取快照,而 Chromium 在 <body> 解析完之前就会渲染,于是过渡会落在一张空白页上、内容再弹出来。解决办法是一行标记,让渲染等到页面末尾解析完成:
<link rel="expect" href="#page-end" blocking="render">页面本身只有几十 KB,这一行不影响首屏时间,但换来了完整的过渡。
SEO 与 URL
- URL 全部 ASCII:中文标题自动转拼音作为 slug,旧的 Hexo 链接(带空格和中文)保存在 frontmatter 的
aliases里;构建会为每条旧链接生成带 canonical 的跳转页,Vercel 上还会配置成真正的 308 重定向,搜索引擎的收录可以平滑迁移。 - 每篇文章的 OG 图在构建时用 satori + resvg 生成(中文字体按文字子集下载并缓存),社交平台分享有图。
- sitemap 带
lastmod,RSS/Atom/JSON Feed 三种订阅源输出全文,JSON-LD 标注BlogPosting和面包屑。 - 中英双语:英文版是与原文并列的
.en.md文件,带hreflang与各自的订阅源;翻译可以人工写,也可以用 DeepL、Google 或大模型的接口批量生成,译文记录原文哈希,原文改动后会被标记为过期。
托管
站点部署在 Vercel,静态文件走 CDN。/_astro/* 这类带哈希的资源缓存一年且 immutable,HTML 走 must-revalidate;安全响应头和尾斜杠策略写在 vercel.json 里。构建在 CI 里跑 pnpm build,类型检查和 500 多个单元测试一起跑。
取舍与没做的事
- Disqus 的 iframe 会阻止 bfcache(浏览器的前进/后退缓存),这是第三方评论的代价;它只在滚到评论区时才加载,影响已经降到最小。
- Mermaid 的体积很大,所以只按需加载;如果以后图表多了,可以改成构建时渲染成 SVG。
- Pagefind 不做中文词干化,子串搜索够用。
- 拼音 slug 不如英文可读,所以提供了用大模型生成英文 slug 的命令,旧 slug 自动变成别名。
- 没有用任何运行时 UI 库。想要 HeroUI 那种交互组件时,也只会放进"点击后再加载"的岛屿里。
怎么衡量
每次改动都重新跑移动端 Lighthouse,同时用脚本测量文章页的内联 CSS 字节数和脚本总大小,超出预算就算失败。这些工具不难,难的是把"少做事"坚持成一条规矩。
整套代码是开源的。如果你也想自己搭一个,pnpm build 一条命令就能把 content/ 里的 Markdown 变成一个这样的站点。
评论