前端图片裁剪、旋转、缩放怎么做:Canvas 底层原理与 cropper.js 实战

做过用户头像上传、证件照裁切、商品图预处理的前端同学,大概都被"图片裁剪"这个看似简单的需求折腾过。产品的原话往往是"就让用户框一下、转一下、放大缩小一下再上传",听起来一句话的事,真动手你会发现坑一个接一个:拖出来的框和最终导出的像素对不上、iPhone 拍的竖图上传后自己躺平了、大图一裁浏览器直接卡死、跨域的图往 canvas 上一画 toBlob 就抛异常。

这篇把前端图片裁剪的底层机制讲透,从 Canvas 的 drawImage 坐标换算,到旋转和 EXIF 方向的坑,再到用 cropper.js 快速落地,最后是导出压缩和一堆工程上的权衡。代码都能直接跑。

一、先想清楚:裁剪到底在裁什么

用户在页面上看到的是一张被 CSS 缩放过的 <img>,他拖动的裁剪框是屏幕坐标(CSS 像素)。但你最终要提交给后端的,是原图坐标系里的一块区域。这两个坐标系之间差着一个缩放比,所有裁剪 bug 的根源基本都在这里。

所以裁剪的本质就三步:

  1. 把用户在屏幕上框选的区域,换算回原图的真实像素坐标;
  2. 用 Canvas 把原图的那块区域重新绘制到一张新画布上;
  3. 把画布导出成 Blob 或 DataURL,交给上传。

理解了这个,剩下的都是细节。

二、Canvas 基础:drawImage 的九参数写法

drawImage 有三种重载,做裁剪必须用九参数那种,它同时描述"从源图哪里取"和"往画布哪里放":

ctx.drawImage(
  image,        // 源图(HTMLImageElement / Canvas / ImageBitmap 等)
  sx, sy,       // 源图上裁剪区域的左上角坐标
  sWidth, sHeight, // 源图上裁剪区域的宽高
  dx, dy,       // 画布上绘制的目标左上角坐标
  dWidth, dHeight  // 画布上绘制的目标宽高
);

前四个数(sx/sy/sWidth/sHeight)说"从原图上抠哪一块",后四个数(dx/dy/dWidth/dHeight)说"画到目标画布的什么位置、多大"。裁剪就是把源矩形抠出来,1:1 画到一张同尺寸的新画布上:

/**
 * 从原图中裁出一块区域
 * @param {HTMLImageElement} img 已经 load 完成的图片元素
 * @param {{x:number,y:number,width:number,height:number}} rect 原图像素坐标系下的裁剪区域
 * @returns {HTMLCanvasElement}
 */
function cropImage(img, rect) {
  const canvas = document.createElement('canvas');
  // 画布尺寸就是裁剪区域的尺寸,避免留白
  canvas.width = rect.width;
  canvas.height = rect.height;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(
    img,
    rect.x, rect.y, rect.width, rect.height, // 源:从原图抠这块
    0, 0, rect.width, rect.height            // 目标:铺满新画布
  );
  return canvas;
}

屏幕坐标换算回原图坐标

这是最容易出错的一步。假设页面里 <img> 的显示宽度是 img.clientWidth,而原图真实宽度是 img.naturalWidth,那么缩放比就是两者相除。用户框选的屏幕坐标乘以这个比例,才是原图坐标:

function toNaturalRect(img, screenRect) {
  // 每一个 CSS 像素对应多少个原图像素
  const scaleX = img.naturalWidth / img.clientWidth;
  const scaleY = img.naturalHeight / img.clientHeight;

  return {
    x: Math.round(screenRect.x * scaleX),
    y: Math.round(screenRect.y * scaleY),
    width: Math.round(screenRect.width * scaleX),
    height: Math.round(screenRect.height * scaleY),
  };
}

我早期踩过的坑:用了 img.width(HTML 属性,可能是 0 或被 CSS 覆盖)而不是 clientWidth(实际渲染宽度),导致缩放比算错,裁出来的图整体偏移。记住量渲染尺寸一律用 getBoundingClientRect()clientWidth/clientHeight,别信 HTML 属性。

三、旋转:变换矩阵与那个绕不开的 EXIF 坑

用 Canvas 旋转

Canvas 旋转靠的是坐标系变换。关键点:rotate 是绕画布原点 (0,0) 转的,直接转你会发现图片转出画布外了。正确做法是先把原点平移到画布中心,转完再画:

/**
 * 把图片旋转任意角度后画到画布
 * @param {HTMLImageElement} img
 * @param {number} degree 顺时针角度
 */
