前端怎么把图片压到指定大小?用二分法找质量
摘要需求单上只有一句话商品主图上传前压到 200KB 以内详情图压到 2MB 以内。组里原有的上传组件带一个压缩函数。它从质量 0.92 开始编、超了就减 0.05 再编第一次不超就停。接硬性上限之前我照例先拿它压测了一轮。它确实能交差可交出来的文件常常只用了一半的额度真实照片压 2MB 时 JPEG 只用了 64.4%WebP 只用了 48.9%。剩下那部分额度本来可以换成画质。这篇讲能上线的「压到 ≤ N 字节」函数怎么写核心是对质量参数做二分。我更想讲清楚的是每条边界为什么这样定。二分凭什么成立为什么用整数二分「200KB」到底是多少字节为什么先用最低质量编一次、质量上限又封在 0.99原图已经达标时要不要重编WebP 编一次要几百毫秒又该怎么办每一条都有实测数字撑着。四段代码都在本机跑过输出是原样贴的。质量调到下限还超标的图这篇只管到函数返回为止。缩尺寸是另一件事第七章只讲怎么交接。版本声明实测数据是 2026-09-28 晚上跑的四段代码是 2026-09-29 上午补跑的。机器是一台 16 GB 内存的 Apple M4 Mac系统是 macOS 26.5.2。浏览器是 Chromium 149 的开源构建149.0.7827.55不是日常装的那个 Chrome。编码全走浏览器自带的canvas.toBlobJPEG 用的是内置的 libjpeg-turboWebP 用的是内置的 libwebp。文中的 KB 和 MB 都按 1024 进制算。200KB 就是 204 800 字节2MB 就是 2 097 152 字节。第四章专门讲这个口径因为它真的会出事。适用边界这篇讲什么。讲浏览器端上传前的有损压缩给定一个字节上限和目标格式JPEG 或 WebP尺寸保持不变要找的是不超限的文件里质量最高的那个。最后交出来的是一个函数和它在页面上的调法。这篇不讲什么。缩尺寸的算法、PNG 减色和 AVIF 编码都不讲。Chromium 149 自带的编码本来就出不了 AVIF我让它编image/avif它一声不吭回了个 272 字节的 PNG。Safari、Firefox 和手机浏览器也不在范围里这一轮一个都没测。样本只有五张。第一张是系统自带的一张风景照片3840×21608.3 MP画面是湖面、岩石和雪山我另外把它缩了一份 1920×1080 的。还有两张是程序按固定种子生成的类照片纹理12 MP 和 24 MP 各一张。它们都不是照片。最后一张是一个工具说明页的截图1440×2400大部分是文字和界面。那张风景照片有版权文中配图一张都不展示它。文章目录一、为什么一档一档往下调会浪费额度1.1 新需求要求主图不超过200KB1.2 往下调质量快是因为提前停下1.3 要找的是不超限里最高的质量二、压缩质量越高文件一定越大吗2.1 四张测试图都是质量越高文件越大2.2 质量越往上调体积涨得越快2.3 质量和体积同涨只在四张图上验证过三、为什么二分时质量只取整数3.1 质量填到小数点后三位没有意义3.2 浮点二分多编一两次拿到同一个文件3.3 WebP用整数二分只差不到一档四、写压缩函数前200KB该算成多少字节4.1 差几百字节也可能被后端拒收4.2 全项目只在一处换算限额五、前端压到指定大小的函数怎么写5.1 压缩函数返回三种状态5.2 原图已经达标就不再重新编码5.3 先试最低质量能省掉白跑的二分5.4 质量上限要避开会变无损的那一档5.5 质量下限不等于画质门槛六、WebP编码慢时页面该怎么做6.1 大图出WebP一轮二分要等7.9秒6.2 编码期间定时器最大间隔只有19毫秒6.3 点取消后过了274毫秒才返回6.4 截图改用WebP画质明显更好七、压到最低还超标的图怎么交给缩尺寸八、二分压缩换浏览器后还能照搬吗九、测压缩耗时和体积时哪些数不可靠参考资料一、为什么一档一档往下调会浪费额度1.1 新需求要求主图不超过200KB我们组负责商家后台的商品图链路商家在前端选图前端压缩后再传到我们自己的存储。压缩放在浏览器里做有两个原因一是省带宽二是合规按规定上架前的商品图不能过第三方压缩服务。前两年我经历过一次数据合规整改。从那以后我评审方案都会先问一句「图在哪一步离开员工电脑」。这次的新需求是给图片加体积上限主图 ≤200KB、详情图 ≤2MB。旧组件的压缩函数是很常见的那种写法。它从 0.92 开始编一次超了就减 0.05 再编直到不超或者减到 0.30 为止。这个函数只有十来行线上跑了两年也没出过工单。我本来打算直接复用。改之前我照例先问影响面接上硬性上限以后会有多少图被它白白压低了画质没人答得上来我就自己跑了一轮。1.2 往下调质量快是因为提前停下我拿三种写法做了对照。逐档下调就是旧组件那种一档一档往下减的写法浮点二分在 0.30 到 1.00 之间分到区间小于 0.01 为止整数二分只在 30 到 100 的整数里分。结果看「填充率」也就是结果字节除以目标字节。它越接近 100%说明限额浪费得越少、画质让得越少。这张图要看橙色条和另外两色的差距。每组三根条里橙色是逐档下调、绿色是整数二分、蓝色是浮点二分条长是填充率右边标的是编码次数。前两组是真实照片压 2MB。橙色只有 64.4%JPEG和 48.9%WebP两根都只编了 1 次。它快就快在第一次就停0.92 编出来已经不超限它就不会再往上找。绿色和蓝色都在 93% 以上。两种二分只在第三组差得比较明显24 MP 的图压到 2MB 出 WebP整数二分是 95.8%、浮点二分是 99.7%。第四组是截图压到 200KB 出 WebP橙色也只有 77.7%同样是编 1 次就停了。逐档下调在另一头也不好看。限额紧到调质量根本压不下去的时候它要一路减到 0.30 才放弃。真实照片要压到 200KB 出 JPEG 时它编了 13 次才认输。压得下去的时候它也不一定省事。把真实照片压到 200KB 出 WebP它编了 13 次、用了 4.31 s整数二分只编了 6 次、用了 1.79 s。两边的填充率都是 99.2%。旧写法真正的毛病是答错了题。它回答的是「有没有一个档位不超限」需求问的却是另一件事。1.3 要找的是不超限里最高的质量「压到 ≤ N 字节」翻成代码就一句找出让编码结果不超过 N 字节的最大 q。质量越高文件越大所以不超限的 q 是从下限开始连着的一段我们要的是这一段最右边那个。逐档下调是从右往左走的碰到第一个不超限的档位就停。步长 0.05 就是说它停下的地方最多比最右边低 0.05 左右。这 0.05 在曲线平的那段不值钱到了陡的那段就是一大截体积。第二章会看到常见的限额偏偏落在陡的那段。在有序区间里找端点正是二分拿手的事。它只有一个前提质量越高体积一定越大。这个前提我没直接当真先量了一遍。二、压缩质量越高文件一定越大吗2.1 四张测试图都是质量越高文件越大我把四张样本分别用 JPEG 和 WebP 从 q0.30 编到 1.00。步长 0.02、每条曲线 36 个点一共 8 条。查法很土相邻两个点只要出现「质量升了、体积反而降了」就记一次。结果 8 条曲线全部单调一次反例都没有。样本 · 格式q0.300.800.901.00真实照片 · JPEG31579211895737真实照片 · WebP1894708327787类照片 24MP · JPEG7852722449234655截图 · JPEG110205263773截图 · WebP69111143753表里单位是 KB。8 条曲线我挑了 5 条、每条列 4 个点其余几条走势一样。2.2 质量越往上调体积涨得越快同一条曲线前后两段的陡度差得很远。真实照片 JPEG 从 q0.30 到 0.80 跨了 50 档体积才涨到 2.5 倍从 0.90 到 1.00 只跨 10 档体积就涨到 4.8 倍。截图的 JPEG 更平0.30 到 0.80 只涨 1.9 倍。这对二分有两层意思。第一层是好消息后段陡说明在高质量那段差一两档体积就差很多。二分每一步的判定都很干脆不会碰到一大片「只差一点点」的区域。第二层是提醒旧组件的 0.05 步长偏偏踩在陡的那段。真实照片出 JPEG 时 q0.90 是 1189 KB调到 0.96 就涨到了 1981 KB。在这段多退一步就可能浪费掉三分之一的限额。表里还能顺便看出一件事。浏览器 JPEG 在 q0.30 时真实照片还有 315 KB、24 MP 的类照片有 785 KB。这几张图光靠调质量压不到 200KB第五章讲的先用最低质量试一次就是为这种图准备的。2.3 质量和体积同涨只在四张图上验证过单调这个结论我只在这四张图和 Chromium 149 自带的两个编码器上验过步长也只细到 0.02。再细下去有没有局部抖动我没测。照理说 libjpeg-turbo 和 libwebp 的码率应该随质量单调上升。可「应该」两个字我不敢直接写进上线代码。函数里我是这么处理的结果对不对不靠单调只有快不快靠它。二分过程中我一直记着「目前已知不超限里质量最高的那个结果」最后返回的就是它。万一某张图在某一段不单调二分可能找不到真正的最优点但返回的文件一定不超限。质量次一点我能接受超限不能接受。代码里best变量那样写就是为了这个第五章会看到。三、为什么二分时质量只取整数3.1 质量填到小数点后三位没有意义两种二分的差别得从浏览器怎么处理质量参数说起。我先写了个最小的编码封装再拿截图样本在 0.95 到 0.964 之间挑了七个质量值看各自编出多少字节。functionencodeOnce(canvas,mime,quality){returnnewPromise((resolve,reject){conststartedperformance.now();canvas.toBlob(blob{if(!blob)returnreject(newError(toBlob 返回了 null));if(blob.type!mime)returnreject(newError(要${mime}拿到${blob.type}));resolve({blob,ms:performance.now()-started});},mime,quality);});}asyncfunctioncheckQualityStep(canvas,mime){constrows[];for(constqualityof[0.95,0.951,0.955,0.959,0.96,0.961,0.964]){const{blob}awaitencodeOnce(canvas,mime,quality);rows.push(${quality.toFixed(3)}→${blob.size});}returnrows.join(\n);}// 截图 1440×2400 画进同尺寸 canvas 后分别跑 JPEG 与 WebPJPEG 0.950 → 336289 0.951 → 336289 0.955 → 362299 0.959 → 362299 0.960 → 362299 0.961 → 362299 0.964 → 362299 WebP 0.950 → 184668 0.951 → 184668 0.955 → 193098 0.959 → 193098 0.960 → 193098 0.961 → 195376 0.964 → 197578代码说明。后面所有代码都建在encodeOnce上它比常见写法多了两道检查。第一道回调拿到 null 就直接报错第二道拿回来的类型和要的不一样也报错。浏览器编不了某种格式时会悄悄换成 PNG前面说的 AVIF 就是这样。少了这道检查就会拿一个 PNG 的体积去和限额比。输出里 JPEG 那一段最能说明问题0.955 到 0.964 这五个值编出来一字节不差0.951 也和 0.950 完全一样。浏览器把 JPEG 的质量四舍五入到了整数档0.955 已经被算成 96。这样有效的质量值只有 101 个二分细到 0.01 以下就是在重复编同一个文件。3.2 浮点二分多编一两次拿到同一个文件看懂这个粒度就能解释两种二分的结果了。浮点二分在 [0.30, 1.00] 上一直分到区间小于 0.01连同开头先用最低质量编的那一次一共要编 8 次。整数二分在 30 到 100 的整数上分最多 7 次。实测里 JPEG 每一组的两种二分拿到的都是同一个文件。真实照片压到 2MB 时浮点二分最后停在质量 0.962整数二分停在 0.96填充率都是 96.7%。浮点二分一共编了 8 次、用了 259 ms整数二分编了 6 次、用了 189 ms。截图压到 200KB 时两边是 8 次对 6 次填充率都是 99.1%。这张图是我写的复现页跑的是上面那组真实照片压 2MB 的 JPEG 整数二分开头没有先用最低质量试那一次。每一行是一次编码试的质量、得到的字节数和下一步往哪边走。要看的是第 4 行 q0.96。它已经是最终答案了后面试的 0.98 和 0.97 都超限这两次只是在确认比它高的档位都不行。底下一行是结果q0.96、填充率 96.7%、6 次编码、189 ms截图为单次运行。浮点多出来的一两次编的是整数档之间的小数浏览器会把它们四舍五入到相邻的整数。编出来的文件不是和上一次一样就是和下一次一样这一两次的时间纯属浪费。JPEG 编一次才二三十毫秒这点浪费不大换成每次几百毫秒的 WebP 就值得省了。3.3 WebP用整数二分只差不到一档WebP 那一段和 JPEG 不同0.960 和 0.961 编出来是两个文件。说明 libwebp 确实用上了小数部分照理说浮点二分在 WebP 上能多挤出一点填充率。实测里也真有一组是这样24 MP 类照片压到 2MB 时浮点二分是 99.7%、整数二分是 95.8%。其他几组差得不多真实照片压到 200KB 两种都是 99.2%截图压到 200KB 时浮点是 98.6%、整数是 97.8%。我最后给 WebP 也用了整数二分理由有两个。一是差出来的不到一档。截图压 200KB 时整数二分停在 q97q98 就超了。浮点二分再怎么找也只能落在 0.97 和 0.98 之间。二是 WebP 每次编码要几百毫秒到一秒多编一两次用户就要多等一两秒第六章细讲。顺带还有个好处两种格式走同一条路测试用例少一半。这是我主动做的取舍不等于整数二分在 WebP 上更好。如果你们限额很紧、画质优先、又只出 WebP浮点二分值得单独评估一下。四、写压缩函数前200KB该算成多少字节动手写函数之前还得先把「200KB」换算成一个确定的字节数。4.1 差几百字节也可能被后端拒收1920×1080 的真实照片用 JPEG 二分到 ≤200KB结果是 q70、204 214 字节。按 200×1024 204 800 算它合格按 200 000 算它超了 2%。前端说合格后端要是按 1000 进制校验就会拒收。这类问题联调时很难撞上。测试同学拿来的图大多远小于或者远大于限额恰好卡在 200 000 到 204 800 之间的图很少。上线以后它会变成「偶发上传失败」一天几单还复现不了。这次是我在评审时主动去问了存储那边的同事他们的校验按 1024 走口径这才定下来。判断条件也要写死成blob.size limitBytes小于等于比的是字节。不要比界面上显示的「199.4 KB」这类四舍五入过的数也不要写成。二分的结果本来就贴着限额差一个字节的情况在这里天天有。4.2 全项目只在一处换算限额我的做法是只在一个地方把「200KB」这种人话换算成字节前端和后端读同一份配置。constUPLOAD_LIMITS{productMain:200KB,detailImage:2MB};// 1K 1024与存储侧校验同一口径constUNIT_BYTES{B:1,KB:1024,MB:1024**2};functionlimitToBytes(text){constm/^(\d(?:\.\d)?)\s*(B|KB|MB)$/i.exec(String(text).trim());if(!m)thrownewError(看不懂的限额${text});returnMath.floor(Number(m[1])*UNIT_BYTES[m[2].toUpperCase()]);}constresultBytes204214;// 真实照片 1920×1080 整数二分到 JPEG q70 的结果for(const[name,text]ofObject.entries(UPLOAD_LIMITS)){console.log(${name.padEnd(12)}${text.padEnd(6)}→${limitToBytes(text)}B);}constby1024limitToBytes(200KB),by1000200*1000;console.log(${resultBytes}≤${by1024}?${resultBytesby1024});console.log(${resultBytes}≤${by1000}?${resultBytesby1000}超${((resultBytes/by1000-1)*100).toFixed(1)}%);productMain 200KB → 204800 B detailImage 2MB → 2097152 B 204214 ≤ 204800 ? true 204214 ≤ 200000 ? false超 2.1%代码说明。换算函数只认 B、KB、MB 三个单位看不懂就直接抛错。「200k」「0.2M」这类写法我故意不支持配置写错了我宁可它在构建时就炸。向下取整Math.floor是给「1.5MB」这类小数留的保证结果是整数字节。这段本身就是算术。它有用是因为全项目只有这一个换算点前端二分用它、后端校验也用它改口径只改一处。输出最后一行就是 4.1 节的例子204 214 字节按 1024 合格、按 1000 超 2.1%。配置旁边那行注释写了 1K 1024、跟谁对过以后谁想改口径至少知道先找谁。五、前端压到指定大小的函数怎么写5.1 压缩函数返回三种状态把前几章的结论合起来就是下面这个函数。输入是画好图的 canvas、目标格式和字节上限。输出分三种状态kept表示原文件直接能用too-big表示质量调到下限还超限ok表示找到了不超限里质量最高的那一档。asyncfunctioncompressToLimit(canvas,options){const{mime,limitBytes,minPct30,maxPct99,// 99 封顶WebP 到 1.0 会切成无损sourcenull,acceptTypes[],// 原文件本身已经能交差就不重编signal,onStep(){},}options;if(sourceacceptTypes.includes(source.type)source.sizelimitBytes){return{status:kept,blob:source,pct:null,encodes:0};}letencodes0;consttryPctasyncpct{signal?.throwIfAborted();const{blob,ms}awaitencodeOnce(canvas,mime,pct/100);encodes1;onStep({pct,bytes:blob.size,ms,encodes,fits:blob.sizelimitBytes});returnblob;};// 先编一次下限下限都超二分没有意义直接交给缩尺寸constfloorBlobawaittryPct(minPct);if(floorBlob.sizelimitBytes){return{status:too-big,floorBytes:floorBlob.size,encodes};}// 整数二分best 始终是「已知不超限里质量最高」的那个letbest{pct:minPct,blob:floorBlob};letlowminPct1,highmaxPct;while(lowhigh){constmid(lowhigh)1;constblobawaittryPct(mid);if(blob.sizelimitBytes){best{pct:mid,blob};lowmid1;}elsehighmid-1;}return{status:ok,...best,encodes};}我拿截图和真实照片跑了六个用例每一步试的质量和得到的字节都打了出来截图 JPEG ≤200KB ok q78 202864 B 7 次 59 ms q30:112297✓ q65:167449✓ q82:218394✗ q73:186461✓ q77:199141✓ q79:205669✗ q78:202864✓ 截图 WebP ≤200KB ok q97 200224 B 7 次 704 ms q30:70912✓ q65:95992✓ q82:119104✓ q91:154768✓ q95:184668✓ q97:200224✓ q98:207772✗ 截图 WebP ≤2MB ok q99 214968 B 8 次 807 ms q30:70912✓ q65:95992✓ q82:119104✓ q91:154768✓ q95:184668✓ q97:200224✓ q98:207772✓ q99:214968✓ 截图 WebP ≤2MB 收PNG原图 kept 477925 B 0 次 照片 JPEG ≤200KB too-big 下限 322425 B 1 次 20 ms q30:322425✗ 照片 WebP ≤200KB ok q32 203202 B 7 次 2201 ms q30:194030✓ q65:336502✗ q47:266838✗ q38:227898✗ q34:211466✗ q32:203202✓ q33:209038✗代码说明。只有编出来不超限才会更新best它的初值是下限那一次的结果。不管二分走到哪返回的文件都不会超限。这就是 2.3 节说的「单调只管快慢不管对错」。编码、计数和进度回调都收在tryPct里第六章的取消也挂在这。输出里有一处次数和前面对不上截图 JPEG 压 200KB 第三章是 6 次、这里是 7 次。多出来的就是开头先用最低质量编的那一次5.3 节讲我为什么愿意多付这一次。结果文件和前面的实测一字节不差截图 JPEG 都是 q78、202 864 B截图 WebP 都是 q97、200 224 B。5.2 原图已经达标就不再重新编码函数第一段是个很容易漏的判断原文件本来就不超限、格式业务上也能接受就直接把原文件还回去编码次数是 0。输出里「收PNG原图」那个用例就走了这条路。截图原文件是 477 925 字节的 PNG比 2MB 小得多。详情图要是收 PNG 它根本不用重编。漏了这个判断会出两个问题。一是画质白白变差原图本来就合格重编一次只会离原图更远。二更隐蔽重编出来的可能比原图还大。这张截图按 WebP 二分到 q99 是 214 968 字节刚好比原图小。已经压得很狠的 JPEG 用高质量重编后变大是常事。我还加了acceptTypes参数因为格式本身也是需求的一部分。主图下游只收 JPEG 的话一张 150KB 的 PNG 也得重编。这个名单要和业务方逐条对我没替他们设默认值。5.3 先试最低质量能省掉白跑的二分函数第二段先用最低质量 q30 编一次。最低质量都超限就说明光调质量解决不了。这时直接返回too-big并带上这一次编出来的字节数。上面那张真实照片压到 200KB 出 JPEG 就走了这条路只编了 1 次、用了 20 ms最低质量下是 322 425 字节。这张图把两张压不下去的图放在一起系统自带的那张风景照片和 24 MP 的生成图目标都是 204 800 B 的 JPEG。第二列是 q0.30 时的字节数两张都已超限24 MP 那张在下限就有 785 KB。后三列是三种写法确认压不下去之前各编了几次。先用最低质量试一次的写法 1 次就结束整数二分要 6 次、逐档下调要 13 次截图为单次运行。不先试最低质量要多花多少实测里直接做整数二分的写法在这个用例上编了 6 次才发现压不下去逐档下调编了 13 次。JPEG 一次二三十毫秒6 次也就一百多毫秒。换成 WebP 或者 24 MP 的大图6 次就是好几秒。这几秒是白等的最后照样要去缩尺寸。图压得下去的时候这一次试编就是多花的。第三章说整数二分要 6 到 7 次加上这一次我这个函数要 7 到 8 次和浮点二分的 8 次差不多。我犹豫过要不要去掉它。另一种写法是不先试等二分结束还没找到合格的再判失败。压得下去时能省一次。最后还是留下了因为两边代价不对称压不下去时要多付五六次编码。压不下去的偏偏又是大图也就是编码最慢的那些。我宁可每张图多等一次最快的编码下限质量编得最快也不想让最大的那几张多等五六次。还有一个次要的好处too-big带回了最低质量编出来的字节数。缩尺寸那一步可以直接拿它和限额的比值估缩放比例不用再编一次。5.4 质量上限要避开会变无损的那一档这是最反直觉的一条边界。直觉上质量上限当然是 1.0限额很松的时候就该给最高质量。实测里偏偏是这种情况出了事。质量上限不封顶的话整数二分会把截图的 WebP限额 2MB一路推到 q1.00得到 753 KB771 126 字节。封在 0.99 的话结果是 214 968 字节约 210 KB。0.999 时还只有 217 KB到 1.0 一下变成 3.5 倍。原因是 Chromium 里 WebP 的 q1.0 会切成无损编码。我把 q1.0 的 WebP 解码回来和源图逐个比较13 824 000 个通道值的差异是 0逐像素相同。真实照片也一样0.98 是 1805 KB、1.0 是 7787 KB跳了 4.3 倍。限额松的时候拿到的反而是个无损大文件。它不超限每个功能用例也都会显示「通过」。可详情图 2MB 的限额本来是挡极端大图用的结果每张截图都被放大成七八百 KB。存储、CDN 流量和商家端的加载时间全都跟着涨这种问题靠功能测试发现不了。JPEG 在 q1.0 没有这个跳变它只是一个很大的有损文件。为了让两种格式走同一条路我把上限统一封在 99。有人问过要不要封在 0.995。WebP 在那里是 222 292 字节比 0.99 多 7324 字节。我的回答是整数二分本来就走不到 99.5封在 99 还顺带让两种格式的参数范围一致。5.5 质量下限不等于画质门槛下限我取了 30。这个数没什么讲究实测也是从 0.30 起的。要讲究的是另一件事别把它当成各种格式通用的画质门槛。同样压到 ≤200KB 时不同格式二分出来的质量值差得很远。1920×1080 的真实照片上 JPEG 的质量停在 70、WebP 停在 81截图上 JPEG 停在 78、WebP 停在 97。画质也不一样照片那组的 PSNR 是 JPEG 35.43 dB、WebP 37.18 dB截图那组是 39.85 dB 对 45.66 dB。两种格式的质量数字根本不能拿来比。产品如果提「质量不能低于 60」这种要求我会先问清楚是哪个格式的 60。写这个函数之前我先看了一眼现成工具怎么处理这件事。我对照用的是图映 ImgInghttps://imging.cn/的图片压缩看的是 2026-09-28 线上版本的界面和页面脚本浏览器同上。它没有「压到指定大小」这个选项。它给的是 30 到 100 的质量滑杆和一个按图片内容推荐的质量。页面脚本里有一张分档表分照片、平滑、纹理、图形文字四类。WebP 分别推荐 75、72、80、84JPG 分别推荐 80、78、84、88。代码注释说这张表按 SSIM 实测加肉眼 A/B 标定过同一类内容 JPG 比 WebP 高 4 到 6 档。这和我上面看到的方向一致。格式按钮上还标着WebP 和 PNG「即时」、AVIF「按需 WASM」。这和前面浏览器编不出 AVIF 对得上。它的脚本里还有一处和 5.4 节的边界一样界面上的 100 送进编码器时会变成 0.99。注释写的理由是避免浏览器把 1 当成「特殊无损/极慢档」。我是先测出无损跳变后来才看到这行注释的两边的结论对上了。它和我这个函数是两个方向。我卡的是字节、质量是算出来的它卡的是观感、体积是算出来的。我们下游卡的是字节只能走前一条路。但它提醒了我一件事限额很松时二分会把质量推到 99可对一张截图来说 84 可能就已经看不出差别了。我在函数上留了一个口子业务方可以按图片类型传一个比 99 低的maxPct。目前还没人用默认值仍是 99。六、WebP编码慢时页面该怎么做6.1 大图出WebP一轮二分要等7.9秒JPEG 和 WebP 在浏览器里的编码速度差了一个数量级。每张样本在曲线实测里编了 36 次取中位数截图 JPEG 8 ms、WebP 94 ms8.3 MP 真实照片 JPEG 21 ms、WebP 321 ms12 MP 类照片是 JPEG 37 ms、WebP 508 ms24 MP 类照片 74 ms 对 990 ms。乘上二分的次数以后用户就能明显感觉到了。24 MP 的图做一轮二分JPEG 大约 0.5 sWebP 要 7.9 到 8.8 s。前面函数的输出里真实照片压到 200KB 出 WebP 编了 7 次、用了 2201 ms。商家一次选二十张详情图全出 WebP 的话要等一两分钟。6.2 编码期间定时器最大间隔只有19毫秒我最初担心的是卡死。因为toBlob是回调接口我一直以为编码本身跑在主线程上编一秒页面就冻一秒。评审时我还提过一个方案把编码挪进 Worker 用 OffscreenCanvas 做。实测把这两个判断都推翻了。编码期间我在主线程挂了个 16 ms 的定时器专门记相邻两次回调的最大间隔。24 MP 的类照片连续做 7 次 WebP 编码来模拟一轮二分一共 7622 ms。定时器最大间隔只有 19 ms页面全程都能点。只有页面里第一次编码会顿一下60 到 180 ms单独编一次 24 MP 的 WebP 时这一下测到 177 ms。我推测是画布像素回读造成的没有拆开验证。Worker 方案也白搭。同一张 24 MP 图编 WebP 在主线程上是 1165 ms、Worker 里是 1146 ms基本一样快。走 Worker 反倒让主线程多出 223 ms 的长任务longtask直接在主线程编是 0。我推测这部分来自createImageBitmap和数据传输同样没有拆开。这几秒是用户要等的时间页面本身并没有卡住。要做的是让用户知道在等什么、等到哪一步、不想等能停下。放进 Worker 防卡死的方案我撤回了。这个结论只在 Chromium 149 上验证过其他浏览器我没测。6.3 点取消后过了274毫秒才返回前面的函数已经留了两个口子。一个是每编完一次就回调的onStep另一个是每次开编之前都会查的signal。接到页面上就是下面几行。functionstartCompress(canvas,{mime,limitBytes,progressEl,cancelBtn}){constcontrollernewAbortController();constmaxEncodes8;// 下限 1 次 [31, 99] 二分最多 7 次cancelBtn.disabledfalse;cancelBtn.onclick()controller.abort();returncompressToLimit(canvas,{mime,limitBytes,signal:controller.signal,onStep:s{progressEl.values.encodes/maxEncodes;progressEl.title第${s.encodes}次 · q${s.pct}·${Math.round(s.bytes/1024)}KB ·${Math.round(s.ms)}ms;},}).finally((){cancelBtn.disabledtrue;});}// 真实照片 3840×2160WebP ≤200KB进度出现第 2 条后 50 ms 点取消第 1 次 · q30 · 189 KB · 293 ms 第 2 次 · q65 · 329 KB · 352 ms AbortError点取消后 274 ms 返回按钮 disabledtrue代码说明。进度按编码次数算、不按时间算分母是 8。最低质量先编 1 次再在 [31, 99] 这 69 个整数里二分最多 7 次。这个上限是定死的进度条不会倒退也不会卡在 99%。有时二分 7 次就结束了进度条这时会从 7/8 直接跳到完成我觉得可以接受。取消的检查放在tryPct的第一行也就是每次编码开始之前。输出里要留意的是那个 274 ms。我在第二次编码开始后 50 ms 点了取消函数却过了 274 ms 才返回。已经交给浏览器的那次编码取消不掉只能等它编完到下一次开编之前才被拦下。我另外测过在回调里立刻取消的情况那时下一次编码还没开始AbortError 2 ms 就返回了。这样算下来取消最慢要等一次编码的时间8.3 MP 的 WebP 三百毫秒左右、24 MP 一秒左右。按钮文案我写的是「正在停止…」而不是「已取消」。这轮取消测试里我也挂了定时器。真实照片连续编了三次 WebP一共 1078 ms主线程最大间隔 18 ms。这和 6.2 节的结论一致。6.4 截图改用WebP画质明显更好等待时间差了十倍最省事的办法是全部改出 JPEG。我认真考虑过最后没这么做。同样 ≤200KB 时 WebP 的画质明显更好1920×1080 的真实照片上 WebP 的 PSNR 比 JPEG 高 1.75 dB截图上高 5.8 dB。商品详情图里大量是带文字的拼图和参数表它们正好是 WebP 占优最多的那一类。我现在按场景分。主图以照片为主、限额又紧出 JPEG 的话一轮二分只要一两百毫秒。详情图出 WebP 就得接受几秒的等待但必须带进度和取消。批量上传时一张一张串行编前一张出结果就开始上传不等整批压完。这个分法是我和产品同事争了一轮才定的。对方更在意详情图的文字清不清楚。七、压到最低还超标的图怎么交给缩尺寸函数和页面都接好以后还剩质量调到下限仍然超标的那类图没有着落。函数返回too-big意思是这张图在当前尺寸下光调质量做不到。真实照片 3840×2160 在 q30 的 JPEG 是 322 425 字节。离 200KB 还差一截再怎么二分都没用。这时只能缩尺寸那是另一套逻辑缩多少、缩几轮、缩完怎么重新找质量。我这个函数在这里只做交接。它带回了最低质量编出来的字节数floorBytes。缩尺寸那一步拿限额除以这个字节数再开方就能估出边长该缩多少。实测里按这个比例估一到两轮就能进限额缩完再调一次函数找质量就行。缩尺寸的具体写法、余量怎么留、为什么同样 200KB 下 WebP 能保住更大的尺寸我另外单独写。还有一件事我没做完。这里先试的最低质量是 q30缩尺寸那一轮实测用的质量下限却是 0.50。两个下限应该合成一个配置项我还没想好用哪个。30 保的是尺寸、50 保的是画质要看业务方更怕图小还是更怕图糊。八、二分压缩换浏览器后还能照搬吗只测了一台机器、一个浏览器。所有数字都来自 Apple M4 / 16 GB 上的 Chromium 149 开源构建。换一台机器编码耗时会变量级关系应该不变但我没验证过。Safari、Firefox 和手机浏览器一个都没测。它们的 JPEG 质量是不是也按整数档处理、WebP 的 q1.0 会不会也切无损我都不知道。上线前这两条我会在目标浏览器上各跑一遍代码一。单调只在四张图上验证过。函数对不对不靠单调快不快才靠。碰到不单调的图最坏也就是返回一个质量差一点的文件不会超限。编码器版本会变。Chromium 升级可能换掉 libwebp 或 libjpeg-turbo 的版本。整数档的粒度、q1.0 的行为和编码速度都可能跟着变。我把代码一做成了自检脚本放进组件的回归用例每次升级浏览器基线时跑一遍。画布本身的风险不在这篇里。超大图画进 canvas 可能失败编码也可能拿回一个 null。这两种情况encodeOnce只做了最基本的拦截。还有 EXIF 方向和色彩配置经过 canvas 重编会丢。这是另一个话题。限额口径要和下游对齐。204 800 这个数是我们和存储侧对过的换一个下游就得重新对一遍。九、测压缩耗时和体积时哪些数不可靠耗时是单次运行。代码三和代码四输出里的毫秒数都来自单次运行同一天我跑了两次字节数两次完全相同耗时差几十毫秒。第六章引用的编码中位数来自曲线实测每张样本 36 次比单次可靠。第一次编码更慢。页面里第一次编码会顿一下60 到 180 ms。拿它去估整轮二分会估高。我在压测脚本里先空编一次再开始计时。填充率看目标定在哪。截图压到 200KB 出 JPEG 时逐档下调的填充率是 97.2%看起来不差同一个方法换成真实照片压到 2MB就只有 64.4%。它好不好全看限额落在曲线哪一段只拿一个用例评价的话结论可以偏向任何一边。我一开始就差点只拿截图样本下了「旧组件够用」的结论。表格里的 KB 是四舍五入过的。第二章的曲线表用 KB、第五章的输出用字节。两边对照时我踩过一次。315 KB 乘 1024 是 322 560二分日志里 q0.30 的实际值却是 322 425。差的就是那次四舍五入。判断超不超限永远用原始字节数。合成样本不是照片。12 MP 和 24 MP 的类照片是程序生成的噪声纹理它们比真实照片更难压同一质量下体积更大。拿它们去估真实商品图的体积会偏高。它们在这里只用来测大尺寸下的耗时。手上如果也有一个逐档下调的压缩函数可以先拿自己的图跑一遍代码一看看质量参数的粒度。再把旧函数和二分各跑一次比比填充率。限额口径记得先和后端对一遍。参考资料HTML Living StandardHTMLCanvasElement.toBlob()与OffscreenCanvas.convertToBlob()的定义WHATWG DOM StandardAbortController与AbortSignal.throwIfAborted()libjpeg-turbo 与 libwebp 的质量参数说明Chromium 149 内置版本本文实测数据2026-09-28 与 2026-09-29 两轮本机运行记录环境见「版本声明」
上一篇/下一篇内容由系统自动关联
返回资讯列表 →