做过图片裁剪的人多半都遇到过这个场景:页面上拖出来的裁剪框看着好好的,点确认,导出的图要么偏了十几像素,要么整体缩小了一圈,要么在高分屏上正常、到同事的老显示器上就错位。
排查这类问题,八成不是算法写错了,是坐标系搞混了。一张图从原始文件到最终裁剪结果,中间至少要经过四套不同的坐标系,每一步都有自己的缩放和偏移。只要有一步没换算,结果就偏。
这篇把这四套坐标系拆开说清楚,再给一份能直接套用的换算代码。
一、四套坐标系分别是什么
假设用户上传了一张 4000×3000 的照片,页面把它显示在一个 800×600 的容器里。
第 1 套:图片的自然坐标系(natural)
就是图片文件本身的像素网格,4000×3000。img.naturalWidth / img.naturalHeight 拿到的就是它。最终裁剪要落在这套坐标上——因为 drawImage 的源矩形参数用的是它。
第 2 套:CSS 布局坐标系(layout)
图片在页面上实际占的 CSS 像素区域。用了 object-fit: contain 之后,800×600 的容器里,这张 4:3 的图正好铺满,显示尺寸是 800×600。但如果图是 4000×1000(4:1),contain 会让它变成 800×200,上下各留 200px 空白。这个空白就是第一个常见的偏移来源。
img.getBoundingClientRect() 拿到的是容器的盒子,不是图片实际绘制的区域。这两个在 object-fit 不是 fill 的时候不相等。
第 3 套:视口坐标系(viewport)
鼠标事件给你的 clientX / clientY 就在这套坐标里,原点是视口左上角。它和布局坐标之间差一个元素的 getBoundingClientRect().left/top,还要考虑页面滚动(clientX 已经排除了滚动,pageX 没有,别混用)。
第 4 套:Canvas 位图坐标系(bitmap)
canvas.width / canvas.height 定义的位图网格,和 canvas.style.width/height 定义的显示尺寸是两回事。在 DPR=2 的屏幕上,一个显示 800px 宽的 canvas,位图通常设成 1600。所有 ctx.drawImage、ctx.fillRect 的参数都在位图坐标里。
二、错位是怎么产生的
把上面四套串起来,一次裁剪的数据流是:
鼠标 (viewport)
→ 减去元素位置 → 裁剪框 (layout)
→ 除以显示缩放、减去 letterbox 偏移 → 源矩形 (natural)
→ drawImage 到 → 输出画布 (bitmap)
常见的四种错法:
错法一:用容器尺寸算缩放比。 scale = naturalWidth / rect.width,其中 rect 是容器。图和容器比例不一致时,这个 scale 是错的,而且横纵还不一样错。
错法二:忘了 letterbox 偏移。 object-fit: contain 会在短边方向留白。用户在留白区域按下鼠标,换算出来的源坐标会是负数或者超出图片边界,drawImage 那边表现为黑边或者干脆画不出来。
错法三:DPR 乘了两次或者一次没乘。 最典型的是用 getBoundingClientRect() 的值直接当 canvas 的 width,然后又在 ctx.scale(dpr, dpr) 里乘了一遍。结果是导出图刚好放大/缩小一倍。
错法四:CSS transform 没算进去。 容器上如果有 transform: scale()(比如做了缩放预览),getBoundingClientRect() 返回的是变换后的尺寸,而 offsetWidth 返回的是变换前的。混用这两个必错。
三、把换算写对
先写一个函数,算出图片在容器里实际绘制区域(contain 模式):
function getRenderedBox(natW, natH, boxW, boxH) {
const scale = Math.min(boxW / natW, boxH / natH)
const w = natW * scale
const h = natH * scale
return {
left: (boxW - w) / 2, // letterbox 偏移
top: (boxH - h) / 2,
width: w,
height: h,
scale, // layout 像素 → natural 像素的倒数
}
}
cover 模式把 Math.min 换成 Math.max 即可,偏移会变成负数,表示图片被容器裁掉了一部分,逻辑同样成立。
然后是鼠标坐标 → 图片自然坐标:
function clientToNatural(e, el, natW, natH) {
const rect = el.getBoundingClientRect()
const box = getRenderedBox(natW, natH, rect.width, rect.height)
// viewport → layout(相对容器)
const lx = e.clientX - rect.left
const ly = e.clientY - rect.top
// layout → natural,去掉 letterbox 偏移再除以缩放
const nx = (lx - box.left) / box.scale
const ny = (ly - box.top) / box.scale
// 夹到图片范围内,避免出现负数或越界
return {
x: Math.min(Math.max(nx, 0), natW),
y: Math.min(Math.max(ny, 0), natH),
}
}
注意 rect.width 已经包含了祖先链上所有 transform: scale 的效果,所以这里不需要单独再处理 transform——只要全程都用 getBoundingClientRect(),不去混 offsetWidth。
裁剪框本身在拖动过程中建议只存自然坐标。很多实现存的是 layout 坐标,然后在容器尺寸变化(窗口 resize、侧栏展开)时整个框就飘了。存自然坐标,每次渲染时正向换算回去画,resize 自动跟随:
function naturalToLayout(rectN, el, natW, natH) {
const rect = el.getBoundingClientRect()
const box = getRenderedBox(natW, natH, rect.width, rect.height)
return {
left: box.left + rectN.x * box.scale,
top: box.top + rectN.y * box.scale,
width: rectN.w * box.scale,
height: rectN.h * box.scale,
}
}