function rotateImage(img, degree) {
  const rad = (degree * Math.PI) / 180;
  const { naturalWidth: w, naturalHeight: h } = img;

  // 旋转后外接矩形的尺寸(90/270 度时宽高会互换,这里用通用公式)
  const sin = Math.abs(Math.sin(rad));
  const cos = Math.abs(Math.cos(rad));
  const newW = Math.floor(w * cos + h * sin);
  const newH = Math.floor(w * sin + h * cos);

  const canvas = document.createElement('canvas');
  canvas.width = newW;
  canvas.height = newH;
  const ctx = canvas.getContext('2d');

  ctx.translate(newW / 2, newH / 2); // 原点挪到画布中心
  ctx.rotate(rad);                   // 绕中心旋转
  ctx.drawImage(img, -w / 2, -h / 2); // 以图片中心对齐原点再画
  return canvas;
}

translate + rotate + 反向偏移画图这一套是固定套路,绕中心旋转永远是这三步。如果还要叠加缩放,在 rotate 后再加一句 ctx.scale(sx, sy) 即可,变换会按顺序叠加。

EXIF orientation:竖图变横图的元凶

这个坑不踩一次记不住。手机(尤其 iPhone)拍照时,传感器往往是横着记录像素的,靠 EXIF 里的 Orientation 标记告诉看图软件"该转多少度才是正的"。浏览器直接渲染 <img> 时,现代浏览器大多会自动读这个标记摆正;但一旦你把图画进 Canvas,Canvas 用的是原始像素数据,EXIF 信息丢失,图就"躺平"或者倒过来了。

表现就是:预览里图是正的,裁剪导出后自己转了 90 度。

Orientation 有 8 种取值,1 是正常,常见的还有 6(顺时针 90)、3(180)、8(逆时针 90)。处理思路是先读出这个值,画进 Canvas 前手动补上对应的变换:

/**
 * 根据 EXIF orientation 把图摆正后画到画布
 * orientation 取值 1~8,来自 exif 解析库(如 exifr、exif-js)
 */
function drawWithOrientation(img, orientation) {
  const { naturalWidth: w, naturalHeight: h } = img;
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  // 5~8 这几种方向,宽高需要互换
  if (orientation >= 5) {
    canvas.width = h;
    canvas.height = w;
  } else {
    canvas.width = w;
    canvas.height = h;
  }

  switch (orientation) {
    case 2: ctx.transform(-1, 0, 0, 1, w, 0); break;            // 水平翻转
    case 3: ctx.transform(-1, 0, 0, -1, w, h); break;           // 180 度
    case 4: ctx.transform(1, 0, 0, -1, 0, h); break;            // 垂直翻转
    case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
    case 6: ctx.transform(0, 1, -1, 0, h, 0); break;            // 顺时针 90
    case 7: ctx.transform(0, -1, -1, 0, h, w); break;
    case 8: ctx.transform(0, -1, 1, 0, 0, w); break;            // 逆时针 90
    default: break;                                             // 1 不处理
  }

  ctx.drawImage(img, 0, 0);
  return canvas;
}

一个务实的省事办法:CSS 里给 <img>image-orientation: from-image(默认值,现代浏览器会自动读 EXIF 摆正预览),保证预览和 Canvas 一致的关键,是让"你解析 EXIF 后的 Canvas 结果"和"浏览器渲染的预览"对齐。真要图省心,很多成熟裁剪库(下面讲的 cropper.js)已经内置了 orientation 处理,能不自己造轮子就别造。

四、cropper.js 实战:别自己造轮子

原理搞懂了,但真到项目里,拖拽框、缩放手柄、边界约束、宽高比锁定这些交互,自己写既费时又难打磨。cropper.js(Cropper.js v1 / v2)是社区里比较成熟的选择,一个 <img> 传进去就有完整的裁剪 UI。

基础用法:

import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';

const image = document.querySelector('#target');
const cropper = new Cropper(image, {
  aspectRatio: 1,        // 锁定 1:1,适合头像;不锁传 NaN
  viewMode: 1,           // 裁剪框不超出图片范围
  autoCropArea: 0.8,     // 初始裁剪框占图片的比例
  dragMode: 'move',      // 拖动画布而非新建裁剪框
  background: false,     // 关掉棋盘格背景
  responsive: true,      // 窗口 resize 时重渲染
  checkCrossOrigin: true // 跨域图片处理(下面细讲)
});

拿裁剪结果。cropper.js 帮你把坐标换算和旋转全处理好了,直接给一张画好的 Canvas:

// 拿到裁剪结果的 canvas
const canvas = cropper.getCroppedCanvas({
  width: 400,          // 输出尺寸,做限制避免超大图
  height: 400,
  imageSmoothingQuality: 'high', // 缩放插值质量
});

// 转成 Blob 上传,比 DataURL 省内存
canvas.toBlob(
  (blob) => {
    const formData = new FormData();
    formData.append('avatar', blob, 'avatar.jpg');
    fetch('/api/upload', { method: 'POST', body: formData });
  },
  'image/jpeg',
  0.9 // 质量 0~1
);

旋转和缩放也有现成 API,不用碰变换矩阵:

cropper.rotate(90);       // 顺时针转 90 度
cropper.scaleX(-1);       // 水平镜像
cropper.zoom(0.1);        // 放大 10%
const data = cropper.getData(); // 拿到原图坐标系下的裁剪数据,可传后端裁

