
1. 从“转圈”说起为什么一个旋转的圆环值得单独拿出来讲很多人第一次接触 Canvas 动画都是从画一个圆开始的。画圆简单arc()一调用就出来了。但当你让它转起来问题就来了——有人转得丝滑有人转得一顿一顿的还有人转着转着圆心跑偏了。我见过不少初学者写的旋转动画代码逻辑看起来没问题但实际跑起来就是不对劲。问题往往不在“旋转”这个动作本身而在于对 Canvas 坐标系、变换顺序和帧循环的理解有偏差。这篇文章要聊的就是这个看似简单、实则暗藏细节的话题用 Canvas 实现一个旋转的圈。别看它只是“转圈”里面涉及的知识点包括坐标系变换、requestAnimationFrame的节奏控制、角度与弧度的换算、变换状态的保存与恢复、以及不同旋转方案的性能差异。这些内容搞明白了你后面做更复杂的 2D 动画——比如旋转的加载指示器、环绕运动的粒子、齿轮联动效果——都会顺很多。这篇文章适合谁看如果你已经会基本的 Canvas 绘图 API但做动画时总觉得“差那么点意思”那这篇就是写给你的。如果你完全没碰过 Canvas也没关系我会把关键概念用生活化的方式讲清楚你跟着代码走一遍就能上手。整篇内容围绕一个核心目标展开让你不仅能让圈转起来还能转得稳、转得对、转得好看。2. 旋转的圈到底在转什么坐标系与变换的本质2.1 Canvas 的坐标系和“旋转”的真实含义在动手写代码之前有必要先把一件事说透Canvas 里的“旋转”到底旋转的是什么很多人的直觉是“旋转这个圆”但更准确的说法是——旋转的是画布坐标系本身。Canvas 的默认坐标系是这样的原点(0, 0)在左上角x 轴向右为正y 轴向下为正。当你调用ctx.rotate(angle)时你并不是在旋转某个图形而是在旋转整个坐标系。之后所有绘制操作都会在这个被旋转过的坐标系里进行。打个比方你在桌上铺了一张纸纸上有网格线。你在纸上画了一个圆。现在你把整张纸转了一个角度圆跟着转了但纸上的网格线也转了。Canvas 的rotate()就是这个“转纸”的动作。理解这一点非常关键因为它直接决定了你后面画圆时圆心该定在哪里、旋转中心对不对。2.2 旋转中心为什么总是跑到左上角初学者最常踩的坑就是这个写了一段旋转代码结果圆不是绕自己的中心转而是绕着画布左上角甩出去了。原因很简单——rotate()默认绕原点(0, 0)旋转而原点在左上角。解决办法是“先平移再旋转”。用ctx.translate(x, y)把坐标系原点搬到你想绕的那个点上然后再rotate()这样旋转中心就变成了你平移后的那个点。这个顺序不能反先旋转再平移效果完全不一样。我用一个具体例子说明。假设你想让一个圆心在(200, 200)、半径 80 的圆绕自己的圆心旋转。正确的做法是ctx.save(); ctx.translate(200, 200); // 把原点搬到圆心 ctx.rotate(angle); // 绕新原点旋转 ctx.beginPath(); ctx.arc(0, 0, 80, 0, Math.PI * 2); // 在新原点画圆 ctx.stroke(); ctx.restore();注意arc的圆心写的是(0, 0)因为坐标系已经被平移过了新原点就是原来的(200, 200)。如果你这里还写(200, 200)圆就会跑到(400, 400)去。2.3 save 和 restore变换状态的“存档”与“读档”上面代码里的save()和restore()不是可有可无的装饰。Canvas 的变换是累积的——你 translate 一次、rotate 一次这些变换会一直叠加下去。如果不做状态管理第二帧再 translate 一次原点就越跑越远画面直接失控。save()的作用是把当前所有变换状态压入一个栈restore()则是弹出栈顶状态并恢复。你可以把它理解成游戏里的“存档”和“读档”进副本前存个档打完出来读档角色位置和状态都回到进副本前。每一帧动画开始时 save结束时 restore就能保证每帧的变换都是干净的、从同一个基准出发的。提示save()和restore()必须成对出现。我见过有人 save 了忘记 restore跑几百帧之后坐标系已经扭曲得不成样子排查半天才发现是这里的问题。3. 让圈真正转起来帧循环与角度推进的几种写法3.1 requestAnimationFrame 为什么比 setInterval 更合适让动画动起来核心是“每隔一小段时间重绘一次”。早期很多人用setInterval或setTimeout来做比如每 16 毫秒画一帧。但这种方式有个硬伤它和浏览器的渲染节奏是脱节的。浏览器通常以 60Hz 的频率刷新屏幕也就是大约每 16.67 毫秒一帧。你用setInterval(fn, 16)回调的触发时机和屏幕刷新不同步就容易出现丢帧或撕裂看起来就是“一顿一顿”的。requestAnimationFrame简称 rAF解决的就是这个问题。它告诉浏览器“我要在下次重绘之前执行一段代码。”浏览器会把你的回调安排在下一次屏幕刷新时执行天然和刷新节奏对齐。而且当页面切到后台时rAF 会自动暂停省电又省资源。用法很简单function animate() { // 更新角度、清屏、绘制 requestAnimationFrame(animate); } requestAnimationFrame(animate);注意 rAF 只执行一次所以要在回调里再次调用自己形成循环。这个“自己调自己”的模式是 Canvas 动画的标准骨架。3.2 基于帧数累加和基于时间戳的两种角度推进方式角度怎么随时间增加有两种常见写法。第一种是固定步长累加每帧给角度加一个固定值比如angle 0.02。写法简单但有个问题——不同设备的刷新率不一样。60Hz 屏幕上每秒转 60 帧120Hz 屏幕上每秒转 120 帧同样的0.02步长在高刷屏上转速直接翻倍。这在手机和显示器混用的场景下会很明显。第二种是基于时间戳计算用 rAF 回调收到的timestamp参数算出距离上一帧过了多少毫秒再根据“每秒转多少弧度”来推进角度。这样无论刷新率是多少转速都是一致的。let lastTime 0; const speed Math.PI; // 每秒转半圈 function animate(timestamp) { if (!lastTime) lastTime timestamp; const delta (timestamp - lastTime) / 1000; // 转成秒 lastTime timestamp; angle speed * delta; // 绘制... requestAnimationFrame(animate); }实测下来如果你只在自己电脑上跑固定步长够用但只要涉及多设备、或者对转速有精确要求就老老实实用时间戳方案。这是我在做加载动画时踩过的坑——本地看着好好的到别人高刷屏上转得飞快被反馈说“太晃眼”。3.3 清屏的三种方式与各自的适用场景每帧重绘前必须清掉上一帧的内容否则会留下拖影。清屏有三种常见做法ctx.clearRect(0, 0, width, height)把指定矩形区域清成透明。适合需要保留背景图或下层内容的场景。ctx.fillRect(0, 0, width, height)用当前填充色覆盖整个画布。适合纯色背景性能通常比 clearRect 略好。直接设置canvas.width canvas.width重置整个画布会清空所有状态。不推荐因为它会重置变换、样式等一切副作用太大。我一般用clearRect因为它最“干净”不会影响画布上可能存在的其他图层。如果你确定背景是纯色且不需要透明用fillRect也完全没问题。4. 从“能转”到“转得好看”视觉细节与常见效果4.1 用圆弧而非整圆做出“缺口”旋转效果一个完整的圆转起来其实看不太出它在转——因为圆是旋转对称的。真正能体现旋转感的是带缺口的圆弧比如常见的加载指示器一个圆环留出一段空白转起来就很明显。做法是用arc的起始角和结束角控制缺口const startAngle angle; const endAngle angle Math.PI * 1.5; // 留出四分之一缺口 ctx.beginPath(); ctx.arc(0, 0, 80, startAngle, endAngle); ctx.stroke();这样圆弧的起点随angle变化缺口就跟着转起来了。如果你想让缺口大小也动态变化比如呼吸效果可以让endAngle和startAngle的差值也随时间波动。4.2 线宽、端点样式与颜色渐变对旋转观感的影响同样是转圈细节调好了质感完全不一样。几个关键属性ctx.lineWidth线宽太细显得单薄太粗又笨重。加载指示器一般用 4 到 8 像素比较舒服。ctx.lineCap默认是butt平头改成round圆头后圆弧两端会变圆润视觉上更柔和。这个小改动对观感提升很明显。ctx.strokeStyle纯色最稳但用createLinearGradient或createConicGradient做渐变会让旋转更有层次。注意createConicGradient的兼容性老浏览器可能不支持需要做降级。我个人的习惯是线宽取半径的十分之一左右端点用 round颜色用同色系深浅渐变。这套组合做出来的加载圈放在任何界面里都不违和。4.3 缓动让旋转不那么“机械”匀速旋转看久了会显得呆板。加一点缓动让转速有快有慢观感会生动很多。最简单的做法是用正弦函数调制角速度const baseSpeed Math.PI; const speed baseSpeed * (1 0.5 * Math.sin(timestamp / 500)); angle speed * delta;这样转速会在 0.5 倍到 1.5 倍之间波动形成一种“呼吸式”的旋转。注意缓动幅度别太大否则会显得忽快忽慢、不够专业。做加载动画时缓动是加分项做精确的机械模拟时就老老实实匀速。5. 性能与兼容旋转动画跑得稳的几个关键点5.1 每帧重绘的开销来自哪里Canvas 动画的性能瓶颈通常不在“画”这个动作而在“状态切换”和“路径构建”。每帧都重新beginPath、arc、stroke这些操作本身开销不大但如果画布尺寸很大、或者一帧里画了几百个旋转元素累积起来就很可观。优化的思路有几个一是减少不必要的状态切换比如strokeStyle和lineWidth如果不变就不要每帧重设二是对于大量重复元素考虑用离屏 Canvas 预渲染然后每帧只做drawImage加旋转三是控制画布分辨率别用 4K 尺寸的画布去画一个小圈。5.2 高刷新率屏幕下的适配问题前面提过固定步长在高刷屏上会加速。除了用时间戳方案还有一个办法是给角度推进设一个上限防止 delta 过大导致跳帧。比如页面从后台切回来时timestamp的差值可能有好几秒如果不做限制角度会瞬间跳一大截。const delta Math.min((timestamp - lastTime) / 1000, 0.1);把 delta 限制在 0.1 秒以内就能避免切页面回来时的“瞬移”。这个细节很多人不注意但在实际项目里很影响体验。5.3 设备像素比与画布模糊问题在高分屏比如 Retina 屏上如果画布的 CSS 尺寸和实际像素尺寸不一致图形会发虚。解决办法是按devicePixelRatio放大画布的实际像素再用 CSS 把它缩回原尺寸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);这样画出来的圆边缘才锐利。不做这一步的话旋转的圈会有一圈模糊的毛边尤其在细线宽时特别明显。6. 几个容易翻车的细节和我踩过的坑6.1 角度单位混用弧度和角度别搞混Canvas 的rotate和arc用的都是弧度不是角度。一圈是2 * Math.PI弧度也就是约 6.283。我见过有人写ctx.rotate(90)想转 90 度结果转了 90 弧度相当于转了十几圈画面直接乱套。换算关系记住这个就行弧度 角度 * Math.PI / 180。如果你习惯用角度思考可以写个辅助函数toRadians(deg)每次传角度进去内部转弧度。这样代码可读性好也不容易出错。6.2 变换顺序写反导致圆心偏移前面强调过“先平移再旋转”但实际写代码时还是容易手滑。我自己的经验是把变换操作按“从外到内”的顺序写——先 translate 定位再 rotate 转向最后 scale 缩放。这个顺序符合大多数人的空间直觉也最不容易出错。如果你发现圆心位置不对第一件事就是检查 translate 和 rotate 的顺序以及 arc 的圆心参数是不是写成了平移后的坐标。6.3 忘记 restore 导致的状态污染这个坑我在做多元素动画时踩过。画布上同时有好几个旋转的圈每个圈都有自己的平移和旋转。如果第一个圈画完没 restore第二个圈的变换就建立在第一个圈的基础上位置全乱。养成习惯只要用了 save就立刻把 restore 写上哪怕中间代码还没写完。这样就不会忘。另外save/restore 是可以嵌套的多层嵌套时按栈的顺序恢复用起来很灵活。6.4 动画不停止导致的内存与电量问题rAF 循环一旦启动就会一直跑哪怕你看不见它。如果页面里有多个这样的动画或者动画在不需要时还在跑会白白消耗 CPU 和电量。正确的做法是在组件卸载、页面隐藏、或者动画结束时调用cancelAnimationFrame停掉循环。let rafId; function animate(timestamp) { // ... rafId requestAnimationFrame(animate); } // 需要停止时 cancelAnimationFrame(rafId);配合document.visibilitychange事件在页面切到后台时暂停动画切回来再恢复是更完善的做法。7. 把旋转的圈用起来几个实际场景的改造思路7.1 加载指示器最典型的落地场景旋转的圈最常见的用途就是加载指示器。把前面讲的圆弧缺口、圆头端点、渐变颜色组合起来就是一个像模像样的 loading 组件。如果要做得更精致可以让缺口大小也做周期性变化形成“追逐”效果——一段圆弧追着另一段跑。实现思路是维护两个角度一个控制整体旋转一个控制缺口大小。缺口大小用正弦函数在 0.3 到 1.5 圈之间波动看起来就像在“呼吸”。7.2 环绕运动的粒子旋转作为运动轨迹如果你要做一个粒子绕中心旋转的效果旋转的圈就变成了粒子的运动轨迹。每个粒子在圆周上的位置由角度决定角度随时间推进粒子就转起来了。多个粒子给不同的初始角度就形成了环绕效果。这种场景下旋转中心通常是固定的粒子的半径可以各不相同形成多层环绕。再给每个粒子加一点径向的波动效果会更丰富。7.3 齿轮联动多个旋转圈的相位配合做机械感的动画时经常需要多个圈以不同速度、不同方向旋转形成齿轮咬合的错觉。核心是给每个圈独立的角速度和初始相位然后在一个 rAF 循环里统一更新、统一绘制。这里要注意的是多个元素共用一个 rAF 循环而不是每个元素各起一个循环。共用循环的好处是帧同步所有元素在同一帧里更新不会出现有的快有的慢的错位。8. 写在最后一些个人体会旋转的圈这个效果我从最早用setInterval写到后来用 rAF 加时间戳中间踩的坑基本都在这篇里了。回头看最核心的其实就三件事变换顺序要对、帧循环要稳、状态管理要干净。这三件事做好了不只是转圈任何 2D 动画你都能搭出一个靠谱的骨架。另外分享一个小技巧调试旋转动画时我会在圆心画一个很小的十字标记这样一眼就能看出旋转中心有没有跑偏。等确认没问题了再把标记去掉。这个习惯帮我省了不少排查时间。如果你刚开始学 Canvas 动画建议就从旋转的圈入手把 save/restore、translate/rotate、rAF 这几样东西练熟。它们是你后面做所有复杂动画的地基。地基打牢了上面盖什么都不会塌。