四、导出那一步
导出时只关心自然坐标和输出位图,和页面显示尺寸完全无关:
function cropToBlob(img, rectN, { type = 'image/jpeg', quality = 0.92 } = {}) {
const canvas = document.createElement('canvas')
// 输出多大就设多大,不要乘 DPR
canvas.width = Math.round(rectN.w)
canvas.height = Math.round(rectN.h)
const ctx = canvas.getContext('2d')
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(
img,
rectN.x, rectN.y, rectN.w, rectN.h, // 源矩形:自然坐标
0, 0, canvas.width, canvas.height // 目标矩形:位图坐标
)
return new Promise(res => canvas.toBlob(res, type, quality))
}
这里不要乘 DPR。 DPR 是"屏幕上一个 CSS 像素用几个物理像素画",只跟显示有关。导出的是文件,1:1 用自然坐标就是原图清晰度,乘了 DPR 只是把图插值放大,文件变大四倍而画面并没有变清楚。
DPR 该出现在哪?只在预览画布上:
const dpr = window.devicePixelRatio || 1
previewCanvas.width = cssW * dpr
previewCanvas.height = cssH * dpr
previewCanvas.style.width = cssW + 'px'
previewCanvas.style.height = cssH + 'px'
previewCtx.scale(dpr, dpr) // 之后所有绘制指令都用 CSS 像素写
设完 ctx.scale(dpr, dpr),后面画裁剪框、控制点、蒙层时就都按 CSS 像素写,不用到处乘 dpr,这是最不容易错的写法。
五、几个容易忽略的细节
EXIF 方向。 手机拍的 JPG 常带 orientation 标记,实际像素是横的,靠 EXIF 告诉软件"请旋转 90 度显示"。现代浏览器在 <img> 渲染时会自动应用这个旋转(image-orientation: from-image 是默认值),但 naturalWidth/naturalHeight 在各浏览器上的表现历史上并不一致,drawImage 的行为也曾经和 <img> 显示不一致。
稳妥的做法是上传后先用 createImageBitmap(file, { imageOrientation: 'from-image' }) 解一次,拿到已经转正的位图,后续全部基于它来算:
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
// bitmap.width / bitmap.height 就是转正后的尺寸,drawImage 直接用
老浏览器不支持这个选项时,需要自己读 EXIF 再手动旋转,代码量不小,但逻辑是清楚的。
四舍五入要统一。 自然坐标经过除法之后几乎必然是小数。如果拖动时向下取整、导出时四舍五入,就会出现"预览里框住的和导出的差一像素"。建议只在最后一步 Math.round,中间全程保留小数。
边界裁剪要在自然坐标里做。 在 layout 坐标里夹边界,遇到 letterbox 时夹的是容器边,不是图片边,还是会越界。
最小尺寸限制。 用户可能把框拖成 0×0 或者 1×2。canvas.width = 0 时 toBlob 在部分浏览器返回 null,不是抛异常,很容易变成一个静默失败。加一条下限判断,顺手把 null 也处理掉。
大图的内存。 一张 8000×6000 的图,解码成位图是 8000×6000×4 ≈ 183MB。移动端同时开两三个这样的 canvas 很容易被系统杀掉。真要支持超大图,得先按屏幕尺寸降采样出一份预览用的小图,裁剪时才回到原图去取那一块。
六、说点这套方案做不到的
它不处理旋转裁剪。 上面所有换算都假设裁剪框和图片是同向的矩形。一旦允许用户旋转裁剪框,drawImage 的源矩形参数就不够用了,得改成先 ctx.translate + ctx.rotate 再画,换算复杂度上一个台阶。
它不保证跨浏览器像素级一致。 不同浏览器的图片解码和缩放插值算法有差别,同样的参数,Chrome 和 Safari 导出的 JPEG 二进制不会完全相同,肉眼看不出来,但如果你的业务要对文件做哈希比对,这点得提前知道。
它不解决"裁出来变糊"。 如果用户在一张 800×600 的图上裁出 100×80 再放大到 400×320 展示,糊是必然的,这是信息量问题,不是代码问题。真要放大,那是超分辨率的事,和裁剪无关。
toBlob 是异步且可能失败的。 内存不足、canvas 被污染(跨域图片没有正确的 CORS 头)都会让它失败。跨域这条尤其常见——img.crossOrigin = 'anonymous' 加上服务端的 Access-Control-Allow-Origin 缺一不可,否则 toBlob 直接抛 SecurityError。
最后
这些坐标系的换算不难,难的是全流程只用一套统一的表示。我的经验是定一条规矩:内部状态一律存自然坐标,只在渲染那一刻换算成 layout,只在导出那一刻换算成 bitmap。中间任何一个环节想"顺手用一下 rect.width",就是 bug 的开始。
我在 forxi.cn 上做图片裁剪这个功能的时候,前后翻车过好几次,基本都是上面列的那几种。这篇写的是收敛之后的通用做法,代码是照着思路重写的示例,不涉及具体产品实现,拿去直接改改就能用。

16

被折叠的 条评论
为什么被折叠?



