前端旋转一张图片,看起来就是 ctx.rotate() 一行的事。真写起来,四个角被裁掉、导出多一条黑边、JPG 角上发黑、翻转方向反了,这些问题基本都会遇到一遍。
这篇把我实测过的六个坑整理出来,每个都给了能直接跑的示意代码和实测数字。测试环境是 Chrome 154(macOS),测试图是一张 1200×800 的纯色图,左上角画了一个 60×60 的橙色方块当标记,方便判断方向。
坑一:在原尺寸画布上旋转,四个角直接没了
最常见的写法是这样:
js
function rotateNaive(img, deg) {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((deg * Math.PI) / 180)
ctx.drawImage(img, -img.width / 2, -img.height / 2)
return canvas
}
画布还是原图那么大,旋转后超出画布的部分就被裁掉了。我数了一下结果里不透明像素占原图面积的比例,1200×800 的图:
| 角度 | 丢掉的像素 |
|---|---|
| 15° | 11.15% |
| 30° | 17.96% |
| 45° | 21.93% |
转 15° 就丢了一成多,四个角上的内容全没了。
坑二:外接矩形要算,而且 cos、sin 必须取绝对值
旋转后图片的外接矩形宽高是:
js
function boundingSize(w, h, deg) {
const rad = (deg * Math.PI) / 180
const cos = Math.abs(Math.cos(rad))
const sin = Math.abs(Math.sin(rad))
return {
width: Math.round(w * cos + h * sin),
height: Math.round(w * sin + h * cos),
}
}
1200×800 转 30° 得到 1439×1293,这样四个角就都在画布里了。
Math.abs 千万别省。不取绝对值的话,转 180° 时 cos 是 -1,算出来宽是 -1200、高是 -799.9999999999999。把负数赋给 canvas.width 不会报错,而是悄悄退回默认值 300,结果就是一张 300 宽的残图,很难想到是这里出的问题。
坑三:用 Math.ceil 取整,90° 会多出 1 像素黑边
有人觉得 Math.round 可能让画布小半个像素,改成 Math.ceil 更保险。问题出在浮点数上:
js
Math.cos(Math.PI / 2) // 6.123233995736766e-17,不是 0
1200 * Math.cos(Math.PI / 2) + 800 * Math.sin(Math.PI / 2)
// 800.0000000000001
Math.ceil(800.0000000000001) // 801
1200×800 转 90°,画布变成 801×1200,多了一列。我读了一下像素,多出来的那一列 alpha 是 0(透明),图片本身还被挤得偏了半个像素,另一侧边缘那一列变成半透明(alpha 128)。
导出成 JPG 以后,透明的那一列变成了 RGB(0, 8, 2),就是一条贴边的黑线。手机照片常见的 4032×3024 转 90°,用 ceil 算出来是 3025×4032,一样多一列。
结论:外接矩形用 Math.round。如果是 90° 的整数倍,干脆直接交换宽高,别走三角函数:
js
function boundingSizeSafe(w, h, deg) {
const d = ((deg % 360) + 360) % 360
if (d === 0 || d === 180) return { width: w, height: h }
if (d === 90 || d === 270) return { width: h, height: w }
return boundingSize(w, h, deg)
}
坑四:导出 JPG,四个角是黑的
外接矩形算对了,转 30° 的图四个角是透明的。导出 PNG 没问题,导出 JPG 时 JPG 没有透明通道,透明像素会被当成黑色。我读了导出后 JPG 左上角的像素,是 RGB(0, 0, 0)。
用户一般希望角上是白色,所以导出 JPG 前先铺一层白底:
js
function toJpegBlob(canvas, quality = 0.92) {
const out = document.createElement('canvas')
out.width = canvas.width
out.height = canvas.height
const ctx = out.getContext('2d')
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, out.width, out.height)
ctx.drawImage(canvas, 0, 0)
return new Promise((resolve) => out.toBlob(resolve, 'image/jpeg', quality))
}
顺带一个体积问题。同一张转了 30° 的测试图,导出 PNG 是 80.1KB,JPG(质量 0.92)37.8KB,WebP(0.9)20.4KB。旋转本身会让画布变大,再保存成 PNG,体积涨得更多。我拿一张 2304×1728 的 JPG 照片在 福兮的图片旋转工具 里转 30°,画布变成 2859×2648,页面上显示的体积变化是 +418%:

