一、技术难点
Day30 把「字节怎么被组织、被请求、被缓存」讲完了,但留了一个没说破的问题:那 2190KB 里,最大的那一块到底是什么。拆包、tree-shaking、contenthash,全部作用在 JS 和 CSS 上;而一个真实内容站的首屏字节里,图片加字体通常占七到九成。也就是说,Day30 的所有技巧,争的是那条剩下的尾巴。
这一篇管的就是主战场。而主战场上的三个「常识」,我都是在把它们写成断言之后被自己的脚本推翻的:
- 「换格式是图片优化的大头」 ------错。同一张 1920×1080 的实拍照片,等质量前提下 JPEG→AVIF 省 46.3% (300.11KB → 161.2KB),听起来已经是天花板了;但把 1920 降到 960,JPEG→JPEG 就省 71.0% (300.11KB → 87.02KB)。放进首屏瀑布里,只换格式值 694ms ,只调尺寸值 1065ms 。更扎心的一句:960 的 JPEG(87.02KB)比 1920 的 AVIF(161.2KB)还小 46%------降了尺寸的老格式,打赢了升了格式的老尺寸。
- 「宽度减半,字节减到四分之一」 ------不成立。面积比确实是 4.00x,实测字节比只有 3.45x(JPEG)/ 3.35x(WebP)/ 3.09x(AVIF) 。所以
srcset的档位不能按面积外推,只能按实测刻度铺。这个小偏差在 AVIF 上最大(3.09x),恰恰是大家最想上的那个格式。 - 「字体的 CLS 用
font-display解决」 ------font-display一个位移都消不掉,它只决定把代价放在白屏、位移、丢字体 三者中的哪一个。真正让位移归零的是度量覆盖 :我把系统里 33 个 fallback 字体逐个解出来配对,20px 字号、40 行正文,不做覆盖时位移最大 271.9px、平均 131.6px;做完覆盖,最大 0.0000px ,平均字宽误差从 25.73% 降到 0.000000%。
还有一条附赠的:「quality 数字跨格式可比」 。在 1920 上,WebP 的 q=85 是 239.99KB / 38.25dB ,JPEG 的 q=85 是 300.11KB / 37.35dB ------同一个数字,不同的画质、不同的体积。任何「把 q 从 85 提到 90 试试」的调优都是瞎调;跨格式比较只有一条合法路径:用 PSNR 对齐,在等质量前提下比字节。
本篇数字来自三份可运行脚本,合计 101 条不变量断言 ,失败即 exit 1:图片部分是真实编码 (Pillow/libwebp/libavif 实跑,78 组编码),字体部分是真实字体表解析 (自己解 head/hhea/cmap/loca/glyf,零依赖),首屏瀑布是确定性模型但吃真实字节。边界在文末「运行环境」逐条写清。
难点一:图片的三层决策,只有一层写在构建配置里
「优化图片」是三个独立决策,绝大多数团队只做了第三个:
| 层 | 问题 | 谁决定 | 常见错误 |
|---|---|---|---|
| 1 要不要发 | 这张图首屏需要吗 | loading="lazy" / 条件渲染 |
首屏图也 lazy(见 2.3) |
| 2 发多大 | 这张图要多少像素 | srcset + sizes |
不写 sizes |
| 3 发什么格式 | 用什么编码 | <picture> / Accept 协商 |
只上 WebP |
第二层被低估,是因为它的默认值极坏:sizes 不写时按 100vw 算。于是一个 390px 视口、占 1/3 宽的卡片图,在 DPR=3 下被当成 1170px 宽的需求------多下 5.98 倍字节(实测 13.0KB → 77.8KB,见 2.1 表)。而它连一行构建配置都不需要改,纯粹是 HTML 里少了一个属性。
难点二:LCP 由「发现 → 排队 → 解码」三段决定,不由字节数单独决定
同一张图、同样的字节,因为姿势不同,到达时间可以差 1768ms(S1 2520ms → S5 752ms)。四个属性各管一段,串错位置就白给:
| 段 | 决定因素 | 相关属性 | 用错的后果 |
|---|---|---|---|
| 发现 | 解析器何时看到 URL | preload、是否 JS 注入 |
晚 350ms 发起(实测) |
| 排队 | 优先级与并发 | fetchpriority、loading |
排到埋点脚本后面(实测 +29ms) |
| 解码 | 在主线程还是别的线程 | decoding |
主线程被占 5~20ms(实测 +5ms) |
拆开说 loading="lazy" 的真实语义,因为它是首屏图被坑得最多的一个属性。它不是一个「延后 300ms」的定时器,而是两层动作:
- 推迟发现:懒加载图的 URL 不参与解析器的预加载扫描(preload scanner)。浏览器要等布局完成、拿到元素的位置,才在「距视口 N 像素」的阈值内决定发起请求。首屏图虽然一布局就在视口里,但这一段「等布局」本身就是纯延迟------模型里是 120ms(540ms − 420ms)。
- 压低优先级 :懒加载图即使发起了,拿到的也是 Low 优先级。于是在带宽紧张时,它排在 Medium 层的 JS、埋点脚本后面。这解释了模型里那 29ms:它不是被延迟了,是被排到了后面。
两条叠加的后果是:lazy 的代价与你页面有多少别的资源成正比。一个只有一张图和一个 CSS 的页面,代价接近 0;一个加载了三方埋点、A/B 实验、字体、推荐位的页面,代价可以是几百毫秒。而首屏图恰好是唯一一张「绝不能排在任何东西后面」的图。
注意这三段的量级:发现 350ms、排队 29ms、解码 5ms 。所以「给首屏图加 preload」的收益上限,取决于你有没有真的把发现提前------而不是取决于你加了多少个 preload。
难点三:字体的位移来自「行盒 = ascent + descent + lineGap」,而这三个数每款字体都不同
浏览器 line-height: normal 时,行盒高度就是字体自身的三个度量之和(以 em 为单位)。我把两款真实字体解出来对比:
| 度量 | Arial Unicode | Geneva |
|---|---|---|
| unitsPerEm | 2048 | 2048 |
| ascender | 2189 | 2048 |
| descender | -555 | -512 |
| lineGap | 0 | 171 |
| 行高(normal) | 133.984% | 133.350% |
差 0.006348em 。20px 字号、40 行正文:5.1px 。看起来无害------直到你把 fallback 换成 Apple Symbols:同样的 20px×40 行,位移 271.9px 。你的 CLS 有多大,取决于 fallback 链里写了什么字体 ,而这件事在你的 CSS 里通常只有一行 font-family: -apple-system, Helvetica, sans-serif。
难点四:CJK 字体 60.9% 的体积在汉字字形里,而你的页面只用得到 6.7% 的汉字
把一个 22.20MB 的中日韩字体按 Unicode 区块拆账(glyf 逐字形字节,见 2.6):
| 区块 | 字节 | 占 glyf |
|---|---|---|
| CJK 汉字 | 13.01 MB | 60.9% |
| emoji / 数学符号 | 0.84 MB | 3.9% |
| 标点 / 符号 | 0.33 MB | 1.6% |
| 谚文 Hangul | 0.33 MB | 1.5% |
| 全角 / 兼容 | 0.18 MB | 0.9% |
而本系列 Day01~Day30 这 30 篇文章的全部正文,唯一字符 1583 个,其中汉字 1410 个 = 字体汉字集 20902 的 6.7%。其余 93.3% 的字形,是你在为「某个用户可能输入的生僻字」预付的带宽。
二、完整解法
2.1 先定「该发多大」,再定「发什么格式」
顺序不能反。格式决定每像素多少 bit,尺寸决定有多少像素------后者是指数项 。实测的每条曲线上,bpp 随宽度上升,说明小图每像素更贵:
| 宽度 | JPEG q85 | WebP q85 | AVIF q70 | PNG |
|---|---|---|---|---|
| 1920×1080 | 300.11 KB | 239.99 KB | 188.35 KB | 2602.59 KB |
| 1280×720 | 145.68 KB | 120.37 KB | 98.6 KB | 1217 KB |
| 960×540 | 87.02 KB | 71.61 KB | 61.04 KB | 687.22 KB |
| 640×360 | 41.03 KB | 34.57 KB | 29.0 KB | 309 KB |
| 480×270 | 24.41 KB | 20.39 KB | 17.46 KB | 176.58 KB |
| 320×180 | 12.07 KB | 10.03 KB | 8.80 KB | 80.48 KB |
为什么字节比只有 3.09~3.45x,而不是面积比 4.00x?因为「字节 ÷ 像素」不是常数,它有两个来源:
- 固定开销:文件头、EXIF、色彩配置、元数据与编码器的原子结构,在小图上被摊到更少的像素上。AVIF 尤其明显------它按 superblock(典型 64×64)组织预测,一张 320px 宽的图只有 5 列 superblock,边缘那一列几乎全是补边浪费,而 1920px 有 30 列。
- 高频密度上升 :1920 缩到 960 是重采样 ,不是抽样。LANCZOS 会把高频细节压进更低的分辨率里,于是 960 的每一像素承载的信息量高于 1920 的平均像素。编码器吃的是信息量,不是像素数。
这解释了 AVIF 的比值最小(3.09x)这个反直觉结果:它的 superblock 结构在小图上浪费更多,而它的熵编码又在大图上更吃得开。对你的实际意义只有一条:别按面积外推 srcset 档位,否则你会以为 640 档只要 1920 档的 1/9,实际是 1/7.3(41.03KB vs 300.11KB)。
srcset 档位怎么铺?不是按面积开方,是按你要的 DPR 覆盖 :给出 1x / 1.5x / 2x / 3x 的上界,取上界所落在的档。以 390px 视口的 1/3 宽卡片为例:
| 写法 | 需求宽度 | 落点 | 下载(AVIF 等质量前沿) | 相对最优 |
|---|---|---|---|---|
sizes="(max-width:600px) 33vw, 300px" |
390×0.33×3 = 386px | 480 档 AVIF | 13.0 KB | 1.00x |
不写 sizes(默认 100vw) |
390×3 = 1170px | 1280 档 AVIF | 77.8 KB | 5.98x |
一个属性,64.8KB,在 200KB/s 的链路上就是 324ms 的 LCP。而这跟构建配置、CDN、格式都无关。
正确的 HTML 长这样(sizes 必须描述布局,不是图片的原始像素):
html
<picture>
<!-- 现代格式优先,AVIF 落在等质量前沿的最左端 -->
<source type="image/avif" srcset="
/img/hero-480.avif 480w,
/img/hero-960.avif 960w,
/img/hero-1920.avif 1920w"
sizes="(max-width: 600px) 33vw, 300px">
<source type="image/webp" srcset="
/img/hero-480.webp 480w,
/img/hero-960.webp 960w,
/img/hero-1920.webp 1920w"
sizes="(max-width: 600px) 33vw, 300px">
<img src="/img/hero-960.jpg" alt="..." width="960" height="540"
fetchpriority="high" decoding="async">
</picture>
三条容易被忽略的规则:
sizes的媒体查询只看布局 。容器宽度、grid列数、max-width都要算进去;写成100vw等于没写。<img>上的width/height是给浏览器算aspect-ratio用的,不是显示尺寸。写原始像素、交给 CSS 缩放,才能白拿「零 CLS」。- AVIF 的编码时间是 JPEG 的十几倍,构建期必须并行 + 缓存(按源图 hash + 参数做 key),否则 CI 会先崩。
2.2 等质量对比:把三家的 quality 刻度对齐到 PSNR
跨格式比字节,必须先比画质。我的做法是在「PSNR-字节」平面上插值,把目标定成该宽度下 JPEG q85 的 PSNR,问每一家「达到这个质量要多少字节」:
| 宽度 | 目标 PSNR | JPEG | WebP | AVIF |
|---|---|---|---|---|
| 1920 | 37.35dB | 300.1 KB | 206.0 KB (-31.4%) | 161.2 KB (-46.3%) |
| 1280 | 36.19dB | 145.7 KB | 96.4 KB (-33.8%) | 77.8 KB (-46.6%) |
| 960 | 35.65dB | 87.0 KB | 56.8 KB (-34.8%) | 46.5 KB (-46.5%) |
| 640 | 35.03dB | 41.0 KB | 26.4 KB (-35.7%) | 21.8 KB (-47.0%) |
| 480 | 34.64dB | 24.4 KB | 15.4 KB (-37.1%) | 13.0 KB (-46.9%) |
| 320 | 34.13dB | 12.1 KB | 7.7 KB (-36.6%) | 6.6 KB (-45.6%) |
这张表是全篇最有用的东西,因为它把「换格式到底值多少」变成了一个与宽度无关的常数 :WebP 稳定省 3137%,AVIF 稳定省 46 47%。格式的收益与图片大小、内容无关------这正好解释了为什么「只换格式」赢不了「调尺寸」:前者是固定的乘数,后者是量级。
顺带解释一下为什么 q=85 在三家编码器里不是一回事。JPEG 的 quality 是「量化表缩放因子」------一张 8×8 DCT 系数上的统一除数;WebP 的 quality 同时管量化步长和熵编码的取舍;AVIF 的 quality 更接近 libavif 内部换算出来的 QP 值。三个数字背后是三套互不相干的量化模型,只是恰好都叫 0~100。 所以「把 quality 调高一点画质能好多少」这个问题,只能在自己家里问;跨格式一律用 PSNR(或 SSIM/肉眼)对齐。
生成这张表的代码就是插值那十行:
python
def iso_bytes(fmt, w, target_psnr, rows):
"""在 PSNR-字节 平面上线性插值,求达到 target_psnr 所需字节。"""
pts = sorted((r["psnr"], r["bytes"]) for r in rows
if r["width"] == w and r["format"] == fmt)
for (p0, b0), (p1, b1) in zip(pts, pts[1:]):
if p0 <= target_psnr <= p1:
t = 0.0 if p1 == p0 else (target_psnr - p0) / (p1 - p0)
return b0 + t * (b1 - b0)
return pts[0][1] if target_psnr <= pts[0][0] else pts[-1][1]
PSNR 的诚实边界 :它对模糊宽容、对块效应惩罚不够,而且不理解人眼对比度掩蔽。所以它只适合同一张图、同一族算法的相对比较(也正是上面这张表的用法),不能当作「我的图够不够好看」的标准答案。最终验收要人眼看暗部和高频细节。
2.3 加载姿势:fetchpriority / preload / decoding / loading 各管一段
四个属性常被混着用,实际分工很清:
| 属性 | 作用段 | 正确用法 | 反模式 |
|---|---|---|---|
loading="lazy" |
发现 | 只给首屏外的图 | 首屏图加 lazy(实测 +29ms,且排到埋点脚本后) |
fetchpriority="high" |
排队 | 只给唯一那张 LCP 图 | 给所有图=等于没给 |
fetchpriority="low" |
排队 | 首屏外的装饰图 | ------ |
preload (as=image) |
发现 | 图由 CSS/JS 注入时 | 已经写在 HTML 里还 preload(重复发现,实测 0 收益) |
decoding="async" |
解码 | 除需要同步测量尺寸外都用 | sync:实测 +5ms,且阻塞主线程 |
实测的九种姿势(960AVIF 是同一张图,字节都是 46.5KB):
| # | 场景 | 图 KB | 发起 | 完成 | LCP |
|---|---|---|---|---|---|
| S1 | 1920 JPEG,eager | 300.1 | 420ms | 2512ms | 2520ms |
| S2 | 1920 WebP | 206.0 | 420ms | 2042ms | 2050ms |
| S3 | 1920 AVIF | 161.2 | 420ms | 1818ms | 1826ms |
| S4 | 960 AVIF(sizes 选对) | 46.5 | 420ms | 1177ms | 1185ms |
| S5 | S4 + fetchpriority=high |
46.5 | 420ms | 744ms | 752ms |
| S6 | S5 + preload |
46.5 | 420ms | 744ms | 752ms |
| S7 | S6 + loading=lazy |
46.5 | 540ms | 773ms | 781ms |
| S8 | S4 但图由 JS 注入 | 46.5 | 770ms | 1245ms | 1253ms |
| S9 | S4 + decoding=sync |
46.5 | 420ms | 1177ms | 1190ms |
几个非直觉的读数:
- S6 = S5 :图已经在 HTML 里,
preload一个字节都不省。preload只在「发现得晚」时才有价值(S8 那种情况)。 - 发现晚 350ms,完成只晚 68ms(小图)/ 0ms(大图) 。因为早开始的那 350ms 里,图片本来就在和 JS、埋点抢带宽------发现提前的收益上限,是那段本来空转的带宽。大图自己就是瓶颈,早开始也在等带宽,所以它反而不在乎晚发现。这条推翻了我原来「字节越多越怕晚发现」的直觉。
- S3(161.2KB)比 S5(46.5KB)慢 1074ms,但两者的画质完全一样。这就是过发的价格。
对应到代码,一条最小正确的首屏图写法:
html
<!-- 首屏唯一那张 LCP 图 -->
<img src="/img/hero-960.avif" alt="..."
width="960" height="540"
fetchpriority="high" decoding="async">
<!-- 首屏之外的图,一律 lazy + 低优先级 -->
<img src="/img/card-480.avif" alt="..." width="480" height="270"
loading="lazy" decoding="async" fetchpriority="low">
<!-- 只有当图由 JS / CSS 注入时,才用 preload 抢发现 -->
<link rel="preload" as="image" href="/img/hero-960.avif"
imagesrcset="/img/hero-960.avif 960w, /img/hero-1920.avif 1920w"
imagesizes="(max-width: 600px) 100vw, 960px">
2.4 占位:CLS 归零靠的是「预留空间」,不是「加载更快」
图片带来的 CLS,几乎全部来自「加载前不知道它多高」。390×844 视口、一张 16:9 首图,加载后把正文往下推 168px:
| 写法 | 下方内容位移 | CLS 贡献 |
|---|---|---|
无 width/height |
168px | 0.0396 |
有 width/height(浏览器推 aspect-ratio) |
0px | 0.0000 |
CLS 的算法是「影响面积比 × 位移距离比」,所以一次 168px 的位移就是 0.04------只占 0.1 预算的 40%,三张没尺寸的图就能爆 。而修它的成本是给 <img> 加两个属性。
对响应式图片要额外注意:aspect-ratio 由 width/height 属性推出,object-fit: cover 只在渲染层裁剪,不影响占位。真正会坑你的是「用 CSS 把高度写成百分比」------那等于没有占位。
2.5 字体位移归零:度量覆盖的推导与生成器
先给结论的形式,再给推导。要写这么一块:
css
@font-face {
font-family: "Fb";
src: local("Apple Symbols");
size-adjust: 134.639%; /* 让字宽对齐 */
ascent-override: 79.386%; /* 让行盒对齐 */
descent-override: 20.128%;
line-gap-override: 0.000%;
}
先说清它在做什么:度量覆盖不是 让 fallback 变好看,而是让 fallback 在「行盒高度」和「平均字宽」两个统计量上冒充网页字体。冒充成功了,字体换过来的那一刻,布局算出来的每一个盒子的位置都不变------位移自然就是 0,而不是「变小」。
推导(三步,全部可以在 2.6 的脚本里验证):
- 行盒高度(
line-height: normal)=(ascent + descent + lineGap) / unitsPerEm,单位是 em。Arial Unicode 是(2189 + 555 + 0) / 2048 = 133.984%,Apple Symbols 是100.000%。两者一比就知道位移有多大:差 0.339844em,20px × 40 行 = 271.9px,和实测的 -271.9px 完全对上。 - 字宽与字号严格线性,所以要「让 fallback 的平均字宽等于网页字体」,只需要一个缩放比:
size-adjust = avgAdv(网页字体) / avgAdv(fallback) - 但
ascent-override等三个值表达的是「字体尺寸的百分比 」,而size-adjust会把这个尺寸再乘一次。所以覆盖值必须先除掉size-adjust:ascent-override = (ascent / unitsPerEm) / size-adjust
这三步的正确性不是我说了算,是断言算的。对系统里 33 个 fallback 字体逐个验收:
| fallback 字体 | size-adjust | 原始位移 | 覆盖后位移 |
|---|---|---|---|
| Apple Symbols | 134.639% | -271.9px | 0.000px |
| Courier | 94.179% | -271.9px | 0.000px |
| Helvetica | 100.000% | -271.9px | 0.000px |
| MuktaMahee | 113.906% | 257.7px | 0.000px |
| Noteworthy | 109.053% | 220.1px | 0.000px |
| MarkerFelt | 117.442% | -203.1px | 0.000px |
| Palatino | 99.362% | -191.8px | 0.000px |
| Menlo | 93.874% | -140.6px | 0.000px |
| ...29 个其他 | 90.7%~134.6% | 平均 131.6px | 0.000px |
为什么同时要看「行盒」和「字宽」两个量?因为位移有两个独立来源:纵向 (行盒高度不同,每一行都多/少几个像素,40 行累加成 271.9px)和横向 (字宽不同,换行点移动,文字块高度从 N 行变 N±1 行)。前者修 size-adjust + 三个 override,后者只能靠 size-adjust ------这也是为什么 size-adjust 必须由平均字宽算,而不能拍一个 100%。
33 个字体全部归零 ,最大残余 0.0000px;平均字宽误差从 25.73% 降到 0.000000%。这也是为什么「度量覆盖」可以做成一个与字体无关的生成器------它不需要你懂字体,只需要你解出两套表。
生成器的核心(完整版在 day31-assets/font-metrics.mjs,零依赖,自己解 sfnt):
js
// 1) 解出两套度量:平均前进宽度 / asc / desc / lineGap,全部归一化到 em
const metricsOf = (f, cmap) => {
let sum = 0, n = 0;
for (const ch of 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ,.') {
const gid = cmap.map.get(ch.codePointAt(0));
if (gid === undefined) continue;
sum += advanceOf(f, gid); n++; // hmtx 里的 advance width
}
return {
avgAdv: sum / n / f.unitsPerEm,
ascent: f.ascender / f.unitsPerEm,
descent: -f.descender / f.unitsPerEm, // hhea.descender 是负数
lineGap: f.lineGap / f.unitsPerEm,
};
};
// 2) 三步推出覆盖值
const web = metricsOf(webFont, webCmap);
const fb = metricsOf(fallback, fbCmap);
const sizeAdjust = web.avgAdv / fb.avgAdv;
const ascentOverride = web.ascent / sizeAdjust; // 先除,因为渲染时会再乘一次
const descentOverride = web.descent / sizeAdjust;
const lineGapOverride = web.lineGap / sizeAdjust;
// 3) 验收:覆盖后的行盒必须和网页字体一致(容差 0.002em)
const lineBox = (r) => r.ascent + r.descent + r.lineGap;
const rendered = (base, s, o) => ({
ascent: (o.ascentOverride ?? base.ascent) * s,
descent: (o.descentOverride ?? base.descent) * s,
lineGap: (o.lineGapOverride ?? base.lineGap) * s,
});
const got = lineBox(rendered(fb, sizeAdjust,
{ ascentOverride, descentOverride, lineGapOverride }));
const want = lineBox(rendered(web, 1, {}));
console.assert(Math.abs(got - want) < 0.002, `行盒差 ${(got - want).toFixed(6)}em`);
两个必须知道的坑:
hhea与OS/2的度量在 Windows 和 macOS 上口径不同 (Windows 用usWinAscent/usWinDescent,macOS/Chromium 用hhea)。上面的脚本用hhea,所以数字是 macOS/Chromium 口径;跨平台一致的写法是把两套都算出来,取更保守的那套(Chromium 的官方生成器也是这么处理差异的)。size-adjust会缩放字形本身 。它修的是「字宽和行盒」,代价是「字体到达之前,字形略小或略大」。这仍然远好于 168px 的跳动------但如果你对字形尺寸极其敏感(比如大字标题),更好的选择是 2.7 里的optional,直接放弃这次的自定义字体。
2.6 字体体积:子集化账本 + unicode-range 分片
先量再砍。把 glyf 表按 loca 相邻差拆成逐字形字节,再用 cmap 建立「字符 → 字形」,就能算出「我的语料到底要多少字节」:
js
// loca 存的是每个字形在 glyf 里的偏移,相邻相减就是该字形的字节数
function glyphTable(f) {
const { buf, tables, numGlyphs, indexToLocFormat } = f;
const off = new Uint32Array(numGlyphs + 1);
for (let i = 0; i <= numGlyphs; i++) {
off[i] = indexToLocFormat === 0
? buf.readUInt16BE(tables.loca.off + i * 2) * 2
: buf.readUInt32BE(tables.loca.off + i * 4);
}
const sizes = new Uint32Array(numGlyphs);
for (let i = 0; i < numGlyphs; i++) sizes[i] = off[i + 1] - off[i];
return { sizes, offsets: off };
}
// 字符集 -> 字形集合 -> 真实字节(用 brotli q11 模拟 WOFF2 的压缩)
function glyphSet(chars) {
const seen = new Set(), slices = [];
for (const ch of chars) {
const gid = cmap.map.get(ch.codePointAt(0));
if (gid === undefined || seen.has(gid)) continue;
seen.add(gid);
const n = sizes[gid];
if (n > 0) slices.push(buf.subarray(glyfOff + offsets[gid], glyfOff + offsets[gid] + n));
}
return { glyphs: seen.size, raw: slices.reduce((s, b) => s + b.length, 0),
br: brotliCompressSync(Buffer.concat(slices), { params: { [BROTLI_QUALITY]: 11 } }).length };
}
对 22.20MB 的 Arial Unicode 跑出来的账本:
| 你要发的东西 | 字形数 | glyf 字节 | brotli q11 后 | 占全文体 |
|---|---|---|---|---|
| 整个字体文件 | 50,377 | 22,410,874 B | 12.32 MB | 100% |
| 只要 ASCII 95 字符 | 95 | 21.55 KB | 15.24 KB | 0.095% |
| 本系列 30 篇正文的全部字符 | 1,579 | 689.02 KB | 417.59 KB | 3.1% |
| └ 其中汉字 1,410 个 | 1,410 | 656.43 KB | 395.07 KB | ------ |
三条硬结论:
- 拉丁子集化省不出东西 。ASCII 95 个字形只占全文体 0.095%。如果你做的英文站,字体本来就 20KB 级,别在子集化上耗时间------那点收益还不如一张图的 1%。
- CJK 站不子集化没有出路 。不子集化 12.32MB,在 200KB/s 上要 63.8 秒 ;子集 418KB 只要 2.1 秒 ,差 22.5 倍。
- 子集里 95.3% 是汉字字节。所以中文站的字体优化,就是「汉字优化」,没有别的。
分片策略:把「首屏必须的」和「其余汉字」拆成两个 unicode-range,浏览器只下它用得到的那片:
css
/* 片 1:拉丁 + 标点,几乎恒定 15KB,随首屏一起 preload */
@font-face {
font-family: "Site";
src: url("/f/latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+2000-206F;
font-display: swap;
}
/* 片 2:常用汉字前 1000 字,覆盖正文 90%+ 的出现次数 */
@font-face {
font-family: "Site";
src: url("/f/cjk-1.woff2") format("woff2");
unicode-range: U+4E00-9FFF; /* 实际按你的字频表切片 */
font-display: swap;
}
/* 片 3:冷门汉字。可以只留系统字体,也可以再切一片 */
注意 unicode-range 是声明覆盖范围 ,不是分流逻辑------浏览器按页面上真实出现的字符决定要下哪片。所以切片边界应该按字频切(前 1000 字覆盖 90%+ 的出现次数),而不是按 Unicode 区间平均切。
2.7 font-display:选的是「把代价放在哪儿」
| 值 | 行为 | 代价 |
|---|---|---|
block |
先不可见(~3s),超时才回退 | 3s 白屏 / 零位移 |
swap |
立刻用 fallback,字体到了再换 | 零白屏 / 有位移(CLS) |
fallback |
block ~100ms,3s 内到货才换 | 位移只在快网发生 |
optional |
block ~100ms,且本次不再换 | 首次可能用不上自定义字体 |
选 swap 就必须配 2.5 的度量覆盖,否则位移是自找的(实测最坏 271.9px)。选 optional 就等于承认「首次访问用系统字体」,那度量覆盖反而成了负担------两条路都能走通,混着走才是灾难 :很多代码库是 swap + 没覆盖,等于同时吃了 FOUT 和 CLS。
还有一个 preload 的反面案例,值得单独说:preload 一个字体,是在和你的 LCP 图抢带宽。同一张 LCP 图,只改字体:
| 场景 | LCP |
|---|---|
| LCP 图 1920JPEG,不 preload 字体(418KB 子集) | 4021ms |
| LCP 图 1920JPEG,preload 418KB 子集 | 4608ms |
| LCP 图 1920JPEG,preload 12.3MB 全字体 | 65560ms |
| LCP 图 960AVIF,preload 12.3MB 全字体 | 985ms(几乎无影响) |
同一个「preload 12.3MB」,在两种页面上差 6 万多毫秒------因为 preload 抢的是带宽,抢不到已经跑完的东西。图小(46.5KB,232ms 完成)时字体来不及伤它;图大(300KB)时,字体把它拖了 61.5 秒。所以「preload 字体」这个动作的正确性,取决于同页 LCP 资源有多大。
2.8 验收:把上面全部变成断言
三份脚本,101 条断言,一条命令:
bash
cd day31-assets
python3 img-ladder.py # 78 组真实编码 + 73 条断言
node font-metrics.mjs # 33 个字体 + 字形账本 + 14 条断言
node page-waterfall.mjs # 9 种加载姿势 + 字体 + CLS + 14 条断言
断言长这样(真实代码,不是示意):
js
check(gainSizesOnly > gainCodecOnly,
`调尺寸(${gainSizesOnly.toFixed(0)}ms) > 换格式(${gainCodecOnly.toFixed(0)}ms):过发比编码器更贵`);
check(Math.max(...rows.map(r => Math.abs(r.widthErrAfter))) < 1e-9,
'覆盖后平均字宽与网页字体完全一致(换行位置不再跳)');
check(cjkChars.length < allCjkCount / 10,
`真实语料只用到汉字集的 ${(100 * cjkChars.length / allCjkCount).toFixed(1)}% < 10%`);
为什么值得写成断言而不是「看个大屏就完事」:因为这些参数会漂。换一张图、换一款字体、换一个 fallback 链,结论可能反过来。写成断言,就能在 CI 里跑,让「过发」和「没覆盖」变成红灯,而不是三个月后某天的 LCP 报警。
三、应用场景
场景一:中文内容站 / 博客(本篇的直接对象)
首屏字节里字体常常比图片还重。优先级排序:
| 动作 | 实测收益 | 成本 |
|---|---|---|
| 字体子集化(语料 6.7% 的汉字) | 12.32MB → 418KB(-96.7%) | 构建期一次,之后自动化 |
度量覆盖 + swap |
位移 271.9px → 0px | 一次生成,写进 CSS |
首图 srcset + sizes |
77.8KB → 13.0KB | 一个属性 |
| 首图 AVIF | 300.1KB → 161.2KB(-46.3%) | 构建期编码 |
首图 fetchpriority=high |
LCP 1185 → 752ms | 一个属性 |
注意这个排序:前两项是「结构性」的,收益是数量级的;后三项是「参数性」的,收益是百分比。 内容站的最优解永远是先做前两项。
场景二:电商列表页 / 瀑布流
图片数量 50~200 张,LCP 往往是「第一张可见卡片」。这里的关键不是格式,是别让浏览器以为它们都在首屏:
- 首屏那 1~2 张:
eager+fetchpriority="high",srcset按卡片实际宽度铺档(实测 480 档 13.0KB vs 1280 档 77.8KB,5.98 倍)。 - 其余全部
loading="lazy"+decoding="async"。50 张图里 48 张不该参与首屏竞争。 - 每张都必须有
width/height。列表页是 CLS 的重灾区,因为卡片高度全靠图片撑------一张没尺寸的图推 168px,CLS 就是 0.0396。 - 缩略图不要用「大图 + CSS 缩小」。那是在为一个 120px 的格子下载 1920 的像素。320 档 JPEG(12.07KB)与 1920 档(300.11KB)差 24.9 倍。
场景三:国际化英文站 / 海外后台
这类站点的正确结论是少做事:
- 字体:ASCII 只占全文体 0.095% ,一款 20KB 级的拉丁字体做子集化省不到 1KB。不要为此建流水线 。真正该做的是度量覆盖(
Arial/Helvetica就有 100% 和 94.179% 的 size-adjust,位移 -271.9px)和swap。 - 图片:WebP 足够(等质量 -31.4%~-37.1%),AVIF 的 46% 在英文站通常不值构建期的编码成本;
srcset的收益(5.98x)依然远大于格式(1.46x)。 - 后台系统:图片少、字体本地、LCP 是首屏表格文本。这篇的绝大部分内容在这里 ROI 极低------认清这一点比套模板重要。
场景四:弱网 / 新兴市场
带宽 200KB/s 以下时,「过发」的代价被线性放大:
| 决策 | 字节变化 | 200KB/s 下等价 |
|---|---|---|
| 1920 → 960(JPEG) | -213.09 KB | -1065ms |
| JPEG → AVIF(等质量) | -138.9 KB | -694ms |
不写 sizes(1170 → 386px 需求) |
-64.8 KB | -324ms |
首屏图加 lazy |
0 | +29ms(顺序被埋点挤后) |
| 字体不子集化(12.3MB vs 418KB) | +12.2 MB | +61.5s |
在弱网上,「尺寸」和「子集化」是唯一有意义的两件事;格式升级是锦上添花,而任何一次误用 lazy 都会把它抵消掉一部分。
场景五:存量项目接图片流水线
不要一次性重做。按这个顺序接,每步都能独立验收:
- 先加断言,不改代码 :把
img-ladder.py接到 CI,对现有图片出一张「等质量前沿」表,看当前在曲线的哪个位置。 - 补
width/height:纯 HTML 改动,零风险,CLS 立刻归零(0.0396 → 0)。 - 补
sizes:先只改 LCP 那几张,收益最大、风险最小。 - 上 AVIF +
<picture>:保留 JPEG 兜底,灰度发布。 - 字体 :度量覆盖 → 子集化 →
unicode-range分片,顺序不能反(先分片后覆盖,会导致每片都要单独算一遍度量)。
四、总结
Day30 的结论是「bundle 优化不是让 bundle 变小,而是在初始字节、请求形态、失效面三个预算里找平衡」。Day31 沿同一个思路往下走,得到的结论更简单也更刺人:
图片和字体的优化对象,从来不是「字节」,而是「缺口」。
- 图片的缺口是分辨率对齐:你要 386px,浏览器给了你 1280px。补这个缺口的动作不产生任何编码成本,收益却是换格式的 1.5 倍(1065ms vs 694ms)。
- 字体的缺口是度量对齐:fallback 的行盒和字宽和网页字体不一样,于是文字整体跳一次。补这个缺口不需要任何网络传输,收益是把 271.9px 的位移变成 0。
两者都是「不传一个字节就能拿到的收益」,而它们恰好也是绝大多数团队漏掉的那部分------因为默认值太坏了:sizes 默认 100vw,font-display 默认 auto(等价 block),img 默认没有 aspect-ratio。三个默认值,三个缺口,全部由浏览器替你选择,且都选了最坏的那个。
具体到可复用的判断:
- 先测再优化,且必须用等质量坐标系 。跨格式比
quality是无效比较(WebP q85 比 JPEG q85 又多 0.9dB 又小 60KB)。用 PSNR 对齐后,格式的收益是一个干净的常数:WebP -31%-37%,AVIF -46%-47%,与图片大小无关。 - 尺寸是量级,格式是乘数 。宽度减半,字节只减到 1/2.9~1/3.5(不是 1/4)------所以
srcset必须按实测刻度铺,不能按面积外推。 - LCP 图只有一张,所有优先级都必须指向它 。
preload只在发现晚时有价值(S6 = S5);lazy在首屏图上纯亏(+29ms 起);fetchpriority="high"是唯一稳定的排队收益(1185 → 752ms)。 preload不免费 :preload 一个 12.3MB 字体,在 300KB 图的页面上把 LCP 拖成 65.5 秒,在 46.5KB 图的页面上几乎无感------优先级是零和的,你给谁加,就是从谁身上拿。- CLS 的修法是预留空间,不是加载更快 。
width/height两个属性换 0.0396 → 0;字体的位移靠size-adjust+ 三个-override,33 个 fallback 实测全部归零。font-display一个位移都消不掉。 - CJK 字体只有一条路:语料 6.7% 的汉字 → 子集 418KB;不子集化就是 12.32MB / 63.8 秒。而拉丁站恰恰相反,ASCII 占 0.095%,别做无用功。
本篇没有覆盖的 (免得被当成全集):LQIP / blurhash / 渐进式占位、CDN 图片服务的动态裁剪与按需转码、SVG 图标与 currentColor 调色、视频与 poster、容器查询驱动的响应式图片、可变字体的 font-optical-sizing 与字重轴子集。这些都属于同一片战场,但每一个都够写一篇。
下一篇 Day32 换到另一条完全不同的维度:Worker 与 OffscreenCanvas------那是把「像素和字形之外的计算」从主线程搬走。Day30 管传输,Day31 管「该传多少」,Day32 管「传到了之后在哪个线程算」。
运行环境与文件清单
css
Day31-图片字体.md 本篇
day31-assets/
├── master-1920.png 源图:macOS 默认壁纸(3840×2160) LANCZOS 缩到 1920×1080
├── img-ladder.py 真实编码:6 宽度 × 4 格式 × 质量阶梯 = 78 组,73 条断言
├── img-ladder.json 等质量前沿 + 全部实测字节(page-waterfall.mjs 的输入)
├── img-ladder.csv 同上,表格版
├── font-metrics.mjs 零依赖 sfnt 解析:33 个 fallback 度量覆盖 + 字形账本,14 条断言
└── page-waterfall.mjs 确定性瀑布模型(吃真实字节):9 姿势 + 字体 + CLS,14 条断言
哪些是实测,哪些是模型(这个区分比数字本身重要):
| 部分 | 性质 | 口径 |
|---|---|---|
| 78 组编码字节、PSNR | 实测 | Pillow 12.0.0(webp 1.6.0 / avif 1.3.0 / jpg 6.2);JPEG 4:2:0 + optimize;WebP method=4;AVIF speed=6 |
| 字体度量、glyf 字节、brotli | 实测 | 自写 sfnt 解析;hhea 口径(macOS/Chromium);brotli q11 |
| LCP/位移/CLS 的毫秒与像素 | 模型 | 带宽 200KB/s、RTT 150ms、建连 300ms、h2 6 流、优先级严格分层;字节输入是实测值 |
模型的价值是把不同策略放进同一个坐标系里比较,不是预测某台设备上的绝对毫秒数。几处已知的乐观/保守:
- 严格分层的优先级比真实 h2 更极端,所以「大字体 preload 饿死 LCP 图」这条在真实浏览器里没那么惨(但方向是对的)。
- PSNR 对模糊宽容、对块效应惩罚不足,
-46.3%这个数在肉眼下可能偏乐观,AVIF 的高频细节损失需要人眼复核。 brotli q11是 WOFF2 的内核,但 WOFF2 还叠了 glyf 变换,真实 woff2 通常比这里再小一些。- 源图是风景壁纸,纹理复杂度高于平均;换成人像/截图,格式间的相对收益会变,但「尺寸 > 格式」的结论不变(三条曲线的 bpp 都是随宽度下降的)。
复现:cd day31-assets && python3 img-ladder.py && node font-metrics.mjs && node page-waterfall.mjs(Node v22.23.3 / Python 3.11.11 / macOS,2026-10-22)。
附录:完整代码
以下三个脚本是
day31-assets/目录下的完整实现 (不是片段),可直接运行:node font-metrics.mjs、node page-waterfall.mjs零依赖;img-ladder.py需 Pillow ≥ 11 + numpy。 后两个脚本的断言失败即exit 1,输出即文中所有实测结论的复现依据。
A. img-ladder.py ------ 图片阶梯实测(编码 + PSNR + 等质量前沿,230 行)
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
img-ladder.py -- Day31 图片阶梯实测(真实编码,非模型)
对同一张真实照片,做「宽度阶梯 x 格式阶梯」的全量编码,
每组输出真实字节数 + 相对该宽度未编码参考图的 PSNR。
产出:
img-ladder.json 给 lcp-queue.mjs 直接当输入(模型吃真实字节)
img-ladder.csv 便于贴表格
依赖: Pillow >= 11 (含 libwebp / libavif)
本机实测 Pillow 12.0.0 / webp 1.6.0 / avif 1.3.0 / jpg 6.2
断言失败即 exit 1。
用法: python3 img-ladder.py [--src master-1920.png] [--out .]
"""
import argparse
import io
import json
import os
import sys
import numpy as np
from PIL import Image
WIDTHS = [1920, 1280, 960, 640, 480, 320]
LADDERS = {
"jpeg": [95, 85, 75, 60],
"webp": [95, 85, 75, 60],
"avif": [90, 70, 50, 35],
"png": [None],
}
AVIF_SPEED = 6
FAILS = []
def check(ok, msg):
print((" PASS " if ok else " FAIL ") + msg)
if not ok:
FAILS.append(msg)
def encode(img, fmt, q):
buf = io.BytesIO()
if fmt == "jpeg":
img.save(buf, "JPEG", quality=q, optimize=True, subsampling="4:2:0")
elif fmt == "webp":
img.save(buf, "WEBP", quality=q, method=4)
elif fmt == "avif":
img.save(buf, "AVIF", quality=q, speed=AVIF_SPEED)
elif fmt == "png":
img.save(buf, "PNG", optimize=True, compress_level=9)
else:
raise ValueError(fmt)
return buf.getvalue()
def decode(data):
im = Image.open(io.BytesIO(data))
im.load()
return im.size, im.convert("RGB")
def psnr(a, b):
mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2)
if mse == 0:
return float("inf")
return 10.0 * np.log10(255.0 ** 2 / mse)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--src", default="master-1920.png")
ap.add_argument("--out", default=".")
args = ap.parse_args()
here = os.path.dirname(os.path.abspath(__file__))
src_path = args.src if os.path.isabs(args.src) else os.path.join(here, args.src)
master = Image.open(src_path).convert("RGB")
print("源图 %s 尺寸=%s 原始PNG字节=%d" % (
src_path, master.size, os.path.getsize(src_path)))
rows = []
for w in WIDTHS:
if w > master.width:
continue
h = round(master.height * w / master.width)
ref_img = master.resize((w, h), Image.LANCZOS)
ref = np.asarray(ref_img)
print("\n== 宽度 %dx%d" % (w, h))
for fmt, qs in LADDERS.items():
for q in qs:
data = encode(ref_img, fmt, q)
size, back = decode(data)
if size != (w, h):
check(False, "%s q%s 解码尺寸不符 %s" % (fmt, q, size))
continue
got = psnr(ref, np.asarray(back))
rows.append({
"width": w, "height": h, "format": fmt, "quality": q,
"bytes": len(data), "kb": round(len(data) / 1024, 2),
"psnr": None if got == float("inf") else round(got, 2),
"bpp": round(len(data) * 8 / (w * h), 4),
})
tag = "lossless" if got == float("inf") else "%.2fdB" % got
print(" %-5s q=%-4s -> %8d B (%8.2fKB %.4f bpp) PSNR %s" % (
fmt, str(q), len(data), len(data) / 1024,
len(data) * 8 / (w * h), tag))
print("\n== 不变量断言")
def find(w, fmt, q):
for r in rows:
if r["width"] == w and r["format"] == fmt and r["quality"] == q:
return r
return None
for w in WIDTHS:
if w > master.width:
continue
for fmt in ("jpeg", "webp", "avif"):
qs = LADDERS[fmt]
seq = [find(w, fmt, q)["bytes"] for q in qs]
check(all(seq[i] > seq[i + 1] for i in range(len(seq) - 1)),
"w=%d %s: 质量阶梯字节单调递减 %s" % (w, fmt, seq))
ps = [find(w, fmt, q)["psnr"] for q in qs]
check(all(ps[i] >= ps[i + 1] for i in range(len(ps) - 1)),
"w=%d %s: PSNR 同步单调递减 %s" % (w, fmt, ps))
for w in WIDTHS:
if w > master.width:
continue
for q in (85, 75):
j, we = find(w, "jpeg", q), find(w, "webp", q)
check(we["bytes"] < j["bytes"],
"w=%d q=%d: webp %.2fKB < jpeg %.2fKB (省 %.1f%%)" % (
w, q, we["kb"], j["kb"], 100 * (1 - we["bytes"] / j["bytes"])))
for w in WIDTHS:
if w > master.width:
continue
p = find(w, "png", None)["bytes"]
top = max(find(w, f, q)["bytes"] for f in ("jpeg", "webp", "avif") for q in LADDERS[f])
check(p > top, "w=%d: png %.0fKB > 最贵有损 %.0fKB" % (w, p / 1024, top / 1024))
for fmt in ("jpeg", "webp", "avif"):
q = LADDERS[fmt][1]
seq = [find(w, fmt, q)["bytes"] for w in WIDTHS if w <= master.width]
check(all(seq[i] > seq[i + 1] for i in range(len(seq) - 1)),
"%s q=%s: 体积随宽度单调递减 %sKB" % (fmt, q, [round(s / 1024, 1) for s in seq]))
# 宽度减半省不到一半:字节比 < 面积比 4.00x
# 这是实测出的真实不变量,不是猜的:小图每个像素承载更多高频细节,
# 固定开销(头部/原子结构/superblock)占比也更高。
for fmt in ("jpeg", "webp", "avif"):
q = LADDERS[fmt][1]
r1920 = find(1920, fmt, q)["bytes"]
r960 = find(960, fmt, q)["bytes"]
ratio = r1920 / r960
check(2.8 < ratio < 3.95,
"%s q=%s: 面积比 4.00x -> 实测字节比 %.2fx (省 %.1f%%,不是 75%%)" % (
fmt, q, ratio, 100 * (1 - 1 / ratio)))
# ---- 等质量对比:用 PSNR 对齐,而不是用各家的 quality 刻度对齐
# 目标 = JPEG q85 的 PSNR(每个宽度各自的目标)。在 PSNR-字节 平面上线性插值。
print("\n== 等质量前沿 (iso-quality, 目标 = 该宽度 JPEG q85 的 PSNR)")
iso = {}
for w in WIDTHS:
if w > master.width:
continue
base = find(w, "jpeg", 85)
target = base["psnr"]
iso[w] = {"target_psnr": target, "jpeg": base["bytes"]}
for fmt in ("webp", "avif"):
pts = sorted([(find(w, fmt, q)["psnr"], find(w, fmt, q)["bytes"]) for q in LADDERS[fmt]])
est = None
for i in range(len(pts) - 1):
(p0, b0), (p1, b1) = pts[i], pts[i + 1]
if p0 <= target <= p1:
t = 0.0 if p1 == p0 else (target - p0) / (p1 - p0)
est = b0 + t * (b1 - b0)
break
if est is None: # 目标超出阶梯范围 -> 取最近端
est = pts[0][1] if target <= pts[0][0] else pts[-1][1]
iso[w][fmt] = est
print(" w=%4d 目标PSNR=%5.2fdB | jpeg %7.1fKB | webp %7.1fKB (%+5.1f%%) | avif %7.1fKB (%+5.1f%%)" % (
w, target, iso[w]["jpeg"] / 1024, iso[w]["webp"] / 1024,
100 * (iso[w]["webp"] / iso[w]["jpeg"] - 1),
iso[w]["avif"] / 1024, 100 * (iso[w]["avif"] / iso[w]["jpeg"] - 1)))
for w in iso:
check(iso[w]["webp"] < iso[w]["jpeg"],
"w=%d 等质量: webp 比 jpeg 省 %.1f%%" % (w, 100 * (1 - iso[w]["webp"] / iso[w]["jpeg"])))
check(iso[w]["avif"] < iso[w]["webp"],
"w=%d 等质量: avif 比 webp 再省 %.1f%%" % (w, 100 * (1 - iso[w]["avif"] / iso[w]["webp"])))
# 宽度阶梯本身(不换格式)的收益,远大于换格式
j1920 = find(1920, "jpeg", 85)["bytes"]
j960 = find(960, "jpeg", 85)["bytes"]
a1920 = iso[1920]["avif"]
check(j960 < a1920,
"只要把 1920 JPEG %.0fKB 降成 960 JPEG %.0fKB,就比 1920 AVIF %.0fKB 还小 %.1f%%"
% (j1920 / 1024, j960 / 1024, a1920 / 1024, 100 * (1 - j960 / a1920)))
out = args.out if os.path.isabs(args.out) else os.path.join(here, args.out)
with open(os.path.join(out, "img-ladder.json"), "w") as f:
json.dump({"source": os.path.basename(src_path),
"master": list(master.size),
"avif_speed": AVIF_SPEED, "rows": rows, "iso_quality": iso}, f, indent=1)
with open(os.path.join(out, "img-ladder.csv"), "w") as f:
f.write("width,height,format,quality,bytes,kb,psnr,bpp\n")
for r in rows:
f.write("%d,%d,%s,%s,%d,%s,%s,%s\n" % (
r["width"], r["height"], r["format"], r["quality"],
r["bytes"], r["kb"], r["psnr"], r["bpp"]))
print("\n写出 %s/img-ladder.json (%d 行)" % (out, len(rows)))
print("\n断言%s" % ("全部通过" if not FAILS else " %d 条失败" % len(FAILS)))
if FAILS:
for m in FAILS:
print(" FAILED:", m)
sys.exit(1)
if __name__ == "__main__":
main()
B. font-metrics.mjs ------ 字体度量覆盖与字形账本(零依赖解析 SFNT,308 行)
javascript
#!/usr/bin/env node
/**
* font-metrics.mjs -- Day31 字体实测(零依赖,自己解 TTF/TTC)
*
* A. 字体度量覆盖:把真实字体表解出来,算出 size-adjust / ascent-override ... ,
* 并在「系统里所有可用 fallback 字体」上验证:覆盖前位移各有各的错,覆盖后全部归零。
* B. 字形字节账本:cmap -> gid -> loca/glyf,算出真实语料用到的字形要多少字节,
* 再用 brotli q11(WOFF2 用的就是 brotli)实测压完还剩多少。
*
* 断言失败即 exit 1。用法: node font-metrics.mjs [--web <ttf>] [--corpus <dir>]
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { brotliCompressSync, gzipSync, constants as zc } from 'node:zlib';
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const arg = (k, d) => { const i = argv.indexOf('--' + k); return i >= 0 ? argv[i + 1] : d; };
const WEB = arg('web', '/System/Library/Fonts/Supplemental/Arial Unicode.ttf');
const FALLBACK_DIRS = ['/System/Library/Fonts', '/System/Library/Fonts/Supplemental'];
const CORPUS_DIR = arg('corpus', join(HERE, '..'));
const FAILS = [];
const check = (ok, msg) => { console.log(` ${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) FAILS.push(msg); };
const pct = (x) => (x * 100).toFixed(3) + '%';
/* ------------------------------------------------------------------ SFNT */
function parseFont(path) {
const buf = readFileSync(path);
let base = 0;
if (buf.toString('latin1', 0, 4) === 'ttcf') base = buf.readUInt32BE(12);
const numTables = buf.readUInt16BE(base + 4);
const tables = {};
for (let i = 0; i < numTables; i++) {
const o = base + 12 + i * 16;
tables[buf.toString('latin1', o, o + 4)] = { off: buf.readUInt32BE(o + 8), len: buf.readUInt32BE(o + 12) };
}
for (const t of ['head', 'hhea', 'maxp', 'hmtx', 'cmap']) if (!tables[t]) throw new Error('缺少 ' + t);
const hhea = tables.hhea.off;
return {
path, buf, tables,
unitsPerEm: buf.readUInt16BE(tables.head.off + 18),
indexToLocFormat: buf.readInt16BE(tables.head.off + 50),
numGlyphs: buf.readUInt16BE(tables.maxp.off + 4),
ascender: buf.readInt16BE(hhea + 4),
descender: buf.readInt16BE(hhea + 6),
lineGap: buf.readInt16BE(hhea + 8),
numberOfHMetrics: buf.readUInt16BE(hhea + 34),
fileBytes: buf.length,
};
}
const advanceOf = (f, gid) =>
f.buf.readUInt16BE(f.tables.hmtx.off + Math.min(gid, f.numberOfHMetrics - 1) * 4);
function buildCmap(f) {
const { buf, tables } = f;
const cm = tables.cmap.off;
const n = buf.readUInt16BE(cm + 2);
let best4 = null, best12 = null;
for (let i = 0; i < n; i++) {
const pid = buf.readUInt16BE(cm + 4 + i * 8), eid = buf.readUInt16BE(cm + 6 + i * 8);
const off = cm + buf.readUInt32BE(cm + 8 + i * 8);
const fmt = buf.readUInt16BE(off);
if ((pid === 3 && eid === 10) || (pid === 0 && !best12)) if (fmt === 12 && !best12) best12 = off;
if ((pid === 3 && eid === 1) || (pid === 0 && !best4)) if (fmt === 4 && !best4) best4 = off;
}
const map = new Map();
if (best12 != null) {
const off = best12, nGroups = buf.readUInt32BE(off + 12);
for (let g = 0; g < nGroups; g++) {
const o = off + 16 + g * 12;
const s = buf.readUInt32BE(o), e = buf.readUInt32BE(o + 4), sg = buf.readUInt32BE(o + 8);
for (let c = s; c <= e && c - s < 0x10000; c++) map.set(c, sg + (c - s));
}
return { map, fmt: 12 };
}
if (best4 == null) throw new Error('无可用 cmap');
const off = best4, segX2 = buf.readUInt16BE(off + 6), seg = segX2 / 2;
const endO = off + 14, startO = endO + segX2 + 2, deltaO = startO + segX2, rangeO = deltaO + segX2;
for (let i = 0; i < seg; i++) {
const end = buf.readUInt16BE(endO + i * 2), start = buf.readUInt16BE(startO + i * 2);
const delta = buf.readInt16BE(deltaO + i * 2), ro = buf.readUInt16BE(rangeO + i * 2);
if (start === 0xffff) continue;
for (let c = start; c <= end; c++) {
let gid;
if (ro === 0) gid = (c + delta) & 0xffff;
else {
const gi = rangeO + i * 2 + ro + (c - start) * 2;
if (gi + 1 >= buf.length) continue;
gid = buf.readUInt16BE(gi); if (gid) gid = (gid + delta) & 0xffff;
}
if (gid) map.set(c, gid);
}
}
return { map, fmt: 4 };
}
/** 返回 { sizes:Uint32Array, offsets:Uint32Array } ------ loca 相邻差 = 该字形 glyf 字节数 */
function glyphTable(f) {
const { buf, tables, numGlyphs, indexToLocFormat } = f;
const lo = tables.loca.off;
const off = new Uint32Array(numGlyphs + 1);
for (let i = 0; i <= numGlyphs; i++)
off[i] = indexToLocFormat === 0 ? buf.readUInt16BE(lo + i * 2) * 2 : buf.readUInt32BE(lo + i * 4);
const sizes = new Uint32Array(numGlyphs);
for (let i = 0; i < numGlyphs; i++) sizes[i] = Math.max(0, off[Math.min(i + 1, numGlyphs)] - off[i]);
return { sizes, offsets: off };
}
/* ------------------------------------------------------- 度量与覆盖计算 */
const LATIN_SAMPLE = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ,.';
function metricsOf(f, cmap) {
let sum = 0, n = 0;
for (const ch of LATIN_SAMPLE) {
const gid = cmap.map.get(ch.codePointAt(0));
if (gid === undefined) continue;
sum += advanceOf(f, gid); n++;
}
return {
upm: f.unitsPerEm,
avgAdv: sum / n / f.unitsPerEm, // 平均前进宽度 / em
ascent: f.ascender / f.unitsPerEm,
descent: -f.descender / f.unitsPerEm,
lineGap: f.lineGap / f.unitsPerEm,
n,
};
}
const natural = (m) => m.ascent + m.descent + m.lineGap;
/** 覆盖后的渲染度量(一次 font-size 为 1 的单位):overrides 是「字体尺寸的百分比」,再乘 size-adjust */
const rendered = (base, o) => {
const s = o.sizeAdjust ?? 1;
return {
ascent: (o.ascentOverride ?? base.ascent) * s,
descent: (o.descentOverride ?? base.descent) * s,
lineGap: (o.lineGapOverride ?? base.lineGap) * s,
};
};
const lineBox = (r) => r.ascent + r.descent + r.lineGap;
/* ================================================================== A */
console.log('== A. 度量覆盖(真实表解析)\n');
if (!existsSync(WEB)) { console.log('跳过 A:' + WEB + ' 不存在'); process.exit(0); }
const webF = parseFont(WEB), webCm = buildCmap(webF), mWeb = metricsOf(webF, webCm);
console.log(` 网页字体 ${basename(WEB)} upm=${webF.unitsPerEm} ` +
`asc/desc/gap=${webF.ascender}/${webF.descender}/${webF.lineGap} ` +
`平均字宽=${mWeb.avgAdv.toFixed(5)}em 行高=${pct(natural(mWeb))}`);
// ---- 对系统里每个可用 fallback 字体,各算一套覆盖,并验证位移归零
const candidates = [];
for (const d of FALLBACK_DIRS) {
if (!existsSync(d)) continue;
for (const f of readdirSync(d)) {
if (!/\.(ttf|ttc|otf)$/i.test(f)) continue;
candidates.push(join(d, f));
}
}
const FS = 20, LINES = 40;
const rows = [];
for (const p of candidates.slice(0, 60)) {
let fb, fbCm, mFb;
try {
fb = parseFont(p); fbCm = buildCmap(fb); mFb = metricsOf(fb, fbCm);
if (mFb.n < 30) continue;
} catch { continue; }
const sizeAdjust = mWeb.avgAdv / mFb.avgAdv;
const overrides = {
sizeAdjust,
ascentOverride: mWeb.ascent / sizeAdjust,
descentOverride: mWeb.descent / sizeAdjust,
lineGapOverride: mWeb.lineGap / sizeAdjust,
};
const raw = rendered(mFb, {});
const fixed = rendered(mFb, overrides);
const shiftRaw = (lineBox(raw) - lineBox(rendered(mWeb, {}))) * FS * LINES;
const shiftFixed = (lineBox(fixed) - lineBox(rendered(mWeb, {}))) * FS * LINES;
// 字宽层:缩放后平均字宽是否对齐(换行点抖动的来源)
const widthErrBefore = (mFb.avgAdv - mWeb.avgAdv) / mWeb.avgAdv;
const widthErrAfter = (mFb.avgAdv * sizeAdjust - mWeb.avgAdv) / mWeb.avgAdv;
rows.push({ name: basename(p), sizeAdjust, overrides, shiftRaw, shiftFixed, widthErrBefore, widthErrAfter, mFb });
}
rows.sort((a, b) => Math.abs(b.shiftRaw) - Math.abs(a.shiftRaw));
console.log(`\n ${'fallback 字体'.padEnd(30)} ${'size-adjust'.padEnd(12)} ${'原始位移'.padEnd(12)} ${'覆盖后位移'.padEnd(12)} 行盒差(em)`);
for (const r of rows.slice(0, 12)) {
console.log(` ${r.name.slice(0, 29).padEnd(30)} ${pct(r.sizeAdjust).padEnd(12)} ` +
`${(r.shiftRaw.toFixed(1) + 'px').padEnd(12)} ${(r.shiftFixed.toFixed(3) + 'px').padEnd(12)} ` +
`${(lineBox(rendered(r.mFb, {})) - lineBox(rendered(mWeb, {}))).toFixed(6)}`);
}
if (rows.length > 12) console.log(` ... 另有 ${rows.length - 12} 个字体测得同样结论`);
const worstRaw = Math.max(...rows.map(r => Math.abs(r.shiftRaw)));
const worstFixed = Math.max(...rows.map(r => Math.abs(r.shiftFixed)));
const avgAbsRaw = rows.reduce((s, r) => s + Math.abs(r.shiftRaw), 0) / rows.length;
console.log(`\n ${rows.length} 个 fallback: 原始位移 最大 ${worstRaw.toFixed(1)}px / 平均 ${avgAbsRaw.toFixed(1)}px` +
` -> 覆盖后最大 ${worstFixed.toFixed(4)}px`);
console.log(` 字宽误差: 覆盖前 ${(100 * Math.max(...rows.map(r => Math.abs(r.widthErrBefore)))).toFixed(2)}% -> ` +
`覆盖后 ${(100 * Math.max(...rows.map(r => Math.abs(r.widthErrAfter)))).toFixed(6)}%`);
// 示例:直出 @font-face 块(取位移最大的那个 fallback)
const w = rows[0];
console.log(`\n 对位移最大的 ${w.name} 生成的覆盖块:`);
console.log(` @font-face { font-family: "Fb"; src: local("${w.name.replace(/\.\w+$/, '')}");`);
console.log(` size-adjust: ${pct(w.sizeAdjust)};`);
console.log(` ascent-override: ${pct(w.overrides.ascentOverride)};`);
console.log(` descent-override: ${pct(w.overrides.descentOverride)};`);
console.log(` line-gap-override: ${pct(w.overrides.lineGapOverride)}; }`);
check(rows.length >= 8, `在系统里找到并解析了 ${rows.length} 个 fallback 字体`);
check(worstFixed < 0.01, `覆盖后全部 fallback 的 ${FS}px x ${LINES} 行位移 < 0.01px (实测最大 ${worstFixed.toFixed(4)}px)`);
check(worstRaw > worstFixed * 1000, `不做覆盖时最坏位移 ${worstRaw.toFixed(1)}px 是覆盖后的 ${Math.round(worstRaw / Math.max(worstFixed, 1e-9))} 倍`);
check(Math.max(...rows.map(r => Math.abs(r.widthErrAfter))) < 1e-9, '覆盖后平均字宽与网页字体完全一致(换行位置不再跳)');
check(rows.every(r => Math.abs(lineBox(rendered(r.mFb, r.overrides)) - lineBox(rendered(mWeb, {}))) < 0.002),
'所有 fallback 覆盖后行盒高度 == 网页字体(差 < 0.002em)');
/* ================================================================== B */
console.log('\n== B. 字形字节账本(cmap -> gid -> loca/glyf)\n');
const { sizes, offsets } = glyphTable(webF);
let glyfTotal = 0;
for (let i = 0; i < sizes.length; i++) glyfTotal += sizes[i];
console.log(` glyf 表: 累计 ${glyfTotal} B / 声明 ${webF.tables.glyf.len} B 字形数 ${webF.numGlyphs}`);
check(Math.abs(glyfTotal - webF.tables.glyf.len) / webF.tables.glyf.len < 0.02,
`loca 相邻差累加 == glyf 声明长度(误差 < 2%),说明账本口径正确`);
function glyphSet(chars) {
const gids = new Set(), bytes = [];
for (const ch of chars) {
const gid = webCm.map.get(ch.codePointAt(0));
if (gid === undefined || gids.has(gid)) continue;
gids.add(gid);
const n = sizes[gid];
if (n > 0) bytes.push(webF.buf.subarray(webF.tables.glyf.off + offsets[gid], webF.tables.glyf.off + offsets[gid] + n));
}
const raw = bytes.reduce((s, b) => s + b.length, 0);
const joined = Buffer.concat(bytes);
return {
glyphs: gids.size, raw,
br: brotliCompressSync(joined, { params: { [zc.BROTLI_PARAM_QUALITY]: 11 } }).length,
gz: gzipSync(joined, { level: 9 }).length,
};
}
const ascii = glyphSet(Array.from({ length: 95 }, (_, i) => String.fromCharCode(0x20 + i)).join(''));
const corpusFiles = existsSync(CORPUS_DIR)
? readdirSync(CORPUS_DIR).filter(f => f.startsWith('Day') && f.endsWith('.md')).sort() : [];
const corpus = corpusFiles.map(f => readFileSync(join(CORPUS_DIR, f), 'utf8')).join('');
const uniq = new Set(corpus);
const used = glyphSet([...uniq].join(''));
const cjkChars = [...uniq].filter(c => { const p = c.codePointAt(0); return p >= 0x4e00 && p <= 0x9fff; });
const usedCjk = glyphSet(cjkChars.join(''));
// 按 Unicode 区块拆 glyf,看字体体积到底被谁吃了
const BLOCKS = [
['ASCII/Latin', 0x0000, 0x024f], ['希腊/西里尔', 0x0370, 0x04ff], ['标点/符号', 0x2000, 0x2bff],
['CJK 汉字', 0x4e00, 0x9fff], ['CJK 扩展A', 0x3400, 0x4dbf], ['假名', 0x3040, 0x30ff],
['谚文 Hangul', 0xac00, 0xd7af], ['全角/兼容', 0xf900, 0xfaff], ['私用区/其他', 0xe000, 0xf8ff],
];
const blockBytes = new Map();
let accounted = 0;
for (const [cp, gid] of webCm.map) {
const B = BLOCKS.find(([, s, e]) => cp >= s && cp <= e);
const key = B ? B[0] : '其他 (emoji/数学等)';
blockBytes.set(key, (blockBytes.get(key) || 0) + sizes[gid]);
accounted += sizes[gid];
}
console.log('\n glyf 体积被谁吃了:');
const sorted = [...blockBytes.entries()].sort((a, b) => b[1] - a[1]);
for (const [k, v] of sorted.slice(0, 7))
console.log(` ${k.padEnd(24)} ${(v / 1024 / 1024).toFixed(2).padStart(7)} MB ${(100 * v / glyfTotal).toFixed(1).padStart(5)}%`);
const fullBr = brotliCompressSync(webF.buf, { params: { [zc.BROTLI_PARAM_QUALITY]: 11 } }).length;
console.log(`\n 文件体积 (同一套 brotli q11 实测):`);
console.log(` 整个字体文件 ${(webF.fileBytes / 1024 / 1024).toFixed(2)} MB -> brotli ${(fullBr / 1024 / 1024).toFixed(2)} MB (${pct(fullBr / webF.fileBytes)})`);
console.log(` ASCII 95 字形 glyf ${(ascii.raw / 1024).toFixed(2)} KB -> brotli ${(ascii.br / 1024).toFixed(2)} KB (${ascii.glyphs} 字形)`);
console.log(` 语料全字符 glyf ${(used.raw / 1024).toFixed(2)} KB -> brotli ${(used.br / 1024).toFixed(2)} KB (${used.glyphs} 字形)`);
console.log(` └ 其中汉字 ${cjkChars.length} 个 ${(usedCjk.raw / 1024).toFixed(2)} KB -> brotli ${(usedCjk.br / 1024).toFixed(2)} KB`);
console.log(` 语料 ${corpusFiles.length} 篇 / 唯一字符 ${uniq.size} 个 / 用掉字形 ${used.glyphs} 个` +
` = 字体字形总数的 ${(100 * used.glyphs / webF.numGlyphs).toFixed(2)}%`);
const allCjkCount = [...webCm.map.keys()].filter(cp => cp >= 0x4e00 && cp <= 0x9fff).length;
console.log(` 语料用到汉字 ${cjkChars.length} 个 = 字体汉字数 ${allCjkCount} 的 ${(100 * cjkChars.length / allCjkCount).toFixed(1)}%`);
check(ascii.raw / webF.fileBytes < 0.01, `只要 ASCII 时占全文体 ${(100 * ascii.raw / webF.fileBytes).toFixed(3)}% < 1%(拉丁子集化省不出东西)`);
check((blockBytes.get('CJK 汉字') || 0) / glyfTotal > 0.5,
`CJK 汉字占 glyf ${(100 * (blockBytes.get('CJK 汉字') || 0) / glyfTotal).toFixed(1)}% > 50%(中文站字体体积的主战场)`);
check(cjkChars.length < allCjkCount / 10, `真实语料只用到汉字集的 ${(100 * cjkChars.length / allCjkCount).toFixed(1)}% < 10%`);
check(used.br < fullBr * 0.2, `语料子集 brotli 后 ${(used.br / 1024).toFixed(0)}KB < 全字体 ${(fullBr / 1024 / 1024).toFixed(2)}MB 的 20%`);
check(used.br < used.gz, `brotli ${(used.br / 1024).toFixed(0)}KB < gzip ${(used.gz / 1024).toFixed(0)}KB(WOFF2 用 brotli 不是随便选的)`);
check(usedCjk.raw > used.raw * 0.8, `汉字字体里汉字占子集字节的 ${(100 * usedCjk.raw / used.raw).toFixed(1)}%------CJK 站的字体优化就是汉字优化`);
/* ================================================================== C */
console.log('\n== C. font-display 四档:不是选好看的,是选代价放哪儿\n');
const modes = [
['block', 'FOIT', '先不可见(~3s),超时才回退', '3s 白屏 / 零位移'],
['swap', 'FOUT', '立刻用 fallback,字体到了再换', '零白屏 / 有位移(CLS)'],
['fallback', '折中', 'block ~100ms,3s 内到货才换', '位移只在快网发生'],
['optional', '零CLS','block ~100ms,本次不再换', '首次可能用不上自定义字体'],
];
for (const [m, k, d, cost] of modes) console.log(` ${m.padEnd(10)} ${k.padEnd(6)} ${d.padEnd(34)} 代价: ${cost}`);
check(modes.length === 4, 'font-display 四档覆盖「白屏 / 位移 / 丢字体」三种代价');
check(true, `→ 结论: 选 swap 就必须配度量覆盖,否则位移是自找的(实测最坏 ${worstRaw.toFixed(1)}px)`);
console.log('\n== 结果');
if (FAILS.length) { for (const f of FAILS) console.log(' FAILED:', f); process.exit(1); }
console.log(' 断言全部通过');
C. page-waterfall.mjs ------ 首屏瀑布模型(吃真实字节的确定性模型,281 行)
javascript
#!/usr/bin/env node
/**
* page-waterfall.mjs -- Day31 首屏瀑布模型(确定性,吃 img-ladder.json 里的真实字节)
*
* 模型边界(先说清,免得被当成实测):
* - 带宽/延迟/RTT 是给定常数,不是测出来的;字节数是真实测出来的(img-ladder.json)。
* - 它回答的是「同样一组真实字节,因为加载姿势不同,到达时间差多少」,
* 而不是「某台手机上的绝对毫秒数」。
* - 这也是这类问题唯一可比的坐标系:字节是真的,网络是参数。
*
* 用法: node page-waterfall.mjs
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const FAILS = [];
const check = (ok, msg) => { console.log(` ${ok ? 'PASS' : 'FAIL'} ${msg}`); if (!ok) FAILS.push(msg); };
/* ------------------------------------------------ 真实字节(来自实测) */
const jf = join(HERE, 'img-ladder.json');
if (!existsSync(jf)) { console.error('缺少 img-ladder.json,先跑 python3 img-ladder.py'); process.exit(1); }
const ladder = JSON.parse(readFileSync(jf, 'utf8'));
const B = (w, fmt) => { // 等质量前沿上的字节数
const iso = ladder.iso_quality[String(w)];
return iso[fmt];
};
/* ---------------------------------------------------------- 网络与时间轴 */
const NET = {
bandwidthKBps: 200, // 1.6 Mbps 下行
rtt: 150, // 一个 RTT
connSetup: 300, // DNS+TCP+TLS 简化成 2 RTT
maxStreams: 6, // h2 并发流
serverKBps: 5000, // 首字节快,不构成瓶颈
};
const PRIO_DELAY = { 0: 0, 1: 0, 2: 0, 3: 0 }; // 优先级只影响"谁能用带宽",不额外加延迟
/**
* 事件驱动:一个带宽池,按优先级分层取带宽;同层平分。
* task: { name, kb, discoveredAt, prio }
* 返回每个 task 的 { startAt, endAt }
*/
function simulate(tasks) {
const dt = 1;
let t = 0;
const res = new Map();
const remaining = tasks.map(x => ({ ...x, left: x.kb }));
const active = new Map(); // name -> startedAt
const MAXT = 300000;
while (t < MAXT) {
// 1) 谁还没开工
const waiting = remaining.filter(x => x.left > 0 && !active.has(x.name));
// 2) 按优先级挑并发槽
const started = [...active.keys()].map(n => remaining.find(x => x.name === n));
const free = NET.maxStreams - started.length;
if (free > 0) {
const cand = waiting.filter(x => x.discoveredAt <= t)
.sort((a, b) => a.prio - b.prio || a.discoveredAt - b.discoveredAt);
for (const c of cand.slice(0, free)) { active.set(c.name, t); res.set(c.name, { startAt: t }); }
}
const running = [...active.keys()].map(n => remaining.find(x => x.name === n));
if (!running.length) {
const next = remaining.filter(x => x.left > 0).map(x => x.discoveredAt).sort((a, b) => a - b)[0];
if (next === undefined) break;
t = Math.max(t, next);
continue;
}
// 3) 带宽:优先级最高的那层独占,同层平分
const topPrio = Math.min(...running.map(x => x.prio));
const layer = running.filter(x => x.prio === topPrio);
const shareKB = NET.bandwidthKBps * (dt / 1000) / layer.length * (layer.length ? 1 : 0);
for (const x of layer) {
x.left = Math.max(0, x.left - shareKB);
if (x.left === 0) {
const r = res.get(x.name);
res.set(x.name, { startAt: r.startAt, endAt: t + dt });
active.delete(x.name);
}
}
t += dt;
}
return res;
}
/* -------------------------------------------------------------- 页面模型 */
const KB = (bytes) => bytes / 1024;
function buildPage(opt) {
const {
lcpSrc, // { w, fmt } 用哪张图
lazy = false,
fetchpriorityHigh = false,
preload = false,
discovery = 'html', // html | js
decodingSync = false,
fontKB = null, // 字体字节(KB),null = 不用自定义字体
fontPreload = false,
fontDisplayBlock = false,
} = opt;
const htmlKB = 24, cssKB = 18, jsKB = 60;
const imgKB = KB(B(lcpSrc.w, lcpSrc.fmt));
const tasks = [];
const t0 = NET.connSetup;
const htmlArrive = t0 + (htmlKB / NET.bandwidthKBps) * 1000;
tasks.push({ name: 'html', kb: htmlKB, discoveredAt: 0, prio: 0 });
tasks.push({ name: 'css', kb: cssKB, discoveredAt: htmlArrive, prio: 0 });
tasks.push({ name: 'js', kb: jsKB, discoveredAt: htmlArrive + 5, prio: 2 });
// 真实页面从来不是只有一张图:埋点脚本 + 两张首屏下方的懒加载图
tasks.push({ name: 'analytics', kb: 40, discoveredAt: htmlArrive + 60, prio: 2 });
tasks.push({ name: 'below1', kb: 30, discoveredAt: 0, prio: 3 });
tasks.push({ name: 'below2', kb: 30, discoveredAt: 0, prio: 3 });
if (fontKB != null) {
tasks.push({ name: 'font', kb: fontKB,
discoveredAt: fontPreload ? htmlArrive : htmlArrive + 40 + 30, // preload=解析即发现;否则等 CSSOM 算完
prio: fontPreload ? 1 : 2 });
}
// LCP 图的发现时刻:解析到 → 立刻;懒加载 → 布局完成;JS 注入 → JS 执行完
const cssArrive = htmlArrive + (cssKB / NET.bandwidthKBps) * 1000;
const layoutDone = cssArrive + 30;
const jsDone = htmlArrive + (jsKB / NET.bandwidthKBps) * 1000 + 50;
let discoveredAt = htmlArrive;
let prio = 2;
if (preload) { discoveredAt = htmlArrive; prio = 1; }
if (discovery === 'js') discoveredAt = Math.max(discoveredAt, jsDone);
if (lazy) { discoveredAt = Math.max(discoveredAt, layoutDone); prio = 3; }
for (const b of ['below1', 'below2']) {
const i = tasks.findIndex(t => t.name === b);
tasks[i] = { ...tasks[i], discoveredAt: layoutDone };
}
if (fetchpriorityHigh) prio = 1;
if (lazy && fetchpriorityHigh) prio = 1; // 显式 fetchpriority 能救一部分
tasks.push({ name: 'lcpimg', kb: imgKB, discoveredAt, prio });
const res = simulate(tasks);
const img = res.get('lcpimg');
// 解码:默认 off-main-thread;decoding="sync" 才回主线程
const megapixels = (lcpSrc.w * lcpSrc.w * 9 / 16) / 1e6;
const decodeMs = decodingSync ? (megapixels / 100) * 1000 : 0;
const lcp = img.endAt + decodeMs + 8; // + 提交合成
return {
lcp, imgKB, imgStart: img.startAt, imgEnd: img.endAt,
fontEnd: res.get('font')?.endAt ?? null,
detail: res,
};
}
/* ---------------------------------------------------------------- 场景 */
const scenarios = [
{ key: 'S1', name: '1920 JPEG q85 / eager(基线:大图 + 老格式)',
o: { lcpSrc: { w: 1920, fmt: 'jpeg' } } },
{ key: 'S2', name: '1920 WebP(等质量)',
o: { lcpSrc: { w: 1920, fmt: 'webp' } } },
{ key: 'S3', name: '1920 AVIF(等质量)',
o: { lcpSrc: { w: 1920, fmt: 'avif' } } },
{ key: 'S4', name: '960 AVIF(srcset+sizes 选对)',
o: { lcpSrc: { w: 960, fmt: 'avif' } } },
{ key: 'S5', name: 'S4 + fetchpriority=high',
o: { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true } },
{ key: 'S6', name: 'S5 + preload',
o: { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true } },
{ key: 'S7', name: 'S6 + loading=lazy(给首屏图加 lazy)',
o: { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true, lazy: true } },
{ key: 'S8', name: 'S4 但图由 JS 注入(发现晚)',
o: { lcpSrc: { w: 960, fmt: 'avif' }, discovery: 'js' } },
{ key: 'S9', name: 'S4 + decoding=sync(解码回主线程)',
o: { lcpSrc: { w: 960, fmt: 'avif' }, decodingSync: true } },
];
console.log('== 首屏瀑布:同一组真实字节,不同加载姿势\n');
console.log(` 网络参数: ${NET.bandwidthKBps}KB/s (1.6Mbps) RTT ${NET.rtt}ms 建连 ${NET.connSetup}ms h2 并发 ${NET.maxStreams}`);
console.log(` 图源: ${ladder.source} 等质量前沿字节来自 img-ladder.json(实测)\n`);
console.log(` ${'#'.padEnd(4)} ${'场景'.padEnd(42)} ${'图KB'.padStart(8)} ${'发起'.padStart(8)} ${'完成'.padStart(8)} ${'LCP'.padStart(9)}`);
const out = {};
for (const s of scenarios) {
const r = buildPage(s.o);
out[s.key] = r;
console.log(` ${s.key.padEnd(4)} ${s.name.padEnd(42)} ${r.imgKB.toFixed(1).padStart(8)} ` +
`${(r.imgStart + 'ms').padStart(8)} ${(r.imgEnd + 'ms').padStart(8)} ${(r.lcp.toFixed(0) + 'ms').padStart(9)}`);
}
const base = out.S1.lcp;
console.log('\n 相对基线(S1)的增益:');
for (const s of scenarios) {
const d = out[s.key].lcp - base;
console.log(` ${s.key} ${(d <= 0 ? '' : '+') + d.toFixed(0)}ms (${(100 * d / base).toFixed(1)}%)`);
}
const gainCodecOnly = base - out.S3.lcp;
const gainSizesOnly = base - buildPage({ lcpSrc: { w: 960, fmt: 'jpeg' } }).lcp;
const gainBoth = base - out.S4.lcp;
console.log(`\n 拆账: 只换格式(JPEG->AVIF, 仍 1920) ${gainCodecOnly.toFixed(0)}ms | ` +
`只调尺寸(1920->960, 仍 JPEG) ${gainSizesOnly.toFixed(0)}ms | 两者都做 ${gainBoth.toFixed(0)}ms`);
/* ---------------------------------------------------------------- 断言 */
console.log('\n== 不变量断言');
check(out.S3.lcp < out.S1.lcp, `换 AVIF 有效: ${out.S1.lcp.toFixed(0)}ms -> ${out.S3.lcp.toFixed(0)}ms`);
check(gainSizesOnly > gainCodecOnly,
`调尺寸(${gainSizesOnly.toFixed(0)}ms) > 换格式(${gainCodecOnly.toFixed(0)}ms):过发比编码器更贵`);
check(out.S5.lcp <= out.S4.lcp, `fetchpriority=high 不劣于默认: ${out.S4.lcp.toFixed(0)} -> ${out.S5.lcp.toFixed(0)}ms`);
check(out.S6.lcp <= out.S5.lcp, `preload 不劣于不 preload: ${out.S5.lcp.toFixed(0)} -> ${out.S6.lcp.toFixed(0)}ms`);
const lazyCost = out.S7.lcp - out.S5.lcp;
check(lazyCost > 0, `首屏图加 loading=lazy 让 LCP 变慢 ${lazyCost.toFixed(0)}ms(低优先级排队)`);
const discHeavy = buildPage({ lcpSrc: { w: 1920, fmt: 'jpeg' }, discovery: 'js' }).lcp
- buildPage({ lcpSrc: { w: 1920, fmt: 'jpeg' } }).lcp;
const discLight = out.S8.lcp - out.S4.lcp;
const startDelay = out.S8.imgStart - out.S4.imgStart;
console.log(` 发现延迟把"发起"推后 ${startDelay}ms,但"完成"只推后 ` +
`960AVIF ${discLight.toFixed(0)}ms / 1920JPEG ${discHeavy.toFixed(0)}ms`);
check(startDelay > 300, `JS 注入确实让图片晚 ${startDelay}ms 发起(模型里这一步是确定的)`);
check(discLight > discHeavy,
`小图对发现延迟更敏感(+${discLight.toFixed(0)}ms vs ${discHeavy.toFixed(0)}ms):` +
`早开始的那段带宽本来在空转,大图自己就是带宽瓶颈,早开始也在等带宽`);
check(out.S9.lcp > out.S4.lcp, `decoding=sync 把解码搬回主线程,LCP +${(out.S9.lcp - out.S4.lcp).toFixed(0)}ms`);
// lazy 的代价可以吃掉格式+尺寸的全部收益
check(lazyCost > 0 && lazyCost < gainBoth,
`lazy 的代价 ${lazyCost.toFixed(0)}ms = 全部优化收益 ${gainBoth.toFixed(0)}ms 的 ` +
`${(100 * lazyCost / gainBoth).toFixed(0)}%------它不会让优化归零,但它把图排到了埋点脚本和首屏下方图片后面`);
/* --------------------------------------------------------- 字体也进同一条瀑布 */
console.log('\n== 字体进同一条瀑布(字节来自 font-metrics.mjs 的 brotli 实测)');
const FONT = { subset: 417.59, full: 12608 }; // KB: 语料子集 / 不子集化的全字体
const fScen = [
['不加载自定义字体(系统字体)', { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true, fontKB: null }],
['子集 418KB + preload', { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true, fontKB: FONT.subset, fontPreload: true }],
['子集 418KB 不 preload', { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true, fontKB: FONT.subset, fontPreload: false }],
['全字体 12.3MB + preload', { lcpSrc: { w: 960, fmt: 'avif' }, fetchpriorityHigh: true, preload: true, fontKB: FONT.full, fontPreload: true }],
];
const fOut = [];
for (const [name, o] of fScen) {
const r = buildPage(o);
fOut.push({ name, lcp: r.lcp, fontEnd: r.fontEnd });
const fe = r.fontEnd == null ? '---' : r.fontEnd.toFixed(0) + 'ms';
console.log(` ${name.padEnd(28)} 字体到达 ${fe.padStart(9)} LCP ${(r.lcp.toFixed(0) + 'ms').padStart(9)}`);
}
check(fOut[3].fontEnd > fOut[1].fontEnd * 10,
`不子集化的 ${(FONT.full / 1024).toFixed(1)}MB 字体到达比子集慢 ${(fOut[3].fontEnd / fOut[1].fontEnd).toFixed(1)}x`);
// preload 的代价与「被保护的资源有多大」成正比:LCP 图小(46.5KB)时抢不到它,图大(300KB)时就抢得到
const starve = [
['LCP图 1920JPEG, 不 preload 字体', { lcpSrc: { w: 1920, fmt: 'jpeg' }, fontKB: FONT.subset, fontPreload: false }],
['LCP图 1920JPEG, preload 418KB 字体', { lcpSrc: { w: 1920, fmt: 'jpeg' }, fontKB: FONT.subset, fontPreload: true }],
['LCP图 1920JPEG, preload 12.3MB 字体', { lcpSrc: { w: 1920, fmt: 'jpeg' }, fontKB: FONT.full, fontPreload: true }],
];
console.log('\n preload 一个字体要付多少: 取决于 LCP 图有多大');
const starveOut = starve.map(([n, o]) => ({ n, lcp: buildPage(o).lcp }));
for (const r of starveOut) console.log(` ${r.n.padEnd(34)} LCP ${(r.lcp.toFixed(0) + 'ms').padStart(9)}`);
check(starveOut[1].lcp > starveOut[0].lcp && starveOut[2].lcp >= starveOut[1].lcp,
`图大时 preload 字体抢带宽:${starveOut[0].lcp.toFixed(0)} -> ${starveOut[1].lcp.toFixed(0)} -> ${starveOut[2].lcp.toFixed(0)}ms(单调变差)`);
check(Math.abs(fOut[3].lcp - fOut[1].lcp) < 60,
`图小时(46.5KB)12.3MB 字体 preload 对 LCP 几乎无影响(${fOut[1].lcp.toFixed(0)} vs ${fOut[3].lcp.toFixed(0)}ms)------ ` +
`它抢的是带宽,抢不到已经跑完的东西`);
check(fOut[2].fontEnd > fOut[1].fontEnd, `字体 preload 省 ${(fOut[2].fontEnd - fOut[1].fontEnd).toFixed(0)}ms`);
const blockMs = fOut[3].fontEnd < 3000 ? fOut[3].fontEnd : 3000;
console.log(` font-display:block 的 block period 上限 3000ms -> 12.3MB 字体在 3s 时被"放弃",` +
`\n 用户看到的是系统字体,等你 12.3MB 到了再闪一下(= 双倍 CLS)。`);
/* ------------------------------------------------------------ 图片 CLS */
console.log('\n== 图片的 CLS 不是图片,是"没占位"');
const VW = 390, VH = 844, imgW = 390, imgH = 219; // 16:9 首图
function cls(shiftPx, vh) {
const impact = (imgW * Math.max(0, shiftPx)) / (VW * vh); // 受影响区域占视口比例
const distance = Math.min(1, Math.max(0, shiftPx) / vh); // 位移距离占视口比例
return impact * distance;
}
const noDim = cls(168, VH); // 无 width/height: 图片加载后把下方内容推下去(正文首行被打到 168px 处)
const withDim = cls(0, VH);
console.log(` ${VW}x${VH} 视口, 16:9 首图:"`);
console.log(` 无 width/height -> 推开下方内容 168px, CLS 贡献 ${noDim.toFixed(4)}`);
console.log(` 有 width/height -> 预留 ${imgH}px 高, CLS 贡献 ${withDim.toFixed(4)}`);
console.log(` 注意: 0.1 就是"需改进"的噪声线, 单张无尺寸首图一次就能吃掉 0.4 倍的预算`);
check(withDim === 0 && noDim > 0.03,
`无尺寸首图 CLS ${noDim.toFixed(4)}:单张吃掉 0.1 预算的 ${(100 * noDim / 0.1).toFixed(0)}%,三张就爆`);
console.log('\n== 结果');
if (FAILS.length) { for (const f of FAILS) console.log(' FAILED:', f); process.exit(1); }
console.log(' 断言全部通过');