别急着一上来就写代码,先搞明白"平移"这个词在Canvas里到底意味着什么
我第一次做Canvas动画的时候,踩过一个特别典型的坑:画了一个方块,想让它从左往右平滑移动,于是很自然地写了个setInterval,每16毫秒把x坐标加1,然后clearRect重绘。结果跑起来画面一卡一卡的,像老式放映机在抽风。最诡异的是,在办公室的显示器上还算流畅,换上高刷屏反而更卡了。
后来我把问题拆开,才发现"图形平滑移动"这件事,真正难的根本不是"给坐标加1",而是另外三件事:用什么机制驱动动画循环、怎么管理图形的位置状态、以及如何在帧率不稳定的情况下让移动速度保持一致。这篇我一次性把这几个问题全部摊开讲清楚,看完你不仅能写出丝滑的Canvas动画平移,还能顺手避开我在生产环境里踩过的那些深坑。
这篇教程面向的是已经能画出一个矩形、能跑起简单Canvas页面的读者,不需要你有图形学基础,但需要你动手跟着敲代码。我会从坐标系讲起,一直讲到可交互的完整Demo,最后附上排坑清单。整个过程不搞花活,全是实际能跑、能改、能直接用到项目里的东西。
1. 平移的底层原理:你动的到底是图形还是坐标系
1.1 不搞清楚Canvas坐标系,后面全是玄学
Canvas的坐标系跟你在数学课上学到的坐标系不太一样。它的原点(0, 0)在画布左上角,x轴向右增大,y轴向下增大。这一点看起来简单,但实际写动画时,很多人会把方向搞反。
比如你想让一个矩形从左上角往右下角移动,直觉上会写:
javascript复制rect.x += 1;
rect.y += 1;
这个写法没错,但如果你是想让矩形"往屏幕下方走",在Canvas里得让y增加,这在初次接触Canvas的人看来很反直觉。我见过不少新手把y递减,结果图形越走越往上,最后直接消失在画布顶部。
另外还要注意一个细节:Canvas的绘图状态(fillStyle、strokeStyle、lineWidth等)会"记住"上一次的设置。你在一个动画循环里画了多个图形,如果没有显式重置样式,第二个图形可能沿用第一个的颜色。更关键的是,transform类的状态(比如translate、rotate)也会被记住,这一点在下面的平移方式里会重点说到。
1.2 两种"移动"方式:改坐标和变换坐标系
Canvas里让图形平移,本质上只有两种做法。
第一种:直接改绘制坐标。 比如画一个矩形,每次重绘时把fillRect的x、y参数改掉:
javascript复制function drawRectAt(x, y) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4a90d9';
ctx.fillRect(x, y, 80, 80);
}
这是最直观、也最容易理解的方式。每次动画帧里,你算出新的x、y,然后重新绘制整个场景。
第二种:通过translate变换坐标系,然后永远按(0,0)附近的相对坐标绘制。 什么意思呢?就是用ctx.translate(dx, dy)把整个画布的坐标系原点移动一段距离,之后你画的任何图形都会跟着偏移:
javascript复制function drawRectTranslated(x, y) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(x, y); // 坐标系原点移到(x, y)
ctx.fillStyle = '#4a90d9';
ctx.fillRect(0, 0, 80, 80); // 图形永远在原点附近画
ctx.restore();
}
这两种方式在"视觉结果"上几乎一样,但底层逻辑完全不同:第一种是"图形动了",第二种是"画笔的基准点动了"。对于平移这个需求,我强烈建议用第一种,因为它的状态模型最简单——每个图形一个坐标,想怎么改就怎么改,出问题也容易排查。translate更适合用在复杂的复合变换场景,比如你要让一个角色围绕自己的中心点旋转,这时候先translate到角色中心再rotate,会方便得多。
提示:如果你用的是
translate方式,务必配合save()和restore()使用,否则平移会不断累加。这个问题我后面专门写一节。
1.3 save/restore:防止变换状态污染下一个图形
ctx.save()会把当前所有绘图状态压入一个栈,ctx.restore()则把栈顶的状态弹出来恢复。简单理解:save()就是存档,restore()就是读档。
在多图形场景里,这个机制极其重要。假设你画了两个矩形,第二个用了translate,如果不做保存恢复:
javascript复制ctx.translate(50, 50);
ctx.fillRect(0, 0, 80, 80);
// 第二次平移会叠加,实际变成了translate(100, 100)
ctx.translate(50, 50);
ctx.fillRect(0, 0, 80, 80);
第二次translate是在第一次基础上累加的,图形会跑到(100,100)的位置,而不是你以为的(50,50)。如果循环动画里每次都这样叠加,图形会以越来越快的速度"逃出"画布,最后变成一个黑屏——这个错我犯过不止一次。
解决方案就是成对使用:
javascript复制ctx.save();
ctx.translate(x, y);
// ...绘制
ctx.restore();
记住一个原则:save()和restore()要么不出现,要出现就必须成对。养成这个习惯,可以帮你避免Canvas开发里最大的一类"灵异事件"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动画循环:requestAnimationFrame才是平滑的基石
2.1 为什么setInterval做的动画总是一跳一跳
很多人刚开始做动画时,第一反应是用setInterval,因为直觉上"每16毫秒执行一次,就相当于60帧了"。
但问题在于,setInterval做动画有四个致命弱点:
第一,它不跟浏览器的渲染节奏对齐。浏览器渲染页面有自己的节奏(多数是60Hz,高刷屏120Hz),setInterval的回调可能在一个渲染周期里执行了两次,或者一次都没执行,造成画面不均匀。
第二,后台标签页不会暂停。你切到别的标签页,setInterval照样跑,白白消耗CPU。如果动画逻辑较重,还能把你的风扇跑响。
第三,回调的间隔不稳定。setInterval只能保证"至少间隔这么多时间",实际执行时间取决于主线程的繁忙程度。一旦主线程卡顿,动画就会瞬间跳一大截。
第四,没有时间参数。你很难判断"距离上一帧过了多久",而这恰恰是做匀速运动最需要的。
2.2 requestAnimationFrame的工作方式与帧率自适应
requestAnimationFrame(后面简称rAF)就是为动画而生的接口。它告诉浏览器:"你准备要重绘屏幕的时候,先调用一下我这个回调函数。"
它的核心有两个优势:
- 自动对齐屏幕刷新率:60Hz屏幕每16.7ms调用一次,120Hz屏幕每8.3ms调用一次。不管多高刷,它都能匹配上。
- 后台标签页自动暂停:切到后台时,浏览器不再调用回调,节省资源。
它用起来很简单:
javascript复制function animate() {
// 每帧要做的更新、重绘
draw();
// 告诉浏览器下一帧继续调用我
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
注意,rAF回调本身接收一个DOMHighResTimeStamp参数,这个参数表示当前帧从页面加载开始经过的时间(单位毫秒)。这个参数用来做速度计算非常方便,后面马上会用到。
2.3 基于时间戳的移动:不同设备上速度一致的关键
很多人写了rAF之后,习惯在函数里写死每帧移动多少像素:
javascript复制function animate() {
rect.x += 3; // 每帧移动3像素
draw();
requestAnimationFrame(animate);
}
这在60Hz屏幕上,每秒大约移动180像素。但如果用户用的是120Hz高刷屏,回调频率翻倍,每帧还是移动3像素,每秒就变成了360像素——图形直接以两倍速度飞奔。同一个人代码在不同设备上表现完全不一致,这就是没有基于时间计算速度的后果。
正确的做法是用时间戳计算时间差deltaTime(简称dt),然后速度乘以dt:
javascript复制let lastTime = 0;
const speed = 180; // 每秒移动180像素
function animate(timestamp) {
// 计算与上一帧的时间差,单位秒
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// 不管帧率多少,每秒移动的距离都是 speed 像素
rect.x += speed * dt;
draw();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
dt的单位是秒,因为timestamp是毫秒,除以1000换算成秒。这样写之后,60Hz和120Hz屏幕上,图形每秒移动的距离完全一样,只是后者每帧移动的像素数更少、看起来更细腻。
提示:需要注意
lastTime的初始值。如果第一次回调时lastTime是0,算出来的dt会非常大,图形会瞬间"瞬移"。可以在函数开头判断:if (lastTime === 0) { lastTime = timestamp; }。
3. 状态管理:图形平移的核心数据结构和增量算法
3.1 用对象管理图形:位置、速度、目标点
搞定了动画循环,下一步就是怎么组织数据。我建议每个可移动的图形都用一个对象来管理,集中保存它的位置、速度、尺寸等属性:
javascript复制const rect = {
x: 100, // 当前x坐标
y: 100, // 当前y坐标
vx: 0, // 水平方向速度(像素/秒)
vy: 0, // 垂直方向速度(像素/秒)
width: 80,
height: 80,
speed: 200 // 最大移动速度(像素/秒)
};
为什么要用对象而不是分散的变量?因为当一个场景里有多个图形时,对象可以让你用一个数组统一管理:
javascript复制const shapes = [];
function createRect(x, y, w, h, color) {
return {
x, y, width: w, height: h, color,
vx: 0, vy: 0, speed: 200
};
}
shapes.push(createRect(100, 100, 80, 80, '#4a90d9'));
shapes.push(createRect(300, 200, 60, 60, '#e67e22'));
以后在update里遍历数组,每个图形各算各的位置,互不干扰。这也为后续做碰撞检测、拖拽等交互打好了基础。
3.2 固定步长与时间步长:两种增量更新的取舍
更新位置有两种常见策略:固定步长和时间步长。
固定步长:每帧移动固定像素(rect.x += 3)。优点是逻辑简单,缺点是帧率越高速度越快,前面已经说过了。固定步长一般只适合"速度无所谓的演示动画"。
时间步长:每帧位移 = 速度 × 时间差(rect.x += speed * dt)。优点是速度稳定,缺点是当dt大于一定阈值时(比如页面卡顿后恢复),图形会出现一次大跨步,可能穿过障碍物或直接飞出边界。
在实际项目里,我通常采用"限制时间步长"的方式,把单帧时间差限制在一个上限之内,比如dt = Math.min(dt, 1/30)。意思是:即使页面卡了半秒,也不要让这一帧移动超过"正常情况下1/30秒"的距离,避免瞬移问题,又保持了大部分情况下的速度一致性。
综合来看,实现"图形平滑移动"的增量更新逻辑就是这样:
javascript复制function update(dt) {
const maxDt = Math.min(dt, 1 / 30); // 限制最大步长
rect.x += rect.vx * maxDt;
rect.y += rect.vy * maxDt;
}
3.3 键盘驱动平移实战:按下、松开与方向控制
要让用户能用键盘控制图形移动,核心是监听keydown和keyup事件,实时更新方向输入的状态,然后在每帧update里根据输入状态计算速度向量。
javascript复制const keys = {};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
// 防止按下方向键时页面跟着滚动
if (e.code.startsWith('Arrow')) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
然后在update里根据按下的键设置速度:
javascript复制function update(dt) {
// 先重置水平、垂直速度
rect.vx = 0;
rect.vy = 0;
if (keys['ArrowLeft'] || keys['KeyA']) rect.vx = -rect.speed;
if (keys['ArrowRight'] || keys['KeyD']) rect.vx = rect.speed;
if (keys['ArrowUp'] || keys['KeyW']) rect.vy = -rect.speed;
if (keys['ArrowDown'] || keys['KeyS']) rect.vy = rect.speed;
// 斜向移动时,如果两轴叠加,速度会变成约1.414倍
// 需要归一化,保持总速度恒定
if (rect.vx !== 0 && rect.vy !== 0) {
const factor = Math.SQRT1_2; // 约0.707
rect.vx *= factor;
rect.vy *= factor;
}
const maxDt = Math.min(dt, 1 / 30);
rect.x += rect.vx * maxDt;
rect.y += rect.vy * maxDt;
}
这里有个很多人忽略的细节:斜向移动速度归一化。如果你同时按右和下,vx和vy都是speed,合成的速度向量长度是√(speed² + speed²) ≈ 1.414 × speed,图形会斜着跑得比直着快。乘以Math.SQRT1_2(即1/√2 ≈ 0.707)之后,总速度就能保持恒定为speed。
4. 从能走到丝滑:缓动、插值与边界处理实战
4.1 线性移动为什么显得机械
如果你要做一个"物体匀速直线运动",线性移动完全够用。但很多交互场景里,你要的是一个图形从点A"飞向"点B,这时候线性匀速反而显得很生硬——因为现实中几乎没有物体从静止瞬间加速到高速,又瞬间停下来。缺少了加速度和减速度的过程,人的眼睛会觉得"这不是移动,这是被拽过去的"。
这种情况就需要引入缓动(easing)。
4.2 easing函数实操:easeOut让移动更有质感
缓动的核心思想是:在一段时间内,让物体从0%走到100%,但不是匀速走,而是按一条曲线走。
最常用的是easeOut:开始时速度快,接近目标时逐渐减速,像车进站滑行一样。下面是一版经典的easeOutCubic:
javascript复制function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
参数t是一个从0到1的归一化时间。比如你的动画时长是500ms,某一帧距离开始已经过了250ms,那t = 0.5,easeOutCubic(0.5) ≈ 0.875,表示此时物体已经走完了87.5%的路程。剩下的12.5%路程要在后半段时间内慢慢走完,于是就有了"开始时快、接近终点时慢"的视觉效果。
完整的用法是这样:
javascript复制const startX = 0;
const targetX = 600;
const duration = 0.8; // 单位:秒
let elapsed = 0;
function update(dt) {
elapsed += dt;
const t = Math.min(elapsed / duration, 1); // 归一化时间,不能超过1
const eased = easeOutCubic(t);
rect.x = startX + (targetX - startX) * eased;
}
这里注意一点:缓动函数处理的理念是"当前位置 = 起点 + 总位移 × 缓动比例",而不是在当前位置上继续增量加。如果你写成rect.x += (targetX - rect.x) * 0.1这样的式子,那其实是另一种处理——指数趋近/lerp插值,下一小节展开。
4.3 鼠标跟随与lerp插值:经典的比例趋近算法
鼠标跟随是很常见的交互需求:图形平滑地追着鼠标移动,但又有一种"惯性拖尾"的质感,不会瞬间跳到鼠标位置。
实现方式通常是用线性插值(lerp)或者叫比例趋近:
javascript复制let currentX = 200;
let currentY = 200;
let targetX = 400;
let targetY = 300;
// 每帧更新
function update(dt) {
// lerp系数,0~1之间,数值越大跟随越快
const lerpFactor = 1 - Math.pow(0.001, dt);
currentX += (targetX - currentX) * lerpFactor;
currentY += (targetY - currentY) * lerpFactor;
}
这里的lerpFactor是个很有意思的设计。如果你直接写currentX += (targetX - currentX) * 0.1,那这个0.1跟帧率耦合:60帧下图形追得很快,30帧下追得很慢。更严谨的做法是用1 - Math.pow(base, dt)来让lerp系数跟时间无关。上面代码中base=0.001,当dt=1/60时,lerpFactor ≈ 0.107,正好接近0.1这个经验值,而且不管帧率怎么变,视觉上的"追随感"是基本一致的。
鼠标监听部分:
javascript复制canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
targetX = e.clientX - rect.left;
targetY = e.clientY - rect.top;
});
跟纯easing相比,lerp的优势在于不需要记录起点和持续时间:它永远在追赶目标,目标如果一直移动,它就一直平滑追随;目标停下,它就逐渐逼近。这种写法非常省心,适合做"跟随类"交互。
4.4 边界检测:不让图形跑出画布
图形平移时最常见的边界问题有两个:一是图形完全飞出画布,找不回来了;二是图形卡在半边,一部分露在画布外。处理方式取决于你的需求。
如果希望图形完全禁止越界,可以做钳制(clamping):
javascript复制function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
rect.x = clamp(rect.x, 0, canvas.width - rect.width);
rect.y = clamp(rect.y, 0, canvas.height - rect.height);
如果希望图形到达边界后能自动回弹,就需要检测碰撞并翻转速度方向。一个简化版的实现:
javascript复制function checkBounds(shape) {
if (shape.x <= 0 || shape.x + shape.width >= canvas.width) {
shape.vx = -shape.vx;
}
if (shape.y <= 0 || shape.y + shape.height >= canvas.height) {
shape.vy = -shape.vy;
}
shape.x += shape.vx * maxDt;
shape.y += shape.vy * maxDt;
}
注意:回弹逻辑要在位置更新之前检查,或者检查并翻转向量后再更新位置,不然图形会卡在边界外一帧,出现抖动。
5. 完整可运行Demo:键盘+鼠标控制的图形平移
5.1 HTML页面结构与Canvas初始化
代码看再多,不如跑一个完整的例子。下面是一个把前面所有知识点整合起来的Demo:一个蓝色矩形用方向键/WASD控制移动,一个橙色圆形平滑追随鼠标。页面是完整可运行的。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 动画平移 Demo</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #1e1e2e;
font-family: monospace;
}
canvas {
background: #f5f5f5;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.tips {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: #cdd6f4;
background: rgba(30, 30, 46, 0.8);
padding: 8px 16px;
border-radius: 999px;
font-size: 14px;
}
</style>
</head>
<body>
<canvas id="stage" width="800" height="600"></canvas>
<div class="tips">方向键/WASD 移动方块,鼠标控制圆形跟随</div>
<script>
// 初始化 Canvas
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
// ====== 图形状态 ======
const rect = {
x: 100, y: 100,
width: 80, height: 80,
vx: 0, vy: 0,
speed: 200,
color: '#4a90d9'
};
const orb = {
x: 400, y: 300,
targetX: 400, targetY: 300,
radius: 24,
color: '#e67e22'
};
// ====== 键盘状态 ======
const keys = {};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code.startsWith('Arrow')) e.preventDefault();
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
// ====== 鼠标状态 ======
canvas.addEventListener('mousemove', (e) => {
const bound = canvas.getBoundingClientRect();
orb.targetX = e.clientX - bound.left;
orb.targetY = e.clientY - bound.top;
});
// ====== 工具函数 ======
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
// ====== 更新逻辑 ======
function update(dt) {
const maxDt = Math.min(dt, 1 / 30);
// 方块:键盘控制
rect.vx = 0;
rect.vy = 0;
if (keys['ArrowLeft'] || keys['KeyA']) rect.vx = -rect.speed;
if (keys['ArrowRight'] || keys['KeyD']) rect.vx = rect.speed;
if (keys['ArrowUp'] || keys['KeyW']) rect.vy = -rect.speed;
if (keys['ArrowDown'] || keys['KeyS']) rect.vy = rect.speed;
// 斜向移动归一化
if (rect.vx !== 0 && rect.vy !== 0) {
rect.vx *= Math.SQRT1_2;
rect.vy *= Math.SQRT1_2;
}
rect.x += rect.vx * maxDt;
rect.y += rect.vy * maxDt;
// 方块边界钳制
rect.x = clamp(rect.x, 0, canvas.width - rect.width);
rect.y = clamp(rect.y, 0, canvas.height - rect.height);
// 圆形:lerp跟随鼠标
const lerpFactor = 1 - Math.pow(0.001, dt);
orb.x += (orb.targetX - orb.x) * lerpFactor;
orb.y += (orb.targetY - orb.y) * lerpFactor;
}
// ====== 绘制逻辑 ======
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画方块
ctx.fillStyle = rect.color;
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
// 画圆形
ctx.beginPath();
ctx.arc(orb.x, orb.y, orb.radius, 0, Math.PI * 2);
ctx.fillStyle = orb.color;
ctx.fill();
}
// ====== 动画主循环 ======
let lastTime = 0;
function animate(timestamp) {
if (lastTime === 0) lastTime = timestamp;
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
update(dt);
draw();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>
</body>
</html>
5.2 完整代码逐段拆解
这个Demo的信息流很清晰:事件输入 → 更新状态 → 绘制场景 → 循环。四步各司其职。
- 事件输入:键盘事件统一记录到
keys对象,不会因为按住某个键而反复触发复杂的逻辑;鼠标事件只更新一个目标坐标。 - 更新状态:
update函数接收dt,先算方块的速度向量,再做边界钳制;圆形则直接用lerp趋近目标点。 - 绘制场景:
draw函数只用当前状态来画,不做任何计算。时刻保持"绘制无副作用"的习惯,后面Debug会轻松很多。 - 循环:
animate用rAF驱动,计算dt后依次调用update和draw。
我特别想强调"绘制无副作用"这个原则。如果你拿到底层状态,每帧重画一遍,画布的状态是确定的。反之,如果你在draw里顺便改坐标、改颜色,画面就会越来越乱,而且特别难排查。
5.3 扩展思路:多个图形、锚点工具、离屏Canvas
拿到上面的Demo之后,你可以往这些方向扩展:
多图形管理:把rect和orb都放进一个shapes数组,遍历调用update和draw。每个图形对象里加一个type字段区分绘制方式,用switch或if分别画矩形和圆形。这样你就拥有了一个极简的"Canvas实体系统"。
线段锚点工具:最近在一些绘图类项目里常见到"线段锚点"的需求。实现思路是在平移的基础上增加"选中"和"拖拽"两个交互状态:鼠标按下去时检测命中(用ctx.isPointInPath或简单的矩形碰撞检测),命中的锚点进入"拖拽中"状态,然后鼠标移动时更新锚点坐标,每帧重绘所有线段和锚点。这里用到的状态管理思路跟上面的rect对象完全一致,只是额外多了交互状态字段。
离屏Canvas:如果你有大量静态内容需要每次重绘(比如网格背景),可以先用一个离屏Canvas画好,之后在动画循环里用drawImage(offCanvas, 0, 0)一次性贴到主画布上,能显著降低每帧的绘制开销。做法很简单:
javascript复制// 创建离屏Canvas
const offCanvas = document.createElement('canvas');
offCanvas.width = canvas.width;
offCanvas.height = canvas.height;
const offCtx = offCanvas.getContext('2d');
// 在离屏画布上画好静态背景
offCtx.fillStyle = '#eee';
offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height);
// ...画网格、画装饰
// 动画循环里直接贴图
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(offCanvas, 0, 0); // 背景一次性绘入
// 再画动态图形
}
关于"js获取canvas内容赋值给另一个canvas"这个需求,本质也跟离屏Canvas同源:你完全可以用targetCtx.drawImage(sourceCanvas, 0, 0)把一个Canvas的内容原样画到另一个Canvas上。不需要读取像素数组,不需要逐点复制,浏览器底层帮你做了这件事。
6. 实机踩坑记录:高清屏模糊、拖尾残影与状态错乱
6.1 devicePixelRatio:高清屏下Canvas模糊和平移错位
这是Canvas开发里出现频率最高的问题。在Retina屏上,CSS里定义的canvas大概是800x600,但物理像素其实是1600x1200。如果你不处理,Canvas默认按CSS像素渲染,画出来会模糊。
更麻烦的是,一旦你设置了canvas.width为物理像素,又忘了调整坐标系,图形的坐标就得用物理像素来算,导致"平移速度看起来变快了"或者"位置对不上"。
我的标准处理方案是在初始化时做适配:
javascript复制function setupCanvas(canvas, maxWidth, maxHeight) {
const dpr = window.devicePixelRatio || 1;
// CSS尺寸保持逻辑像素
canvas.style.width = maxWidth + 'px';
canvas.style.height = maxHeight + 'px';
// 画布实际像素 = 逻辑像素 × dpr
canvas.width = maxWidth * dpr;
canvas.height = maxHeight * dpr;
const ctx = canvas.getContext('2d');
// 坐标系缩放,之后所有绘制都按逻辑像素来
ctx.scale(dpr, dpr);
return ctx;
}
之后你写代码时完全不用关心物理像素,坐标、速度、尺寸全用逻辑像素,跟原来保持一致。这个方案我实测下来是最省心的。
6.2 clearRect范围不对:残影是怎么产生的
"图形拖着长长的尾巴"是很多Canvas新手都会遇到的问题。原因只有一个:clearRect没把画布清干净。比如你只清了图形当前位置的一小块,上一帧的图形就残留下来了。
我自己就干过这事:为了"性能优化",只clear图形的新旧位置区域,结果边界条件没考虑周全,图形一加速就满屏残影。
最稳妥的做法就是全量清除:
javascript复制ctx.clearRect(0, 0, canvas.width, canvas.height);
如果担心性能,可以先用canvas.width = canvas.width这种重置大小的方法强制清空,但注意这会把绘图状态(fillStyle等)也重置掉,需要重新设置样式。实测下来,直接clearRect全画布在现代浏览器上性能完全够用,绝大多数项目不会因为这一帧全量清除而产生性能瓶颈。
6.3 save/restore不配对:变换叠加导致的位置漂移
前面讲过,save和restore必须成对出现。但这里还有个隐蔽的坑:如果在save之前已经对坐标系做了translate,那restore恢复的是save时的状态,而不是"初始状态"。
举例:
javascript复制ctx.translate(50, 50);
ctx.save(); // 保存了"已平移50"的状态
ctx.translate(100, 0); // 再平移100
ctx.fillRect(0, 0, 50, 50); // 画在(50+100, 50)处
ctx.restore(); // 回到"已平移50"的状态
// 后续如果不继续restore,且直接绘制,图形会出现在(50,50)偏移的位置
所以如果你发现"图形位置对不上预期,但代码逻辑看起来没错",优先检查是否有多余的translate没有抵消,或者save/restore层级是否错乱。我排查类似问题的方式是:在怀疑有变换叠加的地方临时加一个ctx.setTransform(1, 0, 0, 1, 0, 0),把坐标系强制重置,如果画面立刻正常,那就是变换状态没清理干净。
6.4 踩坑后总结的排查清单
根据我的经验,Canvas动画平移如果出问题,按这个顺序排查能省大量时间:
- 看动画循环:确认用的是
requestAnimationFrame而不是setInterval。 - 看时间步长:确认用的是
dt而非固定步长;确认dt初始值没有被塞进一个巨大数值。 - 看更新逻辑:确认坐标是"基于上一帧的位置增量修改",而不是"每帧重置到一个常数值"。
- 看绘制逻辑:确认
clearRect覆盖全部画布、绘制只用当前状态、没有在draw里顺手改数据。 - 看变换状态:搜索
translate、save、restore,确认它们成对出现,层级正确。 - 看高清屏适配:确认
dpr设置和坐标映射逻辑。
这几条几乎覆盖了我在实际项目里遇到过的所有Canvas平移类问题。每一条都对应着具体可操作的检测手段,而不是"仔细检查代码"这种空话。
最后说一点个人体会:Canvas动画的"平滑感"并不仅仅来自代码写得巧妙,更多来自你对每一帧发生的事情心里有数。你清楚地知道这一帧图形在哪里、速度是多少、下一步要去哪,画面自然就稳定。反过来,如果对状态模型含含糊糊,那不管怎么调参数,画面都会在某个角落悄悄背叛你。希望这篇教程能帮你把Canvas动画平移这件事彻底理顺。