可以看到四个角是白色而不是黑色,就是导出前铺了白底的效果。
坑五:先翻转还是先旋转,结果不一样
需求是「把图片转 30° 再水平镜像」。直觉写法是先 rotate 再 scale(-1, 1):
js
function rotateThenFlip(img, deg) {
const { width, height } = boundingSize(img.width, img.height, deg)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx.translate(width / 2, height / 2)
ctx.rotate((deg * Math.PI) / 180)
ctx.scale(-1, 1)
ctx.drawImage(img, -img.width / 2, -img.height / 2)
return canvas
}
canvas 的变换矩阵是右乘的,代码里越靠后的变换,越先作用在图片上。所以上面这段实际是「先把原图镜像,再转 30°」。
我用左上角的橙色标记验证了一下(1439×1293 的画布,坐标是标记的中心点):
| 写法 | 标记位置 |
|---|---|
| 只旋转 30° | (410, 41) |
代码顺序 scale → rotate |
(1028, 41) |
代码顺序 rotate → scale |
(1398, 611) |
scale 写在前面的那个,标记在 (1028, 41),刚好是只旋转结果的左右镜像(1439 - 410 - 1 = 1028),这才是「转完再镜像」。另一种写法,标记跑到了右边中间,是「镜像完再转」。
两种都对,关键是想清楚你要的是哪一种。如果界面上旋转角度和翻转按钮是分开的,用户预期的通常是「看到的图转好之后再镜像」。
坑六:浏览器已经帮你处理了 EXIF 方向,别再转一遍
手机竖着拍的照片,像素其实是横着存的,靠 EXIF 里的 Orientation 字段告诉看图软件要转 90°。以前浏览器不认这个字段,所以很多老代码会自己读 EXIF 再手动旋转。
现在的 Chrome 会自动应用 EXIF 方向。我手工给一张 1200×800 的 JPG 塞了 Orientation=6 的 EXIF 段,测下来:
js
img.naturalWidth, img.naturalHeight // 800, 1200(已经转过了)
(await createImageBitmap(blob)).width // 800
ctx.drawImage(img, 0, 0) // 画出来也是竖的
createImageBitmap(blob, { imageOrientation: 'none' }) 在 Chrome 154 里拿到的也是 800×1200。如果你的代码再按 EXIF 转一次 90°,结果是 1200×800,又变回横的了,用户会看到照片莫名其妙倒了。
所以老项目里如果有「读 EXIF → 手动旋转」的逻辑,先在目标浏览器里确认一下是不是已经被浏览器处理过了,大概率可以删掉。
汇总一下
| 坑 | 现象 | 做法 |
|---|---|---|
| 原尺寸画布 | 转 15° 丢 11% | 按外接矩形建画布 |
| 不取绝对值 | 180° 时宽度变 300 | Math.abs(cos)、Math.abs(sin) |
Math.ceil |
90° 多一列黑边 | Math.round,90° 倍数直接交换宽高 |
| 导出 JPG | 四角发黑 | 先铺白底 |
| 变换顺序 | 镜像方向不对 | 代码里越靠后越先执行 |
| EXIF | 照片转了两次 | 浏览器已自动处理,别重复转 |
局限说明
几件我没测或者没解决的事:
- 上面的数字都是 Chrome 154 测的,Safari、Firefox 我没逐个跑,EXIF 那条在老版本 Safari 上行为不一样。
- 大图旋转很吃内存。8000×6000 的图转 45°,外接矩形是 9899×9899,接近一亿像素。常被引用的 iOS Safari canvas 面积上限是 16777216 像素(4096×4096),这么大的图在手机上大概率直接失败,这条我没在真机上验证。
- 任意角度旋转之后的边缘有抗锯齿,边缘像素是半透明的,导出 JPG 铺白底后边缘会有一圈很淡的过渡,这是正常的,想要绝对锐利的边只能转 90° 的倍数。
如果只是想快速把一张图转个角度、不想自己写,可以用 福兮的图片旋转工具,任意角度旋转时会自动扩展画布,导出 JPG 时角上是白色。