前端图片裁剪、旋转、缩放怎么做:Canvas 底层原理与 cropper.js 实战
做过用户头像上传、证件照裁切、商品图预处理的前端同学,大概都被"图片裁剪"这个看似简单的需求折腾过。产品的原话往往是"就让用户框一下、转一下、放大缩小一下再上传",听起来一句话的事,真动手你会发现坑一个接一个:拖出来的框和最终导出的像素对不上、iPhone 拍的竖图上传后自己躺平了、大图一裁浏览器直接卡死、跨域的图往 canvas 上一画 toBlob 就抛异常。
这篇把前端图片裁剪的底层机制讲透,从 Canvas 的 drawImage 坐标换算,到旋转和 EXIF 方向的坑,再到用 cropper.js 快速落地,最后是导出压缩和一堆工程上的权衡。代码都能直接跑。
一、先想清楚:裁剪到底在裁什么
用户在页面上看到的是一张被 CSS 缩放过的 <img>,他拖动的裁剪框是屏幕坐标(CSS 像素)。但你最终要提交给后端的,是原图坐标系里的一块区域。这两个坐标系之间差着一个缩放比,所有裁剪 bug 的根源基本都在这里。
所以裁剪的本质就三步:
- 把用户在屏幕上框选的区域,换算回原图的真实像素坐标;
- 用 Canvas 把原图的那块区域重新绘制到一张新画布上;
- 把画布导出成 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、移动端要真机测。把这些坑提前想到,头像裁剪这类需求就不会在上线后反复回来找你了。

浙公网安备 33010602011771号