前端知库

前端怎样实现图片裁剪?drawImage 的九个参数怎么用?

用户在缩小显示的预览图上拖出裁剪框,先按 naturalWidth 和显示宽度的比例把裁剪框换算成原图像素坐标,再用 drawImage 的九参数形式把原图的这块区域画到目标尺寸的 canvas 上,最后用 toBlob 导出。预览用的 canvas 要乘 devicePixelRatio 才清晰。现代浏览器默认按 EXIF 方向摆正图片,裁剪坐标应按摆正后的尺寸计算。

参考答案

简答

比如上传头像时,用户选了一张 4000×3000 的照片,页面上缩小显示成 400 像素宽,用户在上面拖出一个正方形框,确认后得到 200×200 的头像。前端裁剪的核心就是 Canvas 的 drawImage 九参数形式:前四个数是原图里要截取的矩形,后四个数是画到画布上的位置和大小。

最容易错的是坐标。裁剪框是在缩小后的预览图上拖的,单位是页面上的 CSS 像素,而 drawImage 的源矩形要用原图的像素。所以要先算比例,原图的 naturalWidth 除以预览图的显示宽度,再把裁剪框的位置和大小都乘上这个比例。

画布本身也有两种尺寸。CSS 宽高决定在页面上显示多大,width、height 属性决定实际有多少像素。在设备像素比为 2 或 3 的手机上,预览画布要把像素尺寸乘以 devicePixelRatio,否则看起来发糊;导出用的画布则直接按目标输出尺寸设置。

还要注意照片方向。手机照片可能带 EXIF 方向标记,现代浏览器默认按它把图片摆正,<img> 的 naturalWidth 也是摆正后的尺寸,所以裁剪坐标按摆正后的图来算就行。画完用 toBlob 导出成 JPEG 或 PNG 上传。

drawImage 的九个参数是什么意思

drawImage 有三种写法,MDN 列出的签名是:

JavaScript
drawImage(image, dx, dy);
drawImage(image, dx, dy, dWidth, dHeight);
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

带 s 的是源(source),指原图里要截取的那块矩形:sx、sy 是左上角坐标,sWidth、sHeight 是宽高。带 d 的是目标(destination),指画到画布的哪里、画多大。源矩形和目标矩形大小不一样时,浏览器会自动缩放。

所以裁剪就是选对源矩形,再把目标矩形设成整张输出画布。比如从原图 (1000, 500) 开始截一块 1500×1500 的区域,输出 200×200 的头像:

JavaScript
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 1000, 500, 1500, 1500, 0, 0, 200, 200);

如果裁剪框拖到了图片外面怎么办?HTML 规范规定,源矩形超出原图时,会被裁到原图范围内,目标矩形按同样的比例缩小,超出的部分什么都不画,也不会报错。所以画布上会留出透明的空白。最好在交互上就限制裁剪框不出界。

裁剪框的坐标怎样换算

用户看到的是缩小后的预览图,裁剪框的位置是相对预览图左上角的 CSS 像素。换算只需要一个比例:原图宽度除以显示宽度。下面的函数把预览上的裁剪框换算成源矩形,并把它限制在原图范围内:

JavaScript
function toSourceRect(box, displayWidth, naturalWidth, naturalHeight) {
  const scale = naturalWidth / displayWidth;
  const sx = Math.round(box.x * scale);
  const sy = Math.round(box.y * scale);
  const sw = Math.round(box.width * scale);
  const sh = Math.round(box.height * scale);
  const x = Math.min(Math.max(sx, 0), naturalWidth);
  const y = Math.min(Math.max(sy, 0), naturalHeight);
  return {
    sx: x,
    sy: y,
    sw: Math.min(sw, naturalWidth - x),
    sh: Math.min(sh, naturalHeight - y),
  };
}

const rect = toSourceRect({ x: 100, y: 50, width: 150, height: 150 }, 400, 4000, 3000);
console.log(`${rect.sx},${rect.sy},${rect.sw},${rect.sh}`); // 1000,500,1500,1500

const edge = toSourceRect({ x: 300, y: 250, width: 150, height: 150 }, 400, 4000, 3000);
console.log(`${edge.sx},${edge.sy},${edge.sw},${edge.sh}`); // 3000,2500,1000,500

const dpr = 3;
console.log(400 * dpr); // 1200

