前端知库

图片上传前怎样在浏览器里压缩?

把用户选的图片解码成位图,按目标尺寸画到 canvas 上,再用 canvas.toBlob 以 JPEG 或 WebP 和指定质量导出,得到更小的 Blob 上传。createImageBitmap 可以直接从 File 解码并缩放,默认按 EXIF 方向摆正图片;导出的新图不保留原图的 EXIF 等元数据。

参考答案

简答

用户在手机上选了一张 8MB、4000 多像素宽的照片当头像,其实页面上最多显示 400 像素。直接上传既慢又费流量,所以要在浏览器里先压缩成几百 KB 再传。

压缩分三步:先把图片文件解码成位图;再按目标尺寸画到 canvas 上,这一步把像素数降下来;最后用 canvas.toBlob(callback, 'image/jpeg', 0.8) 导出,第三个参数是 0 到 1 之间的质量,得到一个新的 Blob 上传。

解码推荐用 createImageBitmap(file),它直接接受 File,返回 Promise,还能顺便指定缩放后的宽高。它默认按照片里的 EXIF 方向信息把图片摆正,所以竖着拍的照片画出来不会躺倒。

要注意三点:导出格式浏览器不支持时会悄悄退回 PNG,PNG 可能比原图还大,所以导出后要检查类型和大小;压缩后的新图不带原图的 EXIF,拍摄时间、位置都没了,这对隐私是好事,但需要这些信息就要在压缩前读出来;超大图片解码很占内存,最好在 Worker 里处理。

压缩主要靠什么变小

照片文件大,一是像素多,二是编码质量高。降尺寸的效果最明显:宽高各缩到原来的十分之一,像素数就只剩百分之一。再降低 JPEG 或 WebP 的质量参数,文件还能再小一截。

所以通常先定一个最长边,比如 1600 像素,等比缩放,再用 0.7 到 0.85 的质量导出。这些数字要按业务对清晰度的要求来调。

怎样写压缩函数

下面这个函数接收用户选的 File,返回压缩后的 Blob:

JavaScript
async function compressImage(file, { maxSize = 1600, type = 'image/jpeg', quality = 0.8 } = {}) {
  const bitmap = await createImageBitmap(file); // 默认按 EXIF 方向摆正
  const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
  const width = Math.round(bitmap.width * scale);
  const height = Math.round(bitmap.height * scale);

  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  if (type === 'image/jpeg') {
    ctx.fillStyle = '#fff'; // JPEG 没有透明通道,先铺白底
    ctx.fillRect(0, 0, width, height);
  }
  ctx.drawImage(bitmap, 0, 0, width, height);
  bitmap.close(); // 尽早释放解码出来的位图

  const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, quality));
  if (!blob || blob.type !== type || blob.size >= file.size) return file;
  return blob;
}

Math.min(1, …) 保证小图不会被放大。bitmap.close() 是因为 MDN 说明它会释放 ImageBitmap 占用的所有图形资源,一张几千万像素的位图占内存很多,画完就可以丢掉。

为什么 JPEG 要先铺白底?PNG 透明的部分画到 canvas 上是透明像素,导出 JPEG 时没有透明通道。在 headless Chrome 里实测,一块全透明的 canvas 导出 JPEG 后再读像素,得到的是不透明的黑色 0,0,0,255。先填白色就不会出现黑底。

最后一行的判断很重要,下面单独讲。

为什么导出后要检查类型和大小

toBlob 的回调可能拿到 null,MDN 写明无法生成图片时会传 null。另外,MDN 说明不指定格式或者指定的格式不支持时,会导出 image/png;浏览器必须支持 PNG,很多浏览器还支持 JPEG 和 WebP。在 headless Chrome 里实测,toBlob 传一个不存在的 'image/xyz',回调拿到的 Blob 类型是 image/png。

问题在于 PNG 是无损格式,一张照片导出成 PNG 往往比原来的 JPEG 还大。所以要检查返回的 blob.type 是不是请求的格式,大小是不是真的变小了,不满足就上传原文件。

质量参数也有边界:MDN 说明它只对 JPEG、WebP 这类有损格式有效,没传或者超出 0 到 1 的范围,浏览器用自己的默认质量。

照片为什么会躺倒,EXIF 方向是什么

手机竖着拍照时,传感器存下来的像素其实可能是横着的,相机在文件里记一个“方向”标记(EXIF 里的 Orientation),告诉看图软件显示时要转多少度。如果解码时忽略这个标记,竖拍的照片画到 canvas 上就躺倒了。

现在浏览器默认会处理这个标记。MDN 说明 CSS image-orientation 的初始值是 from-image,用 EXIF 信息摆正图片;createImageBitmap 的 imageOrientation 选项默认也是 from-image,按 EXIF 方向摆正。在 headless Chrome 里实测:给一张 40×20 的 JPEG 写入“需要旋转 90 度”的 EXIF 标记,createImageBitmap 得到的位图是 20×40,用 <img> 加载读到的 naturalWidth 和 naturalHeight 也是 20×40,说明两种方式都已经按方向摆正。

所以用这两种方式解码,一般不需要自己再读 EXIF 去旋转。早年的教程里手动解析 EXIF 再旋转 canvas 的代码,放到现在的浏览器里反而会转两次。如果要兼容很旧的浏览器,先实测再决定。

可以让浏览器在解码时直接缩放吗

createImageBitmap 的选项里有 resizeWidth、resizeHeight 和 resizeQuality。MDN 说明 resizeQuality 可以是 pixelated、low(默认)、medium、high。

JavaScript
const bitmap = await createImageBitmap(file, {
  resizeWidth: width,
  resizeHeight: height,
  resizeQuality: 'high',
});

这样拿到的位图已经是目标尺寸,不用再让 canvas 在 drawImage 时缩放,内存占用也小一些。要先知道原图宽高才能算目标尺寸。也可以只设 resizeWidth:在 headless Chrome 里实测,40×20 的源图只传 resizeWidth: 10,得到的位图是 10×5,高度按比例算出。

超大图片怎样避免卡住页面

解码和导出都很耗时。createImageBitmap 返回 Promise,解码不阻塞主线程;但 canvas 绘制和 toBlob 的编码在主线程的 canvas 上做,几千万像素的图片可能卡顿。可以把 File 发给 Worker,在 Worker 里用 createImageBitmap 和 OffscreenCanvas 完成同样的流程,OffscreenCanvas 用 convertToBlob 导出,见《OffscreenCanvas 什么时候能减少主线程绘制压力?》。

常见追问

压缩后的图片还有原来的拍摄信息吗?

没有。canvas 导出的是一张全新编码的图片,只有像素,不带原图的 EXIF。拍摄时间、相机型号、GPS 位置都会丢失。上传头像时这正好保护了隐私;如果业务需要这些信息,要在压缩前用 EXIF 解析库读出来,单独提交。

怎样在上传前预览压缩后的效果?

用 URL.createObjectURL(blob) 生成一个地址给 <img> 显示,不用等上传完成。预览结束或组件卸载时调用 URL.revokeObjectURL 释放,见《File、Blob 和 object URL 怎样用于文件预览?》。

WebP 和 JPEG 选哪个?

同样的画质,WebP 通常更小,但先确认服务端和使用这些图片的下游都支持 WebP。导出时请求 WebP,再检查 blob.type,不支持就退回 JPEG。