前端图片压缩这点事:Canvas、OffscreenCanvas 和一次次踩坑换来的取舍
上传图片这个需求,做过 C 端的前端多少都碰过。相机随手一拍现在动不动就是 4000 万像素、单张十几兆,用户点上传就把原图怼过来,服务端带宽、存储、后续处理全跟着遭殃。最省事的做法当然是甩给后端压,但很多场景里,在浏览器里先把图片瘦一圈再传,体验和成本都划算得多。
我在几个项目里反复折腾过这块,这篇就把手里的经验摊开讲讲——不谈玄学,只说哪些能用、哪些坑我自己栽过。
最朴素的那一版:canvas.toBlob
前端压缩的底层逻辑其实很简单:把图片画到一个 canvas 上,画的时候顺便缩小尺寸,再用toBlob按指定质量导出成 JPEG 或 WebP。就这么点事。
asyncfunctioncompress(file,{maxSize=1600,quality=0.8}={}){constbitmap=awaitcreateImageBitmap(file);let{width,height}=bitmap;// 长边限制在 maxSize 以内,按比例缩if(Math.max(width,height)>maxSize){constratio=maxSize/Math.max(width,height);width=Math.round(width*ratio);height=Math.round(height*ratio);}constcanvas=document.createElement('canvas');canvas.width=width;canvas.height=height;constctx=canvas.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 里没有 DOM,document.createElement('canvas')直接报错——这就是OffscreenCanvas上场的时候了。它是一个脱离 DOM 的 canvas,能在 Worker 里创建和绘制,导出用convertToBlob(注意不是toBlob,API 名字不一样,我头回写的时候在这卡了十分钟没反应过来,控制台还不报错,就一直返回 null)。
Worker 端worker.js:
self.onmessage=async(e)=>{const{id,file,maxSize,quality}=e.data;try{constbitmap=awaitcreateImageBitmap(file);let{width,height}=bitmap;if(Math.max(width,height)>maxSize){constratio=maxSize/Math.max(width,height);width=Math.round(width*ratio);height=Math.round(height*ratio);}constcanvas=newOffscreenCanvas(width,height);constctx=canvas.getContext('2d');ctx.drawImage(bitmap,0,0,width,height);bitmap.close();constblob=awaitcanvas.convertToBlob({type:'image/webp',quality,});self.postMessage({id,blob});}catch(err){self.postMessage({id,error:err.message});}};主线程这边包一层,用 id 把请求和结果对上:
constworker=newWorker(newURL('./worker.js',import.meta.url),{type:'module',});constpending=newMap();worker.onmessage=(e)=>{const{id,blob,error}=e.data;const{resolve,reject}=pending.get(id);pending.delete(id);error?reject(newError(error)):resolve(blob);};letseq=0;functioncompressInWorker(file,opts={}){constid=++seq;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同样能直接克隆过来。真正需要用 Transferable(postMessage第二个参数那个转移列表)的是ArrayBuffer、ImageBitmap这类,普通 Blob/File 走克隆就够了,不用过度设计。
还有个坑是OffscreenCanvas和createImageBitmap在 Worker 里的支持度。桌面浏览器早就齐了,但个别老旧的移动端 WebView 里OffscreenCanvas可能缺席。我的做法是启动时探一下typeof OffscreenCanvas !== 'undefined',不支持就回退到主线程那版——功能不能因为跑不进 Worker 就整个挂掉。
大图别一步缩到位
还有个反直觉的坑,跟画质有关。把一张超大图直接drawImage缩到目标尺寸,缩放比例太大时(比如 5000px 缩到 800px),浏览器默认的双线性采样会丢掉大量像素信息,结果就是缩图发糊、有锯齿、细节像被水冲过。
对付这个有两条路。一是打开高质量插值:
ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';这行成本几乎为零,先加上。但缩放跨度实在太大时,单靠它还是不够。这时候用"分步降采样"——每次最多缩一半,多缩几轮逼近目标尺寸。每一步都是相邻像素的平均,信息保留得多,最后出来的图明显更清爽:
functionstepDownscale(source,targetW,targetH){letcur=source;letw=source.width;leth=source.height;while(w>targetW*2||h>targetH*2){constnw=Math.max(targetW,Math.round(w/2));constnh=Math.max(targetH,Math.round(h/2));constc=newOffscreenCanvas(nw,nh);constcx=c.getContext('2d');cx.imageSmoothingEnabled=true;cx.imageSmoothingQuality='high';cx.drawImage(cur,0,0,nw,nh);cur=c;w=nw;h=nh;}constout=newOffscreenCanvas(targetW,targetH);constoctx=out.getContext('2d');octx.imageSmoothingEnabled=true;octx.imageSmoothingQuality='high';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 的长边、两三张的并发——都是我在自己那几个业务里量出来的经验值,不是什么普适真理。你的图源、你的用户设备分布、你对画质的容忍度都不一样,最靠谱的还是拿真实的图、在真实的目标机型上跑一轮,量体积、看画质、盯内存曲线,再定你自己的参数。别照抄,包括别照抄我。