1. 从“画出来歪了”说起:为什么Canvas必须搞懂坐标系
在HTML5和H5开发里,Canvas是个高频工具,绘图、动画、游戏、图表、图像处理都离不开它。但我见过太多人——包括不少写了两三年前端的老手——在Canvas上栽跟头,而且栽的地方高度一致:画出来的东西位置不对、旋转中心不对、缩放之后整个图形跑到屏幕外去了。最后排查半天,发现不是数学算错了,也不是代码写错了,而是坐标系变换没有理解透。
说一个最常见的场景:你想在Canvas上画一个旋转的方块,于是写了ctx.rotate(angle),然后fillRect(0, 0, 100, 100)。结果方块确实转了,但转的轨迹完全不在预期位置,甚至干脆跑出了画布边界。另一个经典问题:你想把一张图缩放一半,调用ctx.scale(0.5, 0.5)之后,图片却往左上角“缩”过去了,不是以你希望的中心点缩放。
这两个问题的根子,都在于你对Canvas坐标系变换的机制缺乏一个系统性的认知。Canvas里的translate、rotate、scale、transform这些方法,不是简单的“改参数”,而是对底层坐标系的“手术”。你改了坐标系,后续所有绘图操作都会被“传染”。
这篇博文不打算复述MDN上的API文档,而是想从一个实操角度,把Canvas坐标系变换的底层逻辑、常见坑点、以及如何用变换矩阵彻底掌控画布,一次讲透。适合的人群很明确:已经能写简单Canvas绘图、但一旦遇到旋转缩放就头大的人;或者说你正在做复杂Canvas项目(图表、游戏、图形编辑器),急需一套清晰可靠的坐标系管理方案。看完这篇文章,你至少能做到:不用猜,就能推算出任意变换后图形的准确位置;写一个通用方法,让任何图形绕自己的中心点旋转缩放;彻底搞懂setTransform和transform的区别,以及什么时候该用哪个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Canvas坐标系到底是什么:先把这个底子打好
2.1 默认坐标系:原点、X轴与Y轴
在讲解变换之前,必须先明确Canvas默认的坐标系规则。如果你新建一个Canvas标签:
html复制<canvas id="myCanvas" width="800" height="600"></canvas>
那么默认情况下,坐标原点(0, 0)位于Canvas左上角,X轴正方向朝右,Y轴正方向朝下。这个方向和你在数学课上学到的平面直角坐标系不一样——数学里Y轴默认朝上,而Canvas里Y轴朝下。这个反过来的Y轴,是无数人第一次犯晕的地方。
举个例子:你画一条线,从(10, 10)到(50, 50),它会往右下方倾斜,而不是右上方。这在绘制图表时尤其容易踩坑——你算出一个数据点的Y坐标,想当然地认为数值越大越靠上,结果画出来发现全跑到画布底部去了。
Canvas的坐标系还分两种:一种是“画布坐标”,也就是Canvas元素本身在页面上的坐标,它对应到CSS像素;另一种是“绘图坐标”,也就是调用ctx上下文绘图时使用的坐标。默认情况下这两个坐标系是重合的。一旦你调用了scale,比如ctx.scale(2, 2),那么绘图坐标的单位就变成了原来的一半——你画一个fillRect(0, 0, 100, 100),实际占据的画布像素是200x200。
2.2 变换的本质:不是改变图形,而是改变“画图的工作台”
这是理解坐标系变换最关键的认知转折。很多人把ctx.rotate(0.5)理解为“把接下来画的图形旋转0.5弧度”,这个理解不能说错,但它解释不了很多奇怪现象。
更准确的理解方式是:变换操作改变的不是你要画的图形,而是绘图工作的坐标系(工作台)。每次调用ctx.translate(x, y),相当于你把工作台从原点挪到了(x, y);调用ctx.rotate(angle),相当于你把工作台旋转了一个角度;调用ctx.scale(sx, sy),相当于你把工作台的刻度尺重新校准了。
在这个变换后的工作台上画图,你写的坐标依然是“工作台自己的坐标”,但最终的像素位置,是经过工作台变换后的结果。
打个比方:你坐在一张图纸前画画。图纸自带一个坐标系。translate就是你把整张图纸往右平移一段距离;rotate就是你把图纸顺时针转动一个角度;scale就是你把图纸横向纵向拉伸。你拿笔在同一个坐标点画一个点,图纸平移、旋转、拉伸之后,这个点最终落在你眼前桌子上的位置自然就变了。
这个概念为什么重要?因为它决定了你“以谁为参照系”。比如你想画一个围绕某个点旋转的图形,如果你只在变换后的坐标系里画图,你不需要去算旋转后每个顶点的具体坐标,你只需要把“工作台的旋转中心对准你想绕的点”,然后画图形本身的形状就可以了。这个思路,比逐点计算坐标要简单不知道多少倍。
2.3 状态栈:为什么save()和restore()是命根子
既然变换会“传染”后续所有绘图操作,那你肯定需要一个机制来隔离这些变换的影响。Canvas提供了一对方法:ctx.save()和ctx.restore()。
save()会把当前画布的状态(包括所有变换、globalAlpha、globalCompositeOperation、clip区域、lineWidth等一大堆属性)压入一个状态栈。restore()则从栈顶弹出一个状态,恢复画布为之前保存的那个状态。
注意:save()和restore()不是简单地保存坐标值,而是保存整个绘图状态。
举个例子:
javascript复制ctx.save(); // 保存初始状态
ctx.translate(100, 100); // 移动工作台
ctx.rotate(Math.PI / 4); // 旋转45度
ctx.fillRect(0, 0, 50, 50); // 在变换后的坐标系里画一个正方形
ctx.restore(); // 恢复初始状态
ctx.fillRect(0, 0, 50, 50); // 这里仍然是原来的坐标系,画在左上角
如果不调用restore(),第二次画的fillRect也会继承translate和rotate的效果,位置和方向全都变了。所以在做复杂绘制时,一个良好的习惯是:每次进行变换绘图前,先save(),绘图完成后立刻restore()。这样坐标系的状态是可控的、可预测的。
有人可能会问:translate之后,我再用负值把它移回去不行吗?理论上可以,但非常不推荐。因为如果中间插入了多个rotate、scale操作,想精确“逆向”回去非常容易算错,而且代码可读性极差。save()和restore()是Canvas设计者给你的“后悔药”,用就对了。
3. 三大基础变换:translate、rotate、scale到底做了什么
3.1 translate:平移坐标系,解决“画在哪儿”的问题
ctx.translate(x, y)的作用是把坐标系原点平移到指定位置。注意,指的是“当前坐标系的原点”,不是“画布原点的绝对位置”。
也就是说,如果你在第10行调用了ctx.translate(50, 50),在第20行又调用ctx.translate(30, 30),那么第二次平移之后,坐标系原点相对画布原点是(80, 80),而不是(30, 30)。因为它是在“当前坐标系”上继续平移的。
这个特性和变换的“累积性”有关。Canvas的变换是叠加的,后一次变换是在前一次变换的结果之上进行的。理解这一点,你才能真正控制坐标系的嵌套关系。
translate最常见的用处是把坐标系搬到图形的中心点或某个锚点,然后在这个新坐标系里绘制图形。例如你想在(200, 150)处画一个半径为50的圆,你既可以写ctx.arc(200, 150, 50, 0, Math.PI * 2),也可以写ctx.translate(200, 150); ctx.arc(0, 0, 50, 0, Math.PI * 2)。两种写法结果一样,但第二种写法的好处是:后续如果你还画其他围绕这个圆心旋转的东西,直接在(0, 0)周围操作即可。
3.2 rotate:旋转坐标系,注意角度的单位与方向
ctx.rotate(angle)的作用是绕当前坐标系的原点旋转坐标系。参数单位是弧度,不是角度。如果你习惯用角度,需要转换:radian = degree * Math.PI / 180。
旋转方向:正角度表示顺时针方向旋转(因为Y轴向下,所以“正向”看起来是顺时针)。
这里有个新手非常容易踩的坑:ctx.rotate是绕当前坐标系的原点旋转,不是绕画布中心,也不是绕某个图形的中心。如果你直接调用ctx.rotate(Math.PI / 4)然后画一个矩形,这个矩形会绕着画布左上角旋转,而不是绕矩形自己的中心旋转。
实际上大多数需求都是“让一个图形绕自身的中心旋转”。实现方法也很简单:先用translate把坐标系原点搬到图形中心,再rotate,然后在平移后的坐标系里以(0, 0)为中心画这个图形。
示例代码:
javascript复制function drawRotatedRect(ctx, x, y, width, height, angle) {
ctx.save();
ctx.translate(x, y); // 原点移到图形中心
ctx.rotate(angle); // 绕图形中心旋转
ctx.fillRect(-width / 2, -height / 2, width, height); // 中心在原点
ctx.restore();
}
这段代码里,x、y是图形中心在画布上的坐标。先平移,后旋转,再绘图,顺序不能乱。如果调换顺序,旋转就会绕画布原点了。
3.3 scale:缩放坐标系,小心图形的位置也会被“缩放”
ctx.scale(sx, sy)的作用是沿X轴和Y轴分别缩放坐标系。sx和sy是缩放因子:1表示不变,2表示放大一倍,0.5表示缩小一半。
scale一个容易忽略的副作用:它不仅放缩图形本身,还会放缩坐标值和线宽。也就是说,你把坐标系放大2倍之后,画一个fillRect(10, 10, 100, 100),这个矩形在画布上的实际位置是(20, 20),实际大小是200x200。并且在scale状态下设置的lineWidth: 2,实际绘制时会被放大成4像素宽的线。
这就导致了那个经典问题:为什么我对Canvas做了scale(0.5, 0.5)之后,图形跑到左上角去了?因为图形本身的位置坐标也被压缩了。原来在(100, 100)的点,scale之后变到了(50, 50)。
解决办法依旧依靠translate:如果你希望以某个点为中心进行缩放,先把原点移到该点,再scale,然后以原点为中心绘制图形。
javascript复制function drawScaledRect(ctx, cx, cy, width, height, scaleFactor) {
ctx.save();
ctx.translate(cx, cy);
ctx.scale(scaleFactor, scaleFactor);
ctx.fillRect(-width / 2, -height / 2, width, height);
ctx.restore();
}
还记得我们在3.2里说的“绕自身中心旋转”吗?其实旋转和缩放完全可以统一成一个模式:任何复杂的变换,先translate到锚点,再rotate/scale,然后以锚点为原点绘图。这是Canvas坐标系变换最核心的一个套路。
4. 变换矩阵:从工具人走向真正的掌控者
4.1 初识变换矩阵:Canvas底层只有一种变换
Canvas的translate、rotate、scale虽然接口不同,但它们底层统一由变换矩阵控制。我们可以用ctx.getTransform()查看当前的变换矩阵,它是一个DOMMatrix对象,包含a、b、c、d、e、f六个分量。
这六个分量和坐标的换算关系是:
code复制x' = a * x + c * y + e
y' = b * x + d * y + f
其中(x, y)是你写入绘图API时的坐标,(x', y')是最终映射到画布上的坐标。a、b、c、d控制缩放和旋转,e、f控制平移。
a:X方向缩放b:Y方向影响X(通常与旋转相关)c:X方向影响Y(通常与旋转相关)d:Y方向缩放e:X方向平移f:Y方向平移
如果没有任何变换,矩阵就是单位矩阵:a=1, b=0, c=0, d=1, e=0, f=0,对应关系就是x' = x, y' = y。
4.2 矩阵如何表达这三种基本变换
用一个表格整理一下:
| 变换 | a | b | c | d | e | f |
|---|---|---|---|---|---|---|
| 无变换 | 1 | 0 | 0 | 1 | 0 | 0 |
| translate(tx, ty) | 1 | 0 | 0 | 1 | tx | ty |
| scale(sx, sy) | sx | 0 | 0 | sy | 0 | 0 |
| rotate(angle) | cos | sin | -sin | cos | 0 | 0 |
rotate的矩阵实际上就是二维旋转矩阵:
code复制[cos(θ) -sin(θ) 0]
[sin(θ) cos(θ) 0]
[ 0 0 1]
对应到Canvas的变量映射,就是a=cos(θ),b=sin(θ),c=-sin(θ),d=cos(θ)。
4.3 setTransform和transform:两种截然不同的操作
这两个方法都接受6个参数:ctx.setTransform(a, b, c, d, e, f)和ctx.transform(a, b, c, d, e, f)。
区别在于:
setTransform会重置当前变换为指定的矩阵,忽略之前所有变换。transform会在当前变换矩阵的基础上左乘一个新的矩阵,相当于在当前工作台的前提下再叠加一次变换。
举个例子:
javascript复制ctx.translate(100, 0);
ctx.setTransform(1, 0, 0, 1, 50, 50);
// 此时矩阵对应 translate(50, 50),之前的 translate(100, 0) 被清除了。
ctx.translate(100, 0);
ctx.transform(1, 0, 0, 1, 50, 50);
// 此时两个平移叠加,相当于 translate(150, 50)
transform的行为和直接调用translate/rotate/scale类似,都是在当前矩阵基础上叠加。而setTransform是直接“覆盖”。
这里有一个常见需求:你想在复杂变换之后,一次性把坐标系恢复到初始状态。很多人会连续调用ctx.restore(),但如果前面没有save(),就会报错或无效。这时候更简洁的做法是:
javascript复制ctx.setTransform(1, 0, 0, 1, 0, 0);
这一行代码直接重置回默认坐标系,相当于把之前的变换全部清空。实际开发中,setTransform非常适合用在每一帧动画的重置上。
4.4 如何手写组合变换矩阵
如果不想依赖translate/rotate/scale这些方法,你也可以直接用setTransform一步到位。比方说,你想实现“绕点(cx, cy)旋转angle,再缩放sx和sy”,最终的矩阵可以这样推导:
先平移(-cx, -cy)把锚点移到原点,再缩放,再旋转,再平移回(cx, cy)。这个顺序写成API是这样的:
javascript复制ctx.setTransform(
Math.cos(angle) * sx,
Math.sin(angle) * sx,
-Math.sin(angle) * sy,
Math.cos(angle) * sy,
cx - Math.cos(angle) * sx * cx + Math.sin(angle) * sy * cy,
cy - Math.sin(angle) * sx * cx - Math.cos(angle) * sy * cy
);
这套公式看着吓人,其实就是平移、缩放、旋转三个矩阵相乘的结果。实际开发中有现成的工具库(比如fabric.js、Konva.js)帮你处理矩阵,但理解底层的组合方式,对排查“为什么我的变换不对”有很大帮助——你能一眼看出是矩阵乘积顺序出了问题。
5. 实战:绕自身中心旋转缩放的通用方案
5.1 从需求出发:图表标注、游戏精灵、图片批量处理都能用
这里我们做一个通用函数。你给它一个图形的中心点、宽高、旋转角、缩放因子,它就能在任意位置画出符合要求的图形。这个函数适配各种需求:H5里一个加载动画的小图标、一个粒子特效、一个地图上的标注点,都可以用这套方法。
先看旋转的需求。在3.2我们写过一个drawRotatedRect,现在把它扩展一下,支持缩放:
javascript复制function drawRotatedAndScaledRect(ctx, cx, cy, width, height, angle, sx, sy) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.scale(sx, sy);
ctx.fillRect(-width / 2, -height / 2, width, height);
ctx.restore();
}
这个函数虽然简单,但背后的逻辑值得多说一句:rotate永远绕原点,所以我们需要先用translate把原点搬到(cx, cy)。而scale会缩放坐标位置,所以必须在translate之后、画图之前调用,这样宽高才会被正确缩放,而中心点位置不会被缩放跑偏。
5.2 椭圆、多边形、图片:同一套思路的通吃性
不要以为这套思路只能画矩形。任何基于Canvas的绘图API,都可以在统一定位后的坐标系里绘制。比如:
- 画椭圆:
ctx.ellipse(0, 0, radiusX, radiusY, 0, 0, Math.PI * 2) - 画图片:
ctx.drawImage(img, -img.width / 2, -img.height / 2) - 画多边形:
ctx.moveTo/lineTo坐标都写成相对(0,0)为中心的值。
一个给图片做旋转和缩放的完整示例:
javascript复制function drawImageCentered(ctx, img, cx, cy, angle, sx, sy) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.scale(sx, sy);
ctx.drawImage(img, -img.width / 2, -img.height / 2);
ctx.restore();
}
这个函数在H5开发里特别常用,比如用户上传头像后做居中裁剪预览、或者做场景编辑器的拖拽旋转功能。
5.3 一个容易被忽略的问题:先缩放再旋转,还是先旋转再缩放?
很多同学会问:rotate和scale的顺序到底有没有影响?当然有,而且影响巨大。因为矩阵乘法不满足交换律。
看下面的对比:
javascript复制ctx.scale(1, 0.5); // Y轴压缩一半
ctx.rotate(Math.PI / 4); // 然后旋转45度
// 效果:先压缩再旋转
ctx.rotate(Math.PI / 4); // 先旋转45度
ctx.scale(1, 0.5); // 再Y轴压缩一半
// 效果:先旋转再压缩
两种写法出来的图形,一个是“扁的菱形”,一个是“被压扁后旋转的矩形”,截然不同。所以调换顺序必须想清楚你的需求。
大多数实际场景中,推荐顺序是:rotate在前,scale在后。这是因为缩放通常作用于图形本身的“局部坐标”,而旋转是整个图形的朝向。先旋转,再缩放,图形的缩放方向是相对画布坐标系的;先缩放再旋转,缩放方向是相对图形自身坐标系的。具体用哪种,取决于你希望缩放跟随旋转还是固定方向。没有绝对正确,只有适合你的业务场景。
6. 动画循环中的坐标系管理:每一帧都要回到原点
6.1 为什么动画帧里要用setTransform而不用save/restore
在做Canvas动画时,常见套路是:
javascript复制function drawFrame() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ...各种绘图
requestAnimationFrame(drawFrame);
}
你需要在每一帧清空画布,重新绘制。如果这一帧里使用了transform,不清除的话会影响下一帧。这时候有两种清理思路:
- 每次绘图前
save,绘图后restore。 - 每帧开始用
ctx.setTransform(1, 0, 0, 1, 0, 0)重置坐标系。
个人更推荐第2种。原因有二:一是不容易出错,不用去盯配对的save/restore;二是setTransform的语义就是“直接覆盖”,比你用restore还依赖之前必须save过,更安全。而且就算你忘了重置,下一帧也会被重置掉,动画不会累积错位。
6.2 一个动画粒子系统的完整示例
我们来个实际小例子:一个简单粒子系统,每个粒子有自己的位置和角度,运动时自己旋转,并且绕自身中心缩放淡出。
html复制<canvas id="canvas" width="600" height="400"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const particles = [];
const colors = ['#ff6384', '#36a2eb', '#cc65ff', '#ffce56'];
function createParticle(x, y) {
return {
x: x,
y: y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
angle: Math.random() * Math.PI * 2,
vAngle: (Math.random() - 0.5) * 0.2,
life: 1,
size: 10 + Math.random() * 10,
color: colors[Math.floor(Math.random() * colors.length)]
};
}
function updateParticle(p) {
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.98;
p.vy *= 0.98;
p.angle += p.vAngle;
p.life -= 0.01;
}
function drawParticle(p) {
if (p.life <= 0) return;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.scale(p.life, p.life);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.life;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
ctx.restore();
}
function drawFrame() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(updateParticle);
particles.forEach(drawParticle);
// 移除死亡粒子
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].life <= 0) particles.splice(i, 1);
}
requestAnimationFrame(drawFrame);
}
// 鼠标点击产生粒子
canvas.addEventListener('click', (e) => {
for (let i = 0; i < 10; i++) {
particles.push(createParticle(e.offsetX, e.offsetY));
}
});
drawFrame();
</script>
这里每一帧开头调用setTransform(1,0,0,1,0,0)重置坐标系,确保所有粒子的坐标都是相对画布原点的绝对坐标。每个粒子内部则通过save/translate/rotate/scale/restore互不干扰地完成自己的局部变换。
在这个例子里,scale(p.life, p.life)让粒子随着生命值缩小,globalAlpha让它淡出,旋转让它在坠落过程中翻转。这三个效果叠加在一个粒子上,但彼此清楚、互不污染,靠的就是对坐标系的严格隔离。
6.3 检查坐标系是否“干净”的三个习惯
在调试复杂的Canvas项目时,我养成了三个习惯,推荐给大家:
- 每一帧开头的第一条语句就是
ctx.setTransform(1, 0, 0, 1, 0, 0),除非有特殊理由。 - 在
save()和restore()之间写完所有局部变换的绘制,不要跨越多个函数边界。 - 如果发现某个图形位置不对,在绘制它之前插入
console.log(ctx.getTransform()),看看当前矩阵是什么,很快就能定位是哪一步变换“污染”了。
第三点尤其好用。很多时候你以为自己没做过变换,但可能某个工具函数内部偷偷调用了translate/scale而忘了恢复。打印一下矩阵,立刻现形。
7. 坐标系变换的常见错误与排查链路
7.1 问题一:旋转中心不对,图形绕画布左上角转
这是最常见的问题,现象是你调用了ctx.rotate(angle),然后一个原本在画布中央的矩形开始绕着左上角转,而不是绕着自身中心转。
排查链路:
- 第一步:检查rotate前是否有translate。如果没有,那必然绕原点(左上角)旋转。
- 第二步:检查translate参数是否对应图形的中心点。如果你几何中心是
(200, 150),却写成了(100, 50),那当然不是绕正确中心转。 - 第三步:检查你画图时用的坐标是否以
(0,0)为参照。如果translate到了中心点,但绘图时用的还是绝对坐标,比如fillRect(200, 150, 50, 50),那这个矩形会跑得更远。
正确写法:
javascript复制ctx.save();
ctx.translate(200, 150);
ctx.rotate(angle);
ctx.fillRect(-25, -25, 50, 50);
ctx.restore();
7.2 问题二:缩放之后图形位置“漂移”
现象:你调用了ctx.scale(0.5, 0.5),图形不但缩小了,还往左上角缩。
原因:缩放会同时缩放坐标系里的所有坐标值。图形位置坐标也被缩放了。
解决方案:用translate将原点搬到图形中心,再缩放,然后以原点为中心绘图。这点我们在3.3已经强调过。
另一个可能原因:如果你在某个save()之前已经做了translate,然后缩放后忘记restore,叠加的变换会越来越强大,图形会越画越偏。所以排查漂移问题时,还要检查是否有“未配对的save/restore”。
7.3 问题三:多个图形相互“传染”
现象:画完第一个图形,继续画第二个图形,发现第二个图形继承了第一个图形的旋转和缩放。
原因:变换没有被隔离。最典型的写法是,在某个函数外写了ctx.translate/rotate,忘记在函数结束后restore。
排查链路:
- 打开控制台,看那几个函数的调用栈。
- 在可疑的绘图函数前后加上
console.log(ctx.getTransform()),对比矩阵变化。 - 找到没有配对的
save/restore,或者不该存在的全局变换。
举个例子:
javascript复制function drawPie(ctx) {
ctx.translate(300, 200);
ctx.rotate(someAngle);
// 画了饼图
// 这里没有 restore!
}
function drawLegend(ctx) {
// 想画图例,结果所有坐标都偏移了
ctx.fillRect(10, 10, 20, 20);
}
这种问题在大型项目中特别多。我的建议是:任何函数,只要调用了变换方法,第一行就save(),最后一行就restore(),形成肌肉记忆。这能避免大量因“状态污染”产生的bug。
7.4 问题四:canvas尺寸和CSS尺寸不一致导致的错位
还有一个跟坐标系直接相关的“隐形杀手”:Canvas元素的CSS尺寸和画布属性尺寸不一致时,坐标系统会错乱。
html复制<canvas id="c" width="300" height="150" style="width:600px;height:300px;"></canvas>
这种情况下,Canvas内部的绘图坐标基于300x150,但CSS把它拉伸到了600x300,导致画的图形变形。更麻烦的是,鼠标事件获取的坐标是CSS像素,而你绘图用的是画布像素,两者如果不做换算,你点击的位置和绘制的图形位置就会对不上。
解决方案:要么保证CSS尺寸和属性尺寸一致;要么用canvas.getBoundingClientRect()和canvas.width / rect.width这种比例关系,把鼠标坐标换算成画布坐标。
javascript复制const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mouseX = (e.clientX - rect.left) * scaleX;
const mouseY = (e.clientY - rect.top) * scaleY;
很多“图形位置不对”的问题,源头其实不是变换,而是这种尺寸混用。排查时别忽略这一层。
8. 高清屏适配与坐标系变换的另一层关系
8.1 设备像素比DPR:为什么Canvas在Retina屏上模糊
在做H5开发时,几乎必然遇到高清屏适配问题。Retina屏的devicePixelRatio为2或3,也就是物理像素是CSS像素的2倍或3倍。如果你直接设置canvas.width = 300,CSS显示也是300px,那么在Retina屏上每个画布像素会被放大为2个物理像素,导致图形边缘模糊。
标准解法是:
javascript复制const dpr = window.devicePixelRatio || 1;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
ctx.scale(dpr, dpr);
注意这里已经用到了坐标系变换:ctx.scale(dpr, dpr)把坐标系放大到物理像素尺寸,之后所有绘图代码都按CSS像素的逻辑尺寸来写,画出来的图片就清晰了。
8.2 高清屏适配的“坑”:坐标系不是原来的坐标系
一旦你执行了ctx.scale(dpr, dpr),坐标系就变了。之后如果你再叠加其他变换,比如rotate/translate,要特别小心它们的基准点。
例如你封装了一个函数来做图形变换,在里面用ctx.translate(x, y),这个x、y是基于CSS像素的,没问题,因为坐标系已经被dpr缩放了。但如果你在某个地方忘了这个缩放,直接使用canvas.width或canvas.height作为坐标参数,就会和实际显示位置差dpr倍。
一个安全的做法是:在初始化时保存好逻辑尺寸,绘图时只用逻辑尺寸,不直接使用canvas内部分辨率属性。
javascript复制const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const logicalWidth = 400;
const logicalHeight = 300;
canvas.width = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
canvas.style.width = logicalWidth + 'px';
canvas.style.height = logicalHeight + 'px';
ctx.scale(dpr, dpr);
// 之后所有绘图逻辑都以 logicalWidth/logicalHeight 为边界思考
8.3 DPR和setTransform联动:重置坐标系时不能只重置为(1,0,0,1,0,0)
这节内容很多人容易踩坑。如果我们上面为了适配DPR执行了ctx.scale(dpr, dpr),但是在动画循环里,每帧用ctx.setTransform(1, 0, 0, 1, 0, 0)重置坐标系,那么DPR的缩放就被清除了,之后的图形会按CSS像素尺寸绘制,导致模糊或错位。
正确做法是,每帧重置时也要把DPR缩放考虑进去:
javascript复制ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
或者,你可以用一个变量存储初始变换,每次重置时使用它:
javascript复制const baseTransform = { dpr: window.devicePixelRatio || 1 };
// 初始化时放大
ctx.setTransform(baseTransform.dpr, 0, 0, baseTransform.dpr, 0, 0);
// 每帧重置
ctx.setTransform(baseTransform.dpr, 0, 0, baseTransform.dpr, 0, 0);
如果项目里用了fabric.js这类库,它们内部已经处理了DPR,你不需要手动ctx.scale。但如果你从零开始写Canvas渲染,就一定要把这个逻辑想清楚。
9. 进阶:结合鼠标交互的坐标系反解
9.1 已知画布内某个点,如何求得它在变换后坐标系中的对应点
经常有这样的需求:画布上有一个已经被rotate/scale过的图形,你想实现“点击图形内部则选中它”的功能。鼠标事件给出的坐标是画布坐标(假设已经做过DPR换算),而图形是在变换后的坐标系中绘制的。没有反变换,你很难判断鼠标点是否在图形内部。
解决办法有两种:
- 逆矩阵法:求出当前变换矩阵的逆矩阵,把鼠标画布坐标变回图形的局部坐标,然后用局部坐标去判断(比如矩形:
0 <= localX <= width && 0 <= localY <= height)。 - 包围盒法:不直接判断精确位置,而是用图形变换后的包围盒做粗略判断,适用于精度要求不高的场景。
逆矩阵法是通用做法。Canvas的DOMMatrix对象提供了一个方法:DOMMatrix.invertSelf(),可以求逆矩阵。
javascript复制const currentMatrix = ctx.getTransform();
const inverted = currentMatrix.invertSelf(); // 注意这会改变 currentMatrix 自身
const point = new DOMPoint(mouseX, mouseY);
const localPoint = point.matrixTransform(inverted);
// localPoint.x, localPoint.y 就是鼠标在图形局部坐标系中的坐标
9.2 一个拖动旋转图形的实操例子
我来写一个实际可运行的示例:一个方块,你可以拖动它,并且用鼠标滚轮旋转它缩放它。这里直接演示矩阵反解的用法。
html复制<canvas id="canvas" width="600" height="400"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let rect = { cx: 200, cy: 150, width: 120, height: 80, angle: 0, scale: 1 };
let dragging = false;
let offsetX = 0, offsetY = 0;
function draw() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制网格辅助线
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
for (let i = 0; i < canvas.width; i += 50) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, canvas.height);
ctx.stroke();
}
for (let j = 0; j < canvas.height; j += 50) {
ctx.beginPath();
ctx.moveTo(0, j);
ctx.lineTo(canvas.width, j);
ctx.stroke();
}
// 绘制矩形
ctx.save();
ctx.translate(rect.cx, rect.cy);
ctx.rotate(rect.angle);
ctx.scale(rect.scale, rect.scale);
ctx.fillStyle = 'rgba(54, 162, 235, 0.3)';
ctx.strokeStyle = '#36a2eb';
ctx.lineWidth = 2;
ctx.fillRect(-rect.width / 2, -rect.height / 2, rect.width, rect.height);
ctx.strokeRect(-rect.width / 2, -rect.height / 2, rect.width, rect.height);
ctx.restore();
}
function getLocalMouse(e) {
const rectBox = canvas.getBoundingClientRect();
const mouseX = e.clientX - rectBox.left;
const mouseY = e.clientY - rectBox.top;
// 获取当前变换矩阵
// 注意:因为我们在绘制时会先 scale(rect.scale) 再 rotate 再 translate
// 但我们需要的是“从屏幕坐标到局部坐标”的逆变换
// 所以这里手工构造矩阵,从左到右依次是: translate(cx, cy) -> rotate(angle) -> scale(scale)
const cos = Math.cos(rect.angle);
const sin = Math.sin(rect.angle);
const a = cos * rect.scale;
const b = sin * rect.scale;
const c = -sin * rect.scale;
const d = cos * rect.scale;
const e = rect.cx;
const f = rect.cy;
const inv = new DOMMatrix([a, b, c, d, e, f]).invertSelf();
const point = new DOMPoint(mouseX, mouseY).matrixTransform(inv);
return point;
}
canvas.addEventListener('mousedown', (e) => {
const local = getLocalMouse(e);
if (
local.x >= -rect.width / 2 &&
local.x <= rect.width / 2 &&
local.y >= -rect.height / 2 &&
local.y <= rect.height / 2
) {
dragging = true;
canvas.style.cursor = 'grabbing';
const rectBox = canvas.getBoundingClientRect();
offsetX = e.clientX - rectBox.left - rect.cx;
offsetY = e.clientY - rectBox.top - rect.cy;
}
});
canvas.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rectBox = canvas.getBoundingClientRect();
rect.cx = e.clientX - rectBox.left - offsetX;
rect.cy = e.clientY - rectBox.top - offsetY;
draw();
});
canvas.addEventListener('mouseup', () => {
dragging = false;
canvas.style.cursor = 'default';
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.95 : 1.05;
const local = getLocalMouse(e);
// 仅在鼠标悬停矩形区域时才缩放
if (
local.x >= -rect.width / 2 &&
local.x <= rect.width / 2 &&
local.y >= -rect.height / 2 &&
local.y <= rect.height / 2
) {
rect.scale *= delta;
draw();
}
}, { passive: false });
draw();
</script>
这里关键是getLocalMouse函数:它手动构造了当前矩形绘制时的变换矩阵,然后求逆,把鼠标坐标反算到矩形的局部坐标,从而判断是否命中矩形。这个能力在图形编辑器、白板应用里几乎是标配。
9.3 矩阵反解的局限与替代方案
上面代码里,我们手动构造了矩阵,因为ctx.getTransform()在当前时刻可能不是矩形的变换矩阵——绘制矩形时用save/restore包裹,并没有把变换状态留存下来。另一种方式是把变化后的矩阵保存到一个变量里,比如在draw()里复制一份:
javascript复制const currentMatrix = ctx.getTransform();
currentMatrix.translate(rect.cx, rect.cy);
currentMatrix.rotate(rect.angle);
currentMatrix.scale(rect.scale, rect.scale);
然后在命中检测时对这个矩阵求逆。两个方案等价。选择哪个看你的代码组织习惯。
10. 从坐标系变换到Canvas项目架构的一点建议
经过前面这些实战,你应该已经感觉到,坐标系变换并不是一个孤立的知识点,它和Canvas应用的方方面面都纠缠在一起:动画循环、DPR适配、鼠标交互、图形命中检测、嵌套UI组件的绘制。如果你还在用“能用就行”的心态写Canvas,每天靠运气调试形状位置,我强烈建议你从现在开始建立一套自己的坐标系管理规范。
我的个人规范是这几条,分享给你们参考:
- 全局只初始化一次DPR变换。不要在每次绘制时重复判断
devicePixelRatio。 - 每个有变换的绘制函数,都以
save()开始、restore()结束。如果没有restore,就说明代码有问题。 - 涉及鼠标坐标时,先用统一的坐标转换函数把
clientX/clientY换算成画布逻辑坐标,不要在业务代码里到处写getBoundingClientRect。 - 动画帧第一行永远是
setTransform,而且带上DPR。 - 如果要绘制多个独立图形,每个图形用一个对象管理自己的位置、旋转角、缩放值,绘制时统一走
translate -> rotate -> scale -> 绘图这个流程。
这几条规则看起来简单,但在中大型Canvas项目里能省掉无数排查时间。不少团队做绘图应用,几乎所有人都被“图形跑偏”“旋转中心不对”“缩放漂移”这类问题折磨过,最后基本都是靠统一封装解决。
如果你目前用的是原生Canvas,可以考虑封装一个绘图类,把基础变换封装成方法,例如:
javascript复制class CanvasNode {
constructor(ctx, x, y, angle, scaleX, scaleY) {
this.ctx = ctx;
this.x = x;
this.y = y;
this.angle = angle || 0;
this.scaleX = scaleX || 1;
this.scaleY = scaleY || 1;
}
draw(renderFn) {
const ctx = this.ctx;
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.angle);
ctx.scale(this.scaleX, this.scaleY);
renderFn(ctx);
ctx.restore();
}
}
然后任何图形都继承或组合这个类,保证所有变换逻辑都在同一个地方维护。这样即使以后项目膨胀,也能保持逻辑清晰。
11. 最后再分享几个我踩过的坑
关于Canvas坐标系变换,网上的教程不少,但很多都把最重要的细节藏在字里行间。我最后用五个小坑来收尾吧,都是我实际写代码时踩过的:
第一个坑:rotate参数写成度数。Canvas的rotate只接受弧度,但文档看多了有时手滑写成90,结果图形转得乱七八糟还找不到原因。我的习惯是写一个工具函数degToRad(deg),所有角度输入统一走它。
第二个坑:scale用负值可以实现镜像翻转,但缩放中心要小心。比如ctx.scale(-1, 1)会以当前原点为轴翻转。如果你想让图片沿自身中心水平翻转,要先translate(cx, cy)再scale(-1, 1)再drawImage(-img.width/2, ...)。
第三个坑:save/restore只保存变换矩阵和部分状态,不包括已绘制的像素内容。很多人以为save能“撤销上一步绘图”,这是误解。它撤销的是绘制的属性状态,而不是画布上的像素。
第四个坑:在drawImage和arc中直接使用ctx.transform时,注意某些方法内部有自己的定位逻辑。比如arc的圆心坐标是在变换后的坐标系中解释的,所以如果你已经rotate了,arc的起始角度是相对旋转后的X轴来算的。
第五个坑:在用canvas.toDataURL()或生成图片时,变换矩阵不会影响输出尺寸,但会影响绘制内容的位置。如果你导出的图片内容偏移,检查导出的画布是否经过了你预期的变换,以及在实际输出时是否执行了setTransform(1,0,0,1,0,0)。
写到这里,Canvas坐标系变换的核心内容就差不多了。这个东西初看有点绕,但一旦把“工作台变换”这个思维模型建立起来,后面遇到多复杂的绘制场景,都能拆成清晰的层级:平移 → 旋转 → 缩放 → 画图,一层一层叠加,每一层都用save/restore隔离。用多了,你会发现Canvas其实是一个很讲究“条理”的技术,混乱只会让图形混乱,只有秩序才能带来清晰的画面。