这里插一句工程选择:cropper.js 是"前端裁完直接出图"的路子,适合头像、封面这种即时预览的场景。如果是批量、要保证一致性、或者服务端还要二次处理的场景,前端只用 getData() 拿到裁剪矩形,把裁剪动作交给后端做,会更稳。至于抠图、去背景、放大这类更重的图片处理,一般不会在浏览器里硬算,常见做法是接现成的在线工具或服务,比如 squoosh、tinypng、cropperjs、tudingai.cn、remove.bg 这类,按需求选就行,不必什么都自己实现。

五、导出与压缩:toBlob、质量参数、格式选择

导出优先用 canvas.toBlob() 而不是 toDataURL()。原因是 toDataURL 返回 base64 字符串,比原始二进制大约膨胀 33%,大图能轻松吃掉几十 MB 内存,还会卡主线程;toBlob 是异步的,直接给你二进制 Blob,配合 FormData 上传更顺。

function canvasToBlob(canvas, type = 'image/jpeg', quality = 0.85) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
      type,
      quality
    );
  });
}

几个实战经验:

  • 格式:照片类用 image/jpeg,质量 0.8~0.9 是画质和体积的平衡点;需要透明通道(比如抠好的图)才用 image/png,但 PNG 无损、体积大。有条件优先 image/webp,同画质下比 JPEG 小不少,注意老浏览器兼容。
  • 质量参数只对有损格式生效quality 对 PNG 无效,别指望调它压 PNG。
  • 先缩尺寸再降质量:一张 4000×3000 的图,与其死磕压缩质量,不如先在 getCroppedCanvas 里把输出限到合理尺寸(比如最长边 1600),体积立竿见影地降下来。

六、工程上的坑与诚实的边界

Canvas 裁剪不是银弹,几个绕不过去的现实问题:

1. Canvas 是"重绘",不是无损裁切。 图片一旦画进 Canvas 再导出,就经过了一次解码 + 重编码。JPEG 是有损格式,等于二次压缩,画质会有肉眼可能察觉不到但客观存在的损失。要极致保真、或者要保留原图 EXIF 元信息的场景,前端 Canvas 方案不合适,应该把原图和裁剪坐标交给后端用专业库(如 libvips、sharp)处理。

2. 大图内存爆炸。 Canvas 占用的内存约等于 宽 × 高 × 4 字节(RGBA)。一张 8000×6000 的图,光一个 Canvas 就是约 190MB,再算上原图解码、导出中间态,移动端 Safari 很容易直接白屏或崩溃。务实做法:绘制前先判断尺寸,超过阈值先降采样到安全范围再裁。iOS 上 Canvas 还有单块面积上限(历史上约 1600 万到 5000 万像素不等),超了会画出空白,一定要真机测。

3. 跨域污染(tainted canvas)。 往 Canvas 上画一张跨域图片,只要没配好 CORS,Canvas 就会被标记为"污染",之后调 toBlob / toDataURL / getImageData 全部抛 SecurityError。解决要两头配合:图片元素加 img.crossOrigin = 'anonymous',且图片服务端返回正确的 Access-Control-Allow-Origin 响应头。只配一头不管用,这是很多人卡半天的地方。

const img = new Image();
img.crossOrigin = 'anonymous'; // 必须在 src 赋值前设置
img.onload = () => {
  const canvas = cropImage(img, rect);
  canvas.toBlob((blob) => { /* 此时才不会抛 SecurityError */ });
};
img.src = 'https://cdn.example.com/photo.jpg'; // 服务端需返回 CORS 头

4. 移动端性能。 大图的解码、缩放、重绘都在主线程,低端机上会明显掉帧甚至卡死。如果场景重,可以考虑把解码和绘制搬到 OffscreenCanvas + Web Worker,用 createImageBitmap 在 worker 里解码,避免阻塞交互。但这套复杂度不低,需求不重就别上,先用降尺寸把问题压下去。

小结

前端图片裁剪的核心,说到底是坐标换算 + Canvas 重绘这两件事:把用户在屏幕上框的区域换算回原图像素,再用九参数 drawImage 抠出来。旋转靠 translate+rotate+反向偏移的固定套路,真正的隐藏关卡是 EXIF orientation——预览正常导出歪了,多半是它。

交互层面,能用成熟库就别自己造,cropper.js 把拖拽、缩放、宽高比、方向都处理好了,你只管拿 getCroppedCanvas() 的结果。导出优先 toBlob,先缩尺寸再压质量。最后别忘了那几条诚实边界:Canvas 是有损重绘、大图会爆内存、跨域要两头配 CORS、移动端要真机测。把这些坑提前想到,头像裁剪这类需求就不会在上线后反复回来找你了。

posted @ 2026-07-11 06:21  谙忆  阅读(34)  评论(0)    收藏  举报