节前我拿一张程序画的促销海报做导出测试。图是 1600×900 的,上半截红底黄字,下半截一张白卡排着几行红字。我用 canvas 的 toBlob 导 JPEG 并把质量从 0.80 一路拉到 0.99。文件从 120 501 字节涨到 270 978 字节,足足涨了 2.25 倍。白卡上那行 22 px 的红字放大看还是糊的。我量的是字边 2 像素宽那条带子里的平均色差(CIE76 ΔE),它只从 8.11 降到 6.35。质量填到 1.00 就完全变了样。色差一下掉到 0.19,文件也胀到 428 KB,是源 PNG 的 3.5 倍。差的不是那 0.01 的量化精度。是色度抽样从 4:2:0 换成了 4:4:4。
程序画的图颜色太干净。我又从网上找了一张现成的 2026 年国庆放假通知模板(稿定设计的模板图)重跑了一遍,方向完全一样。浏览器 JPEG 质量从 0.80 拉到 0.99 体积涨了 3.2 倍,卡片上红字的字边色差只从 6.56 降到 4.48。
这张图截的是这张模板里「10月8日返岗」那一行红字放大 5 倍的样子。图上没有标字。四行从上往下分别是原图、浏览器 JPEG 质量 0.80、0.99 和 1.00。要看的是笔画外沿。第二、三行的字边外面有一圈很淡的粉晕,红色也暗了一点。0.99 比 0.80 好得有限。到了第四行的 1.00,抽样已经换成 4:4:4,跟原图几乎看不出区别。这一行字大约 40 px,要凑近了才看得出差别。日历里那些更小的字放大 6 倍以后,发暗发褐一眼就能看出来。
这种事我不想靠盯放大图来猜。用的是哪种抽样在 JPEG 的文件头里写得明明白白。SOF 段给每个颜色分量记了一个字节的采样因子,常见的是 FFC0 基线和 FFC2 渐进两种。这个字节的高 4 位是水平方向、低 4 位是垂直方向。三个分量依次是亮度 Y 和两个色度 Cb、Cr。两个色度分量写 1×1 的时候只看 Y 就够了。Y 写 2×2 是 4:2:0。写 2×1 是 4:2:2。写 1×1 就是 4:4:4。我早年在音视频 SDK 公司做过三年编解码,拿到码流先看的就是这几个字节。下面这段贴进浏览器控制台就能用。它从 FFD8 后面一段一段往下跳,碰到 SOF 就读第一个分量的那个字节。
// 读 JPEG 文件头的 SOF 段,返回色度抽样方式functionreadSubsampling(buf){constb=newUint8Array(buf);for(letp=2;p+4<b.length;){if(b[p]!==0xff){p++;continue;}constm=b[p+1],len=(b[p+2]<<8)|b[p+3];if(m>=0xc0&&m<=0xc2){constyHV=b[p+11];// 第一个分量 Y 的采样因子return{0x11:'4:4:4',0x21:'4:2:2',0x22:'4:2:0'}[yHV]??yHV.toString(16);}p+=2+len;}}用的时候在 toBlob 的回调里把await blob.arrayBuffer()丢进去就行。我先在 node 里拿三个浏览器内核导出的 12 个文件跑了一遍,又加了 3 个用 Pillow 指定抽样编出来的文件。读出来的结果和文件名、和测试记录全都对得上。它只认 SOF0 到 SOF2 这三种段。它还默认两个色度分量都是 1×1,碰上不是 1×1 的怪文件就会读错。要严谨就把后两个分量的字节也读出来比一下。
有了这个函数,我把质量一档一档往上试。机器是一台 Apple M4 / 16 GB 的 Mac。三个浏览器都是开源构建,版本是 Chromium 149、Firefox 151 和 WebKit 26.5。Chromium 和 WebKit 到 0.99 都还是 4:2:0,要填到 0.995 才换成 4:4:4。Firefox 在 0.89 还是 4:2:0。到 0.895 就换了。质量进编码器之前会被取整成 0 到 100 的整数。0.995 取整是 100,0.895 取整是 90。换成整数来说就简单多了。Chromium 和 WebKit 只有 100 这一档给 4:4:4。Firefox 从 90 起就给。取整这一步是我从字节数反推的,三家的源码我没去翻。回到那张程序海报,导 0.9 的时候 Chromium 出来是 157 626 字节,Firefox 是 246 096 字节,WebKit 是 226 166 字节。Firefox 的文件大了一半多。它的字边色差却只有 2.31,Chromium 和 WebKit 分别是 7.11 和 6.64。看到这组数很容易以为 Firefox 的编码器更好。我拿 Pillow 按质量 90 加 4:4:4 编了一张来对。字节数正好也是 246 096。解码出来的像素逐字节相同。差别全在抽样上,跟编码器谁好谁坏没有关系。
这张是我写的一个导出对比小页面的整屏截图,样本是前面那张网上模板。页面用浏览器自带的 toBlob 按质量 1 导出 JPEG。导出的文件再重新解码,和原图的同一块日历上下对照并按原像素放大 6 倍。上面一半是原图,下面一半是导出以后的。导出那一半的右上角标着质量 1.00、3.47 MB、抽样 4:4:4。大小和抽样是页面直接从导出文件的文件头里读的。重点看日期底下「廿一」「廿二」这几个小字。上下两块都是正红,看不出差别。问题是 3.47 MB 折成字节是 3 643 511,比 3.18 MB 的原图 PNG 还要大。Chromium 要填到这一档才给 4:4:4,代价就是这么大一个文件。
这个门槛直接落在我自己的活上。我在图映 ImgIng 管的就是端侧编码这一块。拿同一张海报做对照时我用的是图映 ImgIng(https://imging.cn/)2026-09-29 的线上版,入口是「压缩 + 转换」,参数全默认,跑在同一个 Chromium 149 上,全程没有上传请求。它的 JPG 走的是浏览器原生编码。滑杆拉到 100 时编码器收到的是 0.99。导出来的文件和 toBlob 0.99 逐字节相同,读 SOF 也是 4:2:0。在 Chromium 里它导出的 JPG 永远是 4:2:0。换成那张网上模板也一样。滑杆 100 导出 1.91 MB 的文件,解码像素和 toBlob 0.99 逐个相同,抽样还是 4:2:0。
想查自己的浏览器就在控制台画一张带红字的图。按 0.8、0.9、0.99 和 1.0 各导一次再把 blob 丢给上面那个函数。四行结果一出来门槛在哪就看到了。我的结论只覆盖这三个开源构建。Chrome 正式版和 Chromium 同源,我没单独验。WebKit 的结果也不能直接当成 Safari 的,我没在正式版 Safari 上验过。主数据来自程序画的平面图。网上那张模板只用来验方向。照片我没测。要是页面非得导出红字 JPEG,浏览器这条路没有抽样开关可拧,toBlob 只收格式和质量两个参数。放到服务端用 libjpeg-turbo 指定 4:4:4 会省事很多。同一张海报用质量 75 加 4:4:4 是 169 640 字节,字边色差 4.47。它比浏览器 0.99 的 4:2:0 小了 37% 还更干净。换成网上那张模板也一样。75 加 4:4:4 的体积大约只有浏览器 0.99 那份的三分之一,字边还更干净。这篇排在十一当天发,祝祖国生日快乐。