前端图片压缩这点事:Canvas、OffscreenCanvas 和一次次踩坑换来的取舍
上传图片这个需求做过 C 端的前端多少都碰过。相机随手一拍现在动不动就是 4000 万像素、单张十几兆用户点上传就把原图怼过来服务端带宽、存储、后续处理全跟着遭殃。最省事的做法当然是甩给后端压但很多场景里在浏览器里先把图片瘦一圈再传体验和成本都划算得多。我在几个项目里反复折腾过这块这篇就把手里的经验摊开讲讲——不谈玄学只说哪些能用、哪些坑我自己栽过。最朴素的那一版canvas.toBlob前端压缩的底层逻辑其实很简单把图片画到一个 canvas 上画的时候顺便缩小尺寸再用toBlob按指定质量导出成 JPEG 或 WebP。就这么点事。asyncfunctioncompress(file,{maxSize1600,quality0.8}{}){constbitmapawaitcreateImageBitmap(file);let{width,height}bitmap;// 长边限制在 maxSize 以内按比例缩if(Math.max(width,height)maxSize){constratiomaxSize/Math.max(width,height);widthMath.round(width*ratio);heightMath.round(height*ratio);}constcanvasdocument.createElement(canvas);canvas.widthwidth;canvas.heightheight;constctxcanvas.getContext(2d);ctx.drawImage(bitmap,0,0,width,height);bitmap.close();// 早点释放别等 GCreturnnewPromise((resolve){canvas.toBlob(resolve,image/jpeg,quality);});}这段代码能解决大部分场景。几个细节值得说一下。一是我用createImageBitmap而不是老掉牙的new Image()onload。createImageBitmap直接吃File/Blob解码在浏览器内部干不用先转 DataURL 再塞进 img 的 src省一次内存拷贝而且它返回的ImageBitmap能直接drawImage配合后面要讲的 Worker 特别顺手。用完记得close()这个对象占的是解码后的位图内存一张 4000 万像素的图解码出来是一亿多字节攒几张就能把移动端浏览器顶爆。二是toBlob而不是toDataURL。DataURL 是 base64 字符串比二进制大约膨胀 33%还全塞在 JS 堆里大图直接卡死。toBlob给的是 Blob可以直接扔进FormData上传中间不经过字符串。这两个 API 我现在是闭眼选toBlob。质量参数到底怎么定toBlob第三个参数是 0 到 1 的质量系数只对 JPEG 和 WebP 有效PNG 是无损的这个参数它压根不看。这个数怎么定是最容易想当然的地方。我一开始也拍脑袋写 0.9觉得高质量总没错。后来实测发现0.9 到 0.8 之间肉眼几乎分不出差别体积却能差三成。真正的甜区在 0.75 到 0.82 之间——再往下到 0.7 以下天空、渐变这类平滑区域就开始出现明显的块状噪点了尤其是深色背景上的浅色渐变块效应特别扎眼。所以我现在的习惯是照片类默认 0.8图表截图这种带文字和硬边缘的反而要调高到 0.85 以上因为 JPEG 的 DCT 对高频信息文字边缘特别不友好压狠了字就糊成一团。带文字的截图我甚至会优先考虑 PNG 或者干脆 WebP。WebP 值得单独提一句。同质量下 WebP 比 JPEG 小 25% 到 35% 是常态现在主流浏览器早就全支持了。要不要上 WebP 我一般看两点一是服务端和 CDN 认不认这个格式、后续处理链路会不会因为格式变了出岔子二是要不要兼容一些老旧的图片查看环境。没有历史包袱的新项目我基本默认输出 WebP。有个进阶玩法是目标体积反推质量先按某个质量压一版量一下体积超了就降质量再压一次。听着挺美但每压一次都是一遍完整的编码二分个三四轮下来在手机上能卡出肉眼可见的顿。我的经验是别搞这种精细控制固定质量 尺寸上限压出来的体积在业务里波动其实不大没必要为那点体积去赌用户的耐心。顺带说个副作用走 canvas 重新绘制再导出图片的 EXIF 信息拍摄参数、GPS、方向标记等会被整个丢掉。丢隐私信息这点通常是好事但 EXIF 里的 Orientation 方向标记一并没了就麻烦——有些手机竖拍的照片像素本身是横躺的靠 EXIF 的方向位来告诉查看器转 90 度。canvas 一洗方向信息没了图就躺下了。好在现代浏览器的createImageBitmap支持{ imageOrientation: from-image }选项解码时就按 EXIF 把像素摆正我一般会显式带上这个参数省得后面再手动纠方向。主线程会卡这事绕不过去上面那版代码在 Demo 里跑得飞快一旦用户一次选十几张大图问题就来了drawImage和toBlob的编码全跑在主线程上压图的这零点几秒里页面是彻底冻住的滚动、点击全无响应。批量处理时这种卡顿会叠加用户以为你的页面死了。解法是把整个压缩过程挪进 Web Worker。但普通 Worker 里没有 DOMdocument.createElement(canvas)直接报错——这就是OffscreenCanvas上场的时候了。它是一个脱离 DOM 的 canvas能在 Worker 里创建和绘制导出用convertToBlob注意不是toBlobAPI 名字不一样我头回写的时候在这卡了十分钟没反应过来控制台还不报错就一直返回 null。Worker 端worker.jsself.onmessageasync(e){const{id,file,maxSize,quality}e.data;try{constbitmapawaitcreateImageBitmap(file);let{width,height}bitmap;if(Math.max(width,height)maxSize){constratiomaxSize/Math.max(width,height);widthMath.round(width*ratio);heightMath.round(height*ratio);}constcanvasnewOffscreenCanvas(width,height);constctxcanvas.getContext(2d);ctx.drawImage(bitmap,0,0,width,height);bitmap.close();constblobawaitcanvas.convertToBlob({type:image/webp,quality,});self.postMessage({id,blob});}catch(err){self.postMessage({id,error:err.message});}};主线程这边包一层用 id 把请求和结果对上constworkernewWorker(newURL(./worker.js,import.meta.url),{type:module,});constpendingnewMap();worker.onmessage(e){const{id,blob,error}e.data;const{resolve,reject}pending.get(id);pending.delete(id);error?reject(newError(error)):resolve(blob);};letseq0;functioncompressInWorker(file,opts{}){constidseq;returnnewPromise((resolve,reject){pending.set(id,{resolve,reject});worker.postMessage({id,file,maxSize:opts.maxSize??1600,quality:opts.quality??0.8,});});}这里有个容易忽略的点File对象往 Worker 传的时候是可以被结构化克隆的不需要你手动读成 ArrayBuffer 再传。而回传的Blob同样能直接克隆过来。真正需要用 TransferablepostMessage第二个参数那个转移列表的是ArrayBuffer、ImageBitmap这类普通 Blob/File 走克隆就够了不用过度设计。还有个坑是OffscreenCanvas和createImageBitmap在 Worker 里的支持度。桌面浏览器早就齐了但个别老旧的移动端 WebView 里OffscreenCanvas可能缺席。我的做法是启动时探一下typeof OffscreenCanvas ! undefined不支持就回退到主线程那版——功能不能因为跑不进 Worker 就整个挂掉。大图别一步缩到位还有个反直觉的坑跟画质有关。把一张超大图直接drawImage缩到目标尺寸缩放比例太大时比如 5000px 缩到 800px浏览器默认的双线性采样会丢掉大量像素信息结果就是缩图发糊、有锯齿、细节像被水冲过。对付这个有两条路。一是打开高质量插值ctx.imageSmoothingEnabledtrue;ctx.imageSmoothingQualityhigh;这行成本几乎为零先加上。但缩放跨度实在太大时单靠它还是不够。这时候用分步降采样——每次最多缩一半多缩几轮逼近目标尺寸。每一步都是相邻像素的平均信息保留得多最后出来的图明显更清爽functionstepDownscale(source,targetW,targetH){letcursource;letwsource.width;lethsource.height;while(wtargetW*2||htargetH*2){constnwMath.max(targetW,Math.round(w/2));constnhMath.max(targetH,Math.round(h/2));constcnewOffscreenCanvas(nw,nh);constcxc.getContext(2d);cx.imageSmoothingEnabledtrue;cx.imageSmoothingQualityhigh;cx.drawImage(cur,0,0,nw,nh);curc;wnw;hnh;}constoutnewOffscreenCanvas(targetW,targetH);constoctxout.getContext(2d);octx.imageSmoothingEnabledtrue;octx.imageSmoothingQualityhigh;octx.drawImage(cur,0,0,targetW,targetH);returnout;}多几次绘制会多花一点点时间但既然整个流程已经在 Worker 里了主线程压根感知不到。视觉收益远大于这点开销缩放跨度大的场景我都会挂上分步。内存这件事移动端会真崩调这块的时候最扎心的教训全在内存上而且几乎只在移动端复现——桌面 Chrome 内存宽裕你本地测一百遍都没事一到用户手机上就白屏、就 tab 崩溃。几个实打实的点一张图从解码到编码内存里同时存在的可能有原始File、解码后的ImageBitmap按解码后像素算一亿像素就是四亿字节、canvas 的后备缓冲、导出的 Blob。批量处理时如果不控制并发十几张图的这些中间态全堆在内存里移动端很容易触顶。所以批量压缩我一定限制并发通常两三张同时跑用一个简单的信号量卡住而不是Promise.all一次性全放出去。ImageBitmap一定要close()别指望 GC 及时回收——它占的是浏览器底层的图形内存不在 JS 堆的常规回收视野里不手动关很容易泄漏。同理OffscreenCanvas 用完把引用置空分步降采样里那些中间 canvas 更是要让它们尽快脱离引用链。还有个玄乎的硬限制canvas 的最大尺寸各家浏览器不一样部分移动端对超大画布比如面积超过一定阈值会静默返回空白或者直接抛错。所以尺寸上限不光是为了压体积也是在避开这个雷。真要处理超大原图先降到安全尺寸再说。那到底该前端压还是后端压绕回开头那个问题。前端压缩不是万能的我自己的判断标准是这样的。前端压划算的场景上传前就能砍掉大部分体积省用户流量、省上传时间、省服务端带宽用户在弱网下体验提升很直接而且压缩算力用的是用户设备不占你服务器资源量大的时候这笔账很可观。但前端压有它的边界这些我必须诚实说清楚客户端环境是不可控的不同浏览器、不同设备编码出来的结果有细微差异你没法保证像素级一致涉及需要归档、二次高精度处理、或者有取证/合规要求的原图绝不能在前端就把信息压没了——该传原图就传原图压缩只做预览。安全上也不能只信前端前端压缩纯粹是体验和成本优化服务端该做的格式校验、尺寸校验、大小限制一样都不能省不然一个绕过前端直接打接口的请求就能把你的处理链路搞垮。我现在多数项目的做法是两头都做前端压出一个体积可控的版本改善上传体验服务端再按统一标准做一遍规范化转格式、生成多尺寸、剥离 EXIF 等等两边各司其职。前端那一版解决的是传得快后端那一版解决的是存得规整、后面用得放心。真要做深度的图像处理像 tudingai.cn 这类在线工具走的也是服务端渲染的路子浏览器这点算力扛不住重活。写到这我得补一句边界上面这些数字——0.8 的质量、1600 的长边、两三张的并发——都是我在自己那几个业务里量出来的经验值不是什么普适真理。你的图源、你的用户设备分布、你对画质的容忍度都不一样最靠谱的还是拿真实的图、在真实的目标机型上跑一轮量体积、看画质、盯内存曲线再定你自己的参数。别照抄包括别照抄我。