这个站有 82 个 HTML 页面、49 个工具、16 篇文章(均为 2026-09-05 测试时的快照基线,当前工具体系已扩展至 58 个),全部在构建期生成,运行时不依赖任何第三方域名。这类说法很容易写进首页,也很容易是错的——所以这篇不写“如何优化”,只做一件事:把每条路由实际下载的字节数一项一项数出来,然后看数完之后哪些结论和预期不一样。
三个具体问题:
- 一个只有文字的页面到底要下多少字节,大头是什么?答案不是 JavaScript,也不是 HTML——是字体,占首页的 58%。
- “零 JavaScript”这句话经不经得起数?正文页确实 0 个 JS 文件,但首页有 9,150 B、文章页有 9,449 B(brotli 后)的内联脚本,其中一个块就占掉整页 HTML 字节的 46%。
- 哪些字节是白花的,哪些是明知故犯?两者都有,这篇把价钱标出来。
度量方法(先说清,否则后面的数字没有意义):请求数和请求顺序来自真实浏览器(Playwright 驱动 Chromium,每条路由一个全新 context,无缓存、无预热)。字节数是“网线上的字节”:本地 astro preview 不做压缩,所以文本资源的体积是把响应体离线跑 brotli(quality 11)算出来的——这就是 Cloudflare 实际传输的口径;字体按原样计,原因见第 2 节。度量环境没有外网,所以那个刻意配置的 Cloudflare 分析信标不在下面任何一张表里(它是全站唯一的第三方请求,见小结)。
这篇不给时间数字。 本地 preview 量出来的 FCP 只反映本机磁盘和一个空闲的 CPU,换到真实网络上没有任何预测力。字节数和请求数是可复现的事实,首屏时间要看真实 RUM——把本机的 356 ms 写成“秒开”是自欺。
1. 先摆账本
三条代表性路由,按资源类型分组:
| 路由 | 请求 | HTML | CSS | JS | 字体 | 合计 |
|---|---|---|---|---|---|---|
/(首页) |
7 | 15,549 | 5,557(4 个) | 0 | 29,124(2 个) | 50,230 |
/blog/password-entropy-and-secure-random/ |
10 | 24,230 | 7,068(4 个) | 0 | 75,460(5 个) | 106,758 |
/finance/loan-payment/ |
9 | 13,009 | 7,063(3 个) | 37,633(3 个) | 29,124(2 个) | 86,829 |
单位都是字节,brotli 后。第一行就是这篇的主结论:在一个只有文字的页面上,最大的一块是字体(58%),第二块是 HTML 自己(31%),JavaScript 是 0。 这决定了后面每一节该往哪使劲——为一个已经没有 JS 的页面做 JS 优化,收益上限是零。
还有一个更有意思的差额。如果只读构建产物的 HTML,把里面所有 <link> 和 <script src> 数一遍,三条路由都是 7 个请求。浏览器实际发了 7、10、9 个:
| 路由 | HTML 里能看见 | 浏览器实际发 | 差额 |
|---|---|---|---|
/ |
7(50,230 B) | 7(50,230 B) | 无 |
| 密码熵那篇 | 7(60,422 B) | 10(106,758 B) | +46,336 B,3 个 KaTeX 字重 |
/finance/loan-payment/ |
7(49,227 B) | 9(86,829 B) | +37,602 B,main 分块与算例引擎 |
差额全部来自同一类资源:必须先下载并解析另一个文件,才能知道它存在。这两笔差额分别是第 3 节和第 5 节的主题,而且它们的算术能对上到个位——这也是这份账本能自证的地方。
2. 字体:唯一压不动的那一块
首页 50,230 B 里有 29,124 B 是两个 woff2(正文与粗体)。它们的特别之处在于:服务器的压缩对它们完全无效。woff2 的容器格式内部已经用 brotli 压过一遍,再压一次不但不省,还会变大 4 字节:
| 文件 | 原样 | 再 brotli 一遍 |
|---|---|---|
atkinson-regular.woff2 |
14,376 B | 14,380 B |
atkinson-bold.woff2 |
14,748 B | 14,752 B |
所以这一块没有“配置一下就便宜了”的余地,唯一的办法是让字体里的字形变少。scripts/subset-fonts.py 做的就是这件事,从上游 OFL 授权的 TTF 重新生成子集。
但功劳该分清。同一对字体走三条路:
| 处理 | 两个文件合计 | 相对上游 |
|---|---|---|
| 上游 TTF | 109,604 B | —— |
| 只转 woff2,不裁字形 | 47,028 B | 省 57% |
| 转 woff2 + 子集化(现状) | 29,124 B | 再省 38%(17,904 B) |
也就是说大头是容器格式的功劳,不是子集脚本的。子集化省下的 17,904 B 是真的(每个首次访客都要付),但如果谁把“我做了字体子集化”当成三倍收益来讲,那三分之二的功劳属于 woff2。
子集规则有一条容易写错,值得单独说:ASCII 0x20–0x7E 必须整段保留,不能按“站内出现过的字符”来裁。 因为工具的输出是运行时才产生的——JSON 格式化器、SQL 格式化器、密码生成器打出来的字符,构建期扫描 dist/ 是看不到的。所以保留集合是“ASCII 全段 ∪ 手工维护的符号底线 ∪ 扫描 dist/**/*.html 与 src/ 得到的实际用字”,再和字体自身的覆盖范围求交。汉字显式排除:Atkinson 一个 CJK 字形都没有,中文由系统栈(PingFang SC / 微软雅黑)来排。结果是正文字体从 342 个码位、369 个字形降到 153 个码位、167 个字形。
两个决定是“量过之后不做”:
- 不加
--no-hinting。 能再省 11.6 KB,但 1080p 仍是最主流的屏幕密度,去掉 hinting 之后小字号的字形边缘是肉眼可见地变化的。11.6 KB 换清晰度,不换。 - 上游版本必须钉住。 必须是 OFL 1.1 的 v1.006;Astro 自带的那份 2020 年 v1.002 的授权条款禁止衍生作品,拿它做子集是不合规的。
font-display 这里用 swap:Atkinson 只排拉丁字母和数字,系统回退字体的度量足够接近,先用回退字体把文字显示出来是划算的。下一节会看到 KaTeX 必须反过来。
3. 公式页的 10 个请求:字体是按字重取的
密码熵那篇的静态 HTML 只暴露 7 个请求,浏览器发了 10 个。多出来的 3 个是 KaTeX 的字重文件,藏在 katex.css 的 @font-face 里——浏览器要先下 HTML、发现样式表、下它、解析,才知道要取哪几个脸。
KaTeX 的 20 个字重全部自托管(这个站没有任何第三方资源),但一页只会取它实际落到的那几个:
| 页面 | 取到的字重 | 字节 |
|---|---|---|
| 密码熵那篇 | Main-Regular 26,272 + Math-Italic 16,440 + Size3 3,624 |
46,336 |
| 复利与 IRR 那篇 | Main-Regular 26,272 + Math-Italic 16,440 + Size2 5,208 |
47,920 |
两篇的前两个脸相同,第三个不同——Size2 和 Size3 是不同高度的分式定界符,取哪个取决于那篇文章里最高的那个分式。把这两笔加到各自“HTML 里能看见”的 60,422 / 55,193 上,正好是浏览器观测到的 106,758 / 103,113,一个字节不差。
这种“按脸取”只在 @font-face 指向独立文件时才成立。构建工具会把小于阈值的资源内联成 base64——其中一个 KaTeX 字重恰好只有 3,624 B,被内联之后就变成了所有公式页无条件下载的 4,840 B base64。那件事和它的修法(把 assetsInlineLimit 写成函数)已经写在第 12 篇的第 10 节里,这里不重复。
另外两个和字体有关的细节:
katex.css是 22,418 B raw / 2,815 B brotli,只有含公式的文章才加载,没有公式的文章一个字节都不付(E2E 里有一条用例钉着这个)。- KaTeX 的 20 个脸全部是
font-display: block,和 Atkinson 相反。因为回退字体里既没有 ∑ 也没有四号大括号,而 KaTeX 是按真实字体的度量逐个 span 摆位置的——用swap的结果是公式先以错误的尺寸排一遍,字体到位后再整体跳一下。这条也在 E2E 里钉住了。
4. “0 个 JS 文件”不等于“0 字节 JavaScript”
第 1 节的表里,首页和文章页的 JS 列都是 0。这不是修辞——那两条路由的 HTML 里确实没有任何 <script src> 指向构建产物,关掉 JavaScript 整个页面照样能读。
但它们有内联脚本。首页一共 4 个块:
| 块 | 原始 | brotli | 能不能挪走 |
|---|---|---|---|
| 主题引导(读 localStorage 定深浅色) | 470 B | 218 B | 不能——外链就会先闪一下白底 |
| JSON-LD 结构化数据 | 370 B | 244 B | 不是脚本,是给爬虫的数据 |
| 语言切换 | 2,644 B | 803 B | 可以,但只值 803 B |
| 工具搜索索引 | 26,766 B | 7,885 B | 这就是问题所在 |
| 合计 | 30,250 B | 9,150 B |
先说一个度量上的坑。上面那 7,885 B 是把这个块单独拿出来压得到的,而它的真实成本比这个低:把它从整页 HTML 里删掉,整页只少了 7,156 B。差出来的 733 B 是它和周围 HTML 共享 brotli 字典赚回来的——索引里的 "category":"converters" 和页面上的链接文本是同一批字符串。衡量一个内联块的成本必须用整页的差值,孤立地压会系统性高估。 项目里那条 E2E 断言就是这么算的。
按差值口径,这个索引的占比是这样的:
| 路由 | 整页 HTML | 索引的边际成本 | 占比 |
|---|---|---|---|
/ |
15,549 B | 7,156 B | 46% |
| 密码熵那篇 | 24,230 B | 7,761 B | 32% |
/finance/loan-payment/ |
13,009 B | 7,563 B | 58% |
再加上 ToolSearchModal.D7g1rsLY.css(5,732 B raw / 1,303 B brotli,也在每个页面上),一个多数访客大概一次都不会打开的搜索框(这是假设——站内的分析只有页面级数据,没有埋点统计搜索框的打开率,所以下面的取舍是按“打开率低”这个前提做的),在每个工具页上要 8,866 B brotli——占该页 HTML + CSS + 字体(49,227 B)的 18%,把 main 分块也算进来则是全部 86,829 B 的 10%。
而且它是逐页重复的。82 个页面里有 74 个带这个索引(不带的 8 个是 5 个别名跳转页、1 个中转页,以及 /calendar/ 和 /clock/ 两个刻意不带导航栏的独立页),合计 1,981,942 B 原始 / 583,786 B brotli,全都是同一份 49 个工具的数据。
内联还是取一次:交叉点在哪
- 内联:每页 7.2–7.8 KB × 访问的页面数。省一次往返,且不占缓存。
- 外链 +
immutable:首访约 7.9 KB,之后每页 0 B,代价是多一次往返。
所以交叉点就在 N = 1:只看一个页面就走(搜索引擎带来的绝大多数访问)内联赢;看两页起,外链开始赢,而且差距随页数线性拉开。
还有第三种做法,而且明显比前两种都好:这个索引只在搜索框被打开时才有用——点击、按 /、按 Ctrl/Cmd+K,三种入口都是用户主动触发的。完全可以等到那一刻再去取。
为什么现在没改,两个理由:
- 离线可用是这个站的取舍之一。 内联的索引在一个已经加载完的页面上是断网可用的,改成 fetch 就不是了。这和“不引入外部依赖”是同一件事的延伸——把自己的功能挂在一次网络请求上,和挂在别人的 CDN 上,失败模式是类似的。
- 搜索这块目前零 E2E 覆盖。 要改一个 74 个页面共有的行为,先得有测试。这个项目吃过一次静默失效的亏(一个计算器的按键坏了整整一周才被发现),所以顺序是先补测试再动。
这一节的结论就是把价钱标出来:7.2–7.8 KB/页、74 个页面、可省,欠一套测试。
5. 一个 27 字节的桩,和它后面 35 KB 的分块
工具页的 HTML 里有唯一一个 <script src>,内容是这样,27 个字节:
import"./main.BqV5T0g2.js";
(brotli 后 31 B——比原文还大,短到压缩只剩下开销。)
问题不在它的体积,在它藏起来的东西。浏览器要拿到工具的逻辑,必须串行发三次请求:
HTML ─解析─▶ 桩(27 B) ─解析─▶ main 分块(122,695 B raw / 35,265 B brotli)
main 分块是 46 个工具页共用的调度器,也是“第一个工具变得可交互”的关键路径。而在它前面那个 27 字节的文件,唯一的作用就是让浏览器晚一个往返才知道它存在。全站 0 处 modulepreload(grep 过整个产物)。
顺带发现的一件小事:四个 [slug].astro 路由(calculators / converters / finance / tools)各生成了一个桩,四个文件逐字节相同、只有哈希不同:
_slug_.astro_astro_type_script_index_0_lang.BZuB1Ahs.js
_slug_.astro_astro_type_script_index_0_lang.DbFzwqOK.js
_slug_.astro_astro_type_script_index_0_lang.Df8xO0YG.js
_slug_.astro_astro_type_script_index_0_lang.x-5ajGim.js
108 个字节、4 个请求、4 个缓存条目,做同一件事。
这条链子最长的一页是 /text/markdown-preview/:16 个请求、211,358 B,其中 110,848 B 是 JavaScript——因为它在文档里出现第一个公式时才会动态 import KaTeX,而那个 chunk 自己就有 63,345 B(brotli 后)。那 258,633 B 原始的 chunk 不被任何 HTML 引用,只能通过运行时的 import() 到达,所以它连“HTML 里能看见”这一栏都进不去。那是正确的按需加载,逐字节的账已经写在第 12 篇的第 8 节里——但它和上面那个 27 字节的桩是同一个物理事实的两面:浏览器只能发现它已经解析过的东西。区别只在于一个是有意让它晚发现,另一个是白晚了一步。
修法是构建期后处理 HTML,把哈希后的文件名写进 <link rel="modulepreload">——这需要一个 Astro integration 才能拿到最终文件名,而这个仓库里已经有两个先例(llms-txt.mjs 和 og-images.mjs 都在构建后阶段读写产物),所以不是新机制,只是还没做。量了,没修,价钱是 46 个工具页各一次往返。
6. 从共用分块里搬走的 25,922 字节
这是这次数字节最大的一笔收获,而且它的成因值得单独讲,因为它不是“配置没调好”,是打包器做不到的一件事。
每个工具页都有一段构建期渲染的编辑正文:About 段落和 FAQ,中英各一份。它们在 HTML 里,读者看得见。但它们同时也进了 46 个工具页共用的那个 JS 分块——一个字节都不会被浏览器读到。
链条是这样的:src/scripts/tools/main.ts 为了知道当前页是哪种工具,import 了注册表,取一个 entry 的 kind 和 config。于是整个注册表的 46 个 entry 对象都是可达的。Rollup 能摇掉一个没人引用的顶层 export——搜索关键词表 TOOL_KEYWORDS 就从来没进过任何产物,grep 可验——但它摇不掉一个必须保留的对象上的属性:它无法证明运行时没人会去读 entry.about。
所以那些正文原封不动地进了分块:76,008 B 原始 / 25,922 B brotli,占该分块的 42%,而且和同一个页面 HTML 里已经渲染好的文字完全重复。
修法只能是结构性的,不是加配置:正文搬到 src/tools/content.ts,只有构建期组件 ToolShell.astro 会 import 它,浏览器端的入口再也够不着。main 分块从 61,187 B brotli 降到 35,265 B。
同一个机制还剩一层没动:name / nameZh / description / descriptionZh 这 181 个纯展示字符串还在分块里,11,685 B utf-8,brotli 后值 3,990 B(分块的 11%)。它们和 config 挂在同一个对象上,搬走要改 entry 的形状或者多套一层 key。而这个分块是 immutable 缓存、每个访客只付一次——26 KB 和 4 KB 的性价比不是一回事,所以这一层先记下,不动。
7. _headers:默认配置把内容哈希白白浪费了
Cloudflare Workers Static Assets 对所有资源的默认响应头是 Cache-Control: public, max-age=0, must-revalidate。也就是说连 /_astro/ 下自带内容哈希的文件,都会在每次导航时回源校验一遍——同一个 URL 的内容永远不会变,却还是每次都问一遍,浏览器磁盘缓存等于白放。
所以 public/_headers 分三档:
| 路径 | 策略 | 理由 |
|---|---|---|
/_astro/* |
max-age=31536000, immutable |
文件名自带内容哈希,同一 URL 内容永不改变 |
/og/* |
max-age=86400,不加 immutable |
分享卡片每次构建重新生成,但 URL 是 /og/<slug>.png 不带哈希;加了 immutable 就意味着改了标题的分享图长期不更新 |
| HTML | 保持默认的 must-revalidate |
部署后要立刻可见 |
中间那档是唯一需要想一想的:immutable 的正确性前提是“URL 变则内容变”,只要有一类资源不满足这个前提,就不能一刀切。
顺带一笔账,是关于“产物大小”这个指标本身的。整个 dist/ 是 7,396,810 B,其中 dist/og/ 占 1,765,771 B——24% 的产物字节,读者永远不会下载,只有社交平台的爬虫会去抓。82 个 HTML 页面是 4,668,675 B 原始 / 1,139,790 B brotli,中位数每页 13,237 B,最大的是 SI 单位那篇 25,984 B,最小的 1,545 B。
结论:静态站的成本是按页付的,不是按站付的。 “产物 7 MB”和“访客付 50 KB”是两笔完全不同的账,把前者当性能指标是没有意义的——再加 100 篇文章,首页的字节数一个都不变。
8. 把字节钉住的四条断言
上面这些数字如果不写进测试,下一个改动就会悄悄推翻它们。e2e/perf-budget.spec.ts 有四条:
- 点名的 6 条纯文字路由不得出现任何指向
/_astro/的<script src>。 点名路由而不是写成“所有没有交互组件的页面”——后者会在新增一个交互页时静默扩大豁免范围,而这条断言的意义正是“文字页面永远不该有模块脚本”。它防的是这类事故:某个共享组件里的一个<script>忘了写is:inline,从此每篇文章都开始下模块。 - 三条真实的正文短语必须出现在 HTML 里,且不得出现在任何
_astro/*.js里。 两半都断言是刻意的:只断言“不在 JS 里”的话,把正文整段删掉也能通过。 main分块 brotli 后小于 45,000 B。 是上限不是目标;现值 35,265 B,留了余量给后续工具。真有正当理由越线时,重新量一遍再明确地抬高,而不是顺手加 1 KB。- 内联搜索索引的边际 brotli 成本小于 12,000 B/页。 阈值的含义写在注释里:超过这个数它就不再是“便宜的那个选择”,该换成取一次的独立文件。
四条的共同点是它们读 dist/ 而不是读页面。这类回归的形态是“页面功能完全正常,只是变重了”——按页面写的功能测试永远看不见。全站 91 个用例在 CI 里构建之后、部署之前跑,红了就阻断上线。
小结
- 首页 7 个请求、50,230 B(brotli 后)、0 个 JS 文件;其中 58% 是两个字体文件,而字体是唯一压不动的一块(再 brotli 一遍反而 +4 B)。
- 字体从 109,604 B 降到 29,124 B,但功劳要分清:57% 来自 woff2 容器,剩下 38% 才是子集化。
- “0 个 JS 文件”和“0 字节 JavaScript”不是一回事:内联脚本 9,150 B brotli,其中工具搜索索引占首页 HTML 的 46%、工具页的 58%,且在 74 个页面上各存一份。
- 数完之后最该改的三件事和它们的价钱:搜索索引 7.2–7.8 KB/页(欠一套 E2E)、
modulepreload一次往返 × 46 页(欠一个构建期 integration)、注册表里剩下的展示字符串 3,990 B(要改 entry 形状)。三件都测了,都没动,理由都写在上面——量出来但没修,和没量过,是两种完全不同的状态。 - 已经做掉的那件:把编辑正文从共用分块里搬出去,每个工具页少下 25,922 B,成因是打包器摇得掉未使用的 export、摇不掉必须保留对象上的属性。
- 唯一的第三方请求是刻意配置的 Cloudflare 分析信标(脚本来自
static.cloudflareinsights.com,数据 POST 到顶级域的/cdn-cgi/rum),度量环境无外网,因此不在上面任何一张表里。除它之外,这个站在浏览器里不向任何外部域名发请求——包括公式、字体、图标和搜索。
想看这些页面本身:49 个工具的总目录、数学公式最多的那篇、自己写 Markdown 解析器那篇。