图片字体

一、技术难点

Day30 把「字节怎么被组织、被请求、被缓存」讲完了,但留了一个没说破的问题:那 2190KB 里,最大的那一块到底是什么。拆包、tree-shaking、contenthash,全部作用在 JS 和 CSS 上;而一个真实内容站的首屏字节里,图片加字体通常占七到九成。也就是说,Day30 的所有技巧,争的是那条剩下的尾巴。

这一篇管的就是主战场。而主战场上的三个「常识」,我都是在把它们写成断言之后被自己的脚本推翻的:

  1. 「换格式是图片优化的大头」 ------错。同一张 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%------降了尺寸的老格式,打赢了升了格式的老尺寸。
  2. 「宽度减半,字节减到四分之一」 ------不成立。面积比确实是 4.00x,实测字节比只有 3.45x(JPEG)/ 3.35x(WebP)/ 3.09x(AVIF) 。所以 srcset 的档位不能按面积外推,只能按实测刻度铺。这个小偏差在 AVIF 上最大(3.09x),恰恰是大家最想上的那个格式。
  3. 「字体的 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」的定时器,而是两层动作:

  1. 推迟发现:懒加载图的 URL 不参与解析器的预加载扫描(preload scanner)。浏览器要等布局完成、拿到元素的位置,才在「距视口 N 像素」的阈值内决定发起请求。首屏图虽然一布局就在视口里,但这一段「等布局」本身就是纯延迟------模型里是 120ms(540ms − 420ms)。
  2. 压低优先级 :懒加载图即使发起了,拿到的也是 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?因为「字节 ÷ 像素」不是常数,它有两个来源:

  1. 固定开销:文件头、EXIF、色彩配置、元数据与编码器的原子结构,在小图上被摊到更少的像素上。AVIF 尤其明显------它按 superblock(典型 64×64)组织预测,一张 320px 宽的图只有 5 列 superblock,边缘那一列几乎全是补边浪费,而 1920px 有 30 列。
  2. 高频密度上升 :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>

三条容易被忽略的规则:

  1. sizes 的媒体查询只看布局 。容器宽度、grid 列数、max-width 都要算进去;写成 100vw 等于没写。
  2. <img> 上的 width/height 是给浏览器算 aspect-ratio 用的,不是显示尺寸。写原始像素、交给 CSS 缩放,才能白拿「零 CLS」。
  3. 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 的脚本里验证):

  1. 行盒高度(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 完全对上。
  2. 字宽与字号严格线性,所以要「让 fallback 的平均字宽等于网页字体」,只需要一个缩放比: size-adjust = avgAdv(网页字体) / avgAdv(fallback)
  3. 但 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`);

两个必须知道的坑:

  1. hhea 与 OS/2 的度量在 Windows 和 macOS 上口径不同 (Windows 用 usWinAscent/usWinDescent,macOS/Chromium 用 hhea)。上面的脚本用 hhea,所以数字是 macOS/Chromium 口径;跨平台一致的写法是把两套都算出来,取更保守的那套(Chromium 的官方生成器也是这么处理差异的)。
  2. 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 ------

三条硬结论:

  1. 拉丁子集化省不出东西 。ASCII 95 个字形只占全文体 0.095%。如果你做的英文站,字体本来就 20KB 级,别在子集化上耗时间------那点收益还不如一张图的 1%。
  2. CJK 站不子集化没有出路 。不子集化 12.32MB,在 200KB/s 上要 63.8 秒 ;子集 418KB 只要 2.1 秒 ,差 22.5 倍。
  3. 子集里 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 都会把它抵消掉一部分。

场景五:存量项目接图片流水线

不要一次性重做。按这个顺序接,每步都能独立验收:

  1. 先加断言,不改代码 :把 img-ladder.py 接到 CI,对现有图片出一张「等质量前沿」表,看当前在曲线的哪个位置。
  2. 补 width/height:纯 HTML 改动,零风险,CLS 立刻归零(0.0396 → 0)。
  3. 补 sizes:先只改 LCP 那几张,收益最大、风险最小。
  4. 上 AVIF + <picture>:保留 JPEG 兜底,灰度发布。
  5. 字体 :度量覆盖 → 子集化 → 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。三个默认值,三个缺口,全部由浏览器替你选择,且都选了最坏的那个。

具体到可复用的判断:

  1. 先测再优化,且必须用等质量坐标系 。跨格式比 quality 是无效比较(WebP q85 比 JPEG q85 又多 0.9dB 又小 60KB)。用 PSNR 对齐后,格式的收益是一个干净的常数:WebP -31%-37%,AVIF -46%-47%,与图片大小无关。
  2. 尺寸是量级,格式是乘数 。宽度减半,字节只减到 1/2.9~1/3.5(不是 1/4)------所以 srcset 必须按实测刻度铺,不能按面积外推。
  3. LCP 图只有一张,所有优先级都必须指向它 。preload 只在发现晚时有价值(S6 = S5);lazy 在首屏图上纯亏(+29ms 起);fetchpriority="high" 是唯一稳定的排队收益(1185 → 752ms)。
  4. preload 不免费 :preload 一个 12.3MB 字体,在 300KB 图的页面上把 LCP 拖成 65.5 秒,在 46.5KB 图的页面上几乎无感------优先级是零和的,你给谁加,就是从谁身上拿。
  5. CLS 的修法是预留空间,不是加载更快 。width/height 两个属性换 0.0396 → 0;字体的位移靠 size-adjust + 三个 -override,33 个 fallback 实测全部归零。font-display 一个位移都消不掉。
  6. 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('  断言全部通过');
相关推荐
Bruce_Liuxiaowei3 小时前
前端校验登录态,等于没校验——一次应急广播终端高危漏洞的发现与报送
运维·前端·安全
Meta Luo3 小时前
AI 时代,Web 自动化测试该怎么做?
前端·人工智能·playwright 替代工具·无代码自动化测试工具·低代码 ui 自动化测试·web 回归测试工具
去伪存真3 小时前
AI Agent 性能评估指标与自动化测试落地方案
前端·agent
MaAidan3 小时前
用 Agent 排查线上问题:SLS 日志分析 + DB/Redis 数据验证,一条链路搞清楚
前端·bootstrap·html
墨林陌4 小时前
AI 热点日报(2026-11-03):美团发布 1.6 万亿参数国产大模型,四大厂商共推 AI 训练退出标准
前端
Moment4 小时前
DeepSeek 发布新模型,直接把 Agent 联网搜索的门槛打下来了!
前端·后端
AI宇宙狂想家4 小时前
开源 AI 视频工具进化史:从难用到好用,OpenMontage 改了什么
开发语言·前端·人工智能·开源·ai创业·openmontage
Sailing4 小时前
🚀🚀【封闭结束7】Agent 为什么总是“失忆”?从长期记忆到上下文工程的生产实践
前端·人工智能·agent
OpenTiny社区4 小时前
AI 生成的 Vue 代码,终于能直接用了!
前端·vue.js·github
Ramble_Naylor5 小时前
Rust深度解读:高性能 Web 网关与 Axum 架构剖析
前端·架构·rust