这个站有 82 个 HTML 页面、49 个工具、16 篇文章(均为 2026-09-05 测试时的快照基线,当前工具体系已扩展至 58 个),全部在构建期生成,运行时不依赖任何第三方域名。这类说法很容易写进首页,也很容易是错的——所以这篇不写“如何优化”,只做一件事:把每条路由实际下载的字节数一项一项数出来,然后看数完之后哪些结论和预期不一样。

三个具体问题:

  1. 一个只有文字的页面到底要下多少字节,大头是什么?答案不是 JavaScript,也不是 HTML——是字体,占首页的 58%。
  2. “零 JavaScript”这句话经不经得起数?正文页确实 0 个 JS 文件,但首页有 9,150 B、文章页有 9,449 B(brotli 后)的内联脚本,其中一个块就占掉整页 HTML 字节的 46%。
  3. 哪些字节是白花的,哪些是明知故犯?两者都有,这篇把价钱标出来。

度量方法(先说清,否则后面的数字没有意义):请求数和请求顺序来自真实浏览器(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/**/*.htmlsrc/ 得到的实际用字”,再和字体自身的覆盖范围求交。汉字显式排除: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

两篇的前两个脸相同,第三个不同——Size2Size3 是不同高度的分式定界符,取哪个取决于那篇文章里最高的那个分式。把这两笔加到各自“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,三种入口都是用户主动触发的。完全可以等到那一刻再去取。

为什么现在没改,两个理由:

  1. 离线可用是这个站的取舍之一。 内联的索引在一个已经加载完的页面上是断网可用的,改成 fetch 就不是了。这和“不引入外部依赖”是同一件事的延伸——把自己的功能挂在一次网络请求上,和挂在别人的 CDN 上,失败模式是类似的。
  2. 搜索这块目前零 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.mjsog-images.mjs 都在构建后阶段读写产物),所以不是新机制,只是还没做。量了,没修,价钱是 46 个工具页各一次往返。

6. 从共用分块里搬走的 25,922 字节

这是这次数字节最大的一笔收获,而且它的成因值得单独讲,因为它不是“配置没调好”,是打包器做不到的一件事。

每个工具页都有一段构建期渲染的编辑正文:About 段落和 FAQ,中英各一份。它们在 HTML 里,读者看得见。但它们同时也进了 46 个工具页共用的那个 JS 分块——一个字节都不会被浏览器读到。

链条是这样的:src/scripts/tools/main.ts 为了知道当前页是哪种工具,import 了注册表,取一个 entry 的 kindconfig。于是整个注册表的 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 有四条:

  1. 点名的 6 条纯文字路由不得出现任何指向 /_astro/<script src> 点名路由而不是写成“所有没有交互组件的页面”——后者会在新增一个交互页时静默扩大豁免范围,而这条断言的意义正是“文字页面永远不该有模块脚本”。它防的是这类事故:某个共享组件里的一个 <script> 忘了写 is:inline,从此每篇文章都开始下模块。
  2. 三条真实的正文短语必须出现在 HTML 里,且不得出现在任何 _astro/*.js 里。 两半都断言是刻意的:只断言“不在 JS 里”的话,把正文整段删掉也能通过。
  3. main 分块 brotli 后小于 45,000 B。 是上限不是目标;现值 35,265 B,留了余量给后续工具。真有正当理由越线时,重新量一遍再明确地抬高,而不是顺手加 1 KB。
  4. 内联搜索索引的边际 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 解析器那篇