第一行就是上面 drawImage 例子用的数字。原图宽 4000、显示宽 400,比例是 10,预览上 (100, 50) 起 150×150 的框,对应原图 (1000, 500) 起 1500×1500 的区域。

第二行的框靠近右下角,乘上比例以后会超出原图:横向从 3000 到 4500,纵向从 2500 到 4000,原图只有 4000×3000。函数把宽度截到 1000、高度截到 500,结果就不是正方形了。实际产品里通常是在用户拖动时就不让框超出图片,这样宽高比不会被破坏。

最后一行是预览画布的像素宽度,下一节讲。

预览为什么会糊

如果预览用 canvas 画(比如要显示裁剪框外的半透明遮罩),会遇到画面发糊的问题。canvas 的 CSS 宽高只决定在页面上占多大,width、height 属性才决定它有多少个像素。在设备像素比为 3 的手机上,400 个 CSS 像素宽的区域实际有 1200 个物理像素,画布只有 400 像素宽,就会被拉伸显示。

MDN 的做法是:CSS 尺寸不变,画布的像素尺寸乘以 devicePixelRatio,再用 ctx.scale 把坐标系换回 CSS 像素,这样后面的绘制代码仍然按 400 宽来写:

JavaScript
const dpr = window.devicePixelRatio;
preview.style.width = '400px';
preview.style.height = '300px';
preview.width = Math.floor(400 * dpr);
preview.height = Math.floor(300 * dpr);
previewCtx.scale(dpr, dpr);

导出用的画布不需要这样处理。它不显示在页面上,像素尺寸直接设成想要的输出大小,比如头像的 200×200。

照片方向会不会让裁剪错位

手机竖着拍的照片,像素数据可能是横着存的,再用 EXIF 里的方向标记告诉软件显示时要转多少度。MDN 说明,CSS image-orientation 的初始值是 from-image,按图片里的 EXIF 信息把图片转正。《图片上传前怎样在浏览器里压缩?》里用 headless Chrome 实测过,带“旋转 90 度”标记的图片,<img> 读到的 naturalWidth 和 naturalHeight 已经是转正后的尺寸,createImageBitmap 默认也会转正。

所以在现代浏览器里,用户看到的预览、naturalWidth 和 drawImage 画出的内容都是同一个方向,裁剪坐标按看到的图来算就不会错位。MDN 的 drawImage 页面提到,一些旧版浏览器的 drawImage 会忽略 EXIF 方向,这在 iOS 上尤其明显,那时需要自己读方向再用 rotate() 转正。现在再手动旋转,反而可能转两次,要兼容旧浏览器时应该先实测。

怎样导出裁剪结果

画好以后用 toBlob 导出。它通过回调给出 Blob,不传类型默认是 PNG;JPEG、WebP 可以传第三个参数指定 0 到 1 的质量:

JavaScript
canvas.toBlob(
  (blob) => {
    if (!blob) return; // 图片因为任何原因生成不了时会得到 null
    const form = new FormData();
    form.append('avatar', blob, 'avatar.jpg');
    fetch('/api/avatar', { method: 'POST', body: form });
  },
  'image/jpeg',
  0.9,
);

如果原图来自其他源又没有经过 CORS 允许,画进画布以后 toBlob 会抛 SecurityError。用户从本地选择的文件不存在这个问题;裁剪网络图片时,要给图片设置 crossOrigin,并让图片服务器返回 CORS 头,详见《前端怎样生成截图或分享海报?html2canvas 有哪些限制?》。

常见追问

裁剪框想固定 1:1 或者 16:9,怎么处理?

在拖动裁剪框时就锁定宽高比,比如改变宽度时按比例算出高度,并且限制框不超出预览图。这样换算到原图以后,源矩形的比例和目标画布一致,drawImage 缩放时不会拉伸变形。

为什么不直接用 CSS 裁剪,比如 object-fit 或 overflow: hidden?

CSS 只改变显示效果,上传的仍然是原图。要把裁剪结果真正发给服务器,就得把像素画到 canvas 上再导出成新文件。CSS 适合做预览,canvas 负责生成结果。

用户选的图片特别大,裁剪时页面卡顿怎么办?

预览不必用原图大小渲染,可以先生成一张缩小的预览图,只在最后导出时才用原图按源矩形裁一次。也可以把解码和绘制放到 Worker 里,用 OffscreenCanvas 完成,见《OffscreenCanvas 什么时候能减少主线程绘制压力?》。