Canvas 2D旋转动画实战:从数学原理到性能优化

发布时间:2026/10/21 5:09:12
Canvas 2D旋转动画实战:从数学原理到性能优化
1. 从一行标题拆出来的真实需求“可视化2D动画—cavas旋转的圈”这个标题第一次看到的时候我脑子里冒出来的画面特别具体一个用 Canvas 画出来的圆环可能是一圈点、一圈线段、一圈渐变色块然后它在那儿匀速转或者带点缓动地转。听起来简单得不行但真正动手写过的人都知道这里面能踩的坑一点都不少。先把话说清楚Canvas 是 HTML5 提供的一个绘图接口它本身不是动画引擎只是一块画布加一堆绘图 API。你要让上面的东西动起来靠的是每秒钟清空画布、重新画一遍这个最朴素的逻辑。所谓“旋转的圈”本质上就是每一帧里把圆上每个元素的角度加上一个增量再根据新的角度算出坐标重新绘制。这个增量怎么定、帧率怎么控、性能怎么保才是这个项目真正要解决的问题。这个内容适合谁看如果你刚学完 Canvas 基础 API能画线画圆但不知道怎么让画面动起来那这篇正好。如果你已经会写动画但总觉得自己的圆转起来有点卡、有点抖、或者元素一多就掉帧那这篇里关于帧率控制和分层渲染的部分应该对你有用。如果你是想做一个加载动画、数据可视化里的环形进度、或者某个交互组件里的旋转指示器那这套思路可以直接拿去改。我打算把这个项目从头到尾拆一遍为什么用 Canvas 而不是别的方案、旋转的数学原理到底怎么回事、代码怎么写才能又稳又顺、以及我在实际写这类动画时踩过的那些坑。不堆概念直接上能跑的东西。2. 方案选型为什么是 Canvas而不是 CSS 或 SVG2.1 三种主流方案的真实差异要做“旋转的圈”摆在面前的路其实有三条纯 CSS 动画、SVG 动画、Canvas 绘制。很多人第一反应是用 CSS因为写起来最短——一个keyframes加transform: rotate()就完事了。但这里有个关键区别CSS 旋转的是整个元素而 Canvas 旋转的是元素内部的每一个绘制单元。这两者的差别在简单场景下看不出来一旦你的圈不是“一个实心圆环”而是“一圈均匀分布的小圆点每个点还要有自己的颜色和大小变化”CSS 就抓瞎了。你没法用一条 CSS 规则让 36 个独立的小点各自按不同相位旋转。SVG 倒是可以每个点是一个独立元素但元素一多DOM 节点数量上去浏览器要维护的样式计算和重排成本就压不住了。Canvas 的优势在这里就体现出来了整块画布就是一个 DOM 元素里面画多少东西浏览器都不关心它只知道“这块区域被重绘了”。所以当你的圈要承载几十上百个绘制单元时Canvas 的渲染开销基本是恒定的而 SVG 和 DOM 方案会随元素数量线性增长。我做过一个粗略的对比测试同样画 200 个小圆点组成的旋转圈三种方案在普通笔记本上的表现大致是这样方案200 元素帧率内存占用代码复杂度适合场景CSS 动画不适用低极低单个整体旋转SVG约 45fps中高中元素少、需交互Canvas稳定 60fps低中元素多、纯视觉注意这个数据不是绝对的跟设备性能、浏览器版本都有关系但趋势是稳定的——元素越多Canvas 优势越明显。2.2 什么情况下不该用 Canvas我也得说句公道话Canvas 不是万能的。如果你的圈就是单纯一个圆环在转没有任何内部元素的变化那用 CSS 是最省事的性能也够。Canvas 的代价在于你得自己管理动画循环、自己处理高分屏适配、自己算坐标。这些在 CSS 方案里浏览器帮你做了。还有一个容易被忽略的点Canvas 绘制的内容对无障碍访问不友好。屏幕阅读器读不到画布里的东西。如果你的旋转圈承载了重要信息比如环形进度条显示百分比那要么在画布外面用文字补一份要么考虑 SVG。纯装饰性的旋转动画这个问题就不存在。所以我的判断标准很简单圈里有独立变化的多个元素用 Canvas圈本身整体转、没有内部变化用 CSS需要每个元素单独交互比如点击某个点用 SVG。这个项目标题里说的是“旋转的圈”我默认按最典型的场景来做——一圈由多个绘制单元组成的环整体旋转每个单元可以有自己的样式。3. 旋转的数学角度、弧度与坐标换算3.1 从角度到坐标的那一步很多人卡在第一步我知道要转但怎么让一个点绕着圆心转这里涉及一个最基础但最容易搞混的换算。Canvas 的坐标系里圆心在(cx, cy)半径是r。圆上任意一个点的位置由角度决定。但 JavaScript 的Math.sin和Math.cos接收的是弧度不是角度。所以每次算坐标之前得先把角度转成弧度弧度 角度 × π / 180一个完整的圆是 360 度对应 2π 弧度。这个换算我建议直接写成一个工具函数因为后面每一帧都要用const degToRad (deg) deg * Math.PI / 180;然后圆上某一点的坐标就是const x cx r * Math.cos(degToRad(angle)); const y cy r * Math.sin(degToRad(angle));这里有个细节值得说Canvas 的 y 轴是向下的所以Math.sin算出来的正值对应的是圆心下方的点。如果你想让角度从正上方开始算12 点钟方向需要在角度上减 90 度或者用-Math.PI / 2作为起始偏移。这个偏移量在画环形进度条的时候特别重要不然你的起点永远在右边3 点钟方向。3.2 每帧的角度增量怎么定旋转的速度由每帧增加多少角度决定。假设你要让圈每秒转一圈也就是 360 度/秒。如果动画跑在 60fps那每帧增加 6 度。但这里有个陷阱不能假设帧率永远是 60fps。如果你写死angle 6在性能好的设备上圈转得快在性能差的设备上圈转得慢因为帧率不一样。正确的做法是用时间差来算增量const speed 360; // 度/秒 let lastTime 0; function animate(currentTime) { const deltaTime (currentTime - lastTime) / 1000; // 转成秒 lastTime currentTime; angle speed * deltaTime; // ... 绘制 requestAnimationFrame(animate); }这样不管帧率是 30 还是 120圈转一圈的时间都是 1 秒。这个思路叫基于时间的动画是所有流畅动画的基础。我见过太多人写死增量然后在不同设备上效果不一致排查半天才发现是帧率问题。提示requestAnimationFrame的回调参数currentTime是高精度时间戳单位是毫秒。第一次调用时lastTime是 0算出来的deltaTime会很大导致第一帧跳一下。解决办法是在第一次调用时把lastTime初始化成currentTime跳过第一帧的增量计算。3.3 缓动让旋转不那么机械匀速旋转看久了会有点呆。如果你想让圈有“加速—匀速—减速”的感觉可以引入缓动函数。最简单的做法是用正弦波调制速度const easedSpeed speed * (0.5 0.5 * Math.sin(currentTime / 1000));这样速度会在 0 到 speed 之间周期性变化圈转起来就有呼吸感。更复杂的缓动可以用三次贝塞尔曲线但对于旋转圈来说正弦调制已经够用了。我试过在加载动画里用这个技巧比匀速转看起来“活”很多。4. 核心实现从零搭一个旋转的圈4.1 画布初始化与高分屏适配第一步是把画布准备好。这里有个几乎所有人都会踩的坑高分屏下画布会模糊。原因是 Canvas 的绘制尺寸和显示尺寸是两回事。在 Retina 屏上一个 300×300 的 CSS 像素区域实际有 600×600 的物理像素如果你只按 300×300 来画浏览器会拉伸结果就是糊的。解决办法是让画布的width和height属性等于显示尺寸乘以设备像素比然后用 CSS 把它压回显示尺寸const canvas document.getElementById(ring); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; const displaySize 300; canvas.width displaySize * dpr; canvas.height displaySize * dpr; canvas.style.width displaySize px; canvas.style.height displaySize px; ctx.scale(dpr, dpr);最后那句ctx.scale(dpr, dpr)是关键它让后续所有绘制操作都按逻辑像素来算不用每次手动乘 dpr。这个套路我建议直接背下来任何 Canvas 项目开头都这么写。4.2 绘制一圈元素的两种思路画“一圈东西”有两种常见做法各有适用场景。第一种是画一个圆环路径用虚线模拟分段。设置lineDash为[段长, 间隔]然后画一个整圆看起来就是一圈断开的弧。这种做法的好处是代码极短性能极好适合做刻度盘、虚线环。缺点是每段的样式没法单独控制颜色、粗细都是统一的。第二种是循环计算每个元素的位置逐个绘制。比如要画 36 个小圆点就循环 36 次每次算出角度和坐标画一个小圆。这种做法的好处是每个点可以有自己的颜色、大小、透明度适合做有层次感的装饰环。缺点是元素多了性能会下降但一般几十个点完全没问题。我两种都写过选择标准是样式统一用虚线样式各异用循环。这个项目标题说的是“旋转的圈”我默认按第二种来做因为更有发挥空间。4.3 完整可运行的代码骨架下面这份代码是我在实际项目中反复用过的骨架直接改改就能用const canvas document.getElementById(ring); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; const size 300; const cx size / 2; const cy size / 2; const radius 110; const dotCount 36; const dotRadius 4; canvas.width size * dpr; canvas.height size * dpr; canvas.style.width size px; canvas.style.height size px; ctx.scale(dpr, dpr); let angle 0; let lastTime 0; const speed 60; // 度/秒 function degToRad(deg) { return deg * Math.PI / 180; } function draw(currentTime) { if (!lastTime) lastTime currentTime; const deltaTime (currentTime - lastTime) / 1000; lastTime currentTime; angle speed * deltaTime; ctx.clearRect(0, 0, size, size); for (let i 0; i dotCount; i) { const dotAngle angle (360 / dotCount) * i; const rad degToRad(dotAngle); const x cx radius * Math.cos(rad); const y cy radius * Math.sin(rad); // 根据角度算透明度做出渐变尾巴的效果 const alpha 0.2 0.8 * ((i / dotCount)); ctx.beginPath(); ctx.arc(x, y, dotRadius, 0, Math.PI * 2); ctx.fillStyle rgba(80, 160, 255, ${alpha}); ctx.fill(); } requestAnimationFrame(draw); } requestAnimationFrame(draw);这段代码跑起来就是一个由 36 个蓝色小点组成的圈整体匀速旋转每个点的透明度从 0.2 到 1.0 渐变看起来像一条拖尾。clearRect每帧清空画布是必须的不然上一帧的内容会叠在上面转起来就是一团糊。4.4 让旋转更有质感的几个参数上面那份代码能跑但看起来比较朴素。想让圈转得更有质感可以调这几个地方点的半径做变化。让每个点的大小随角度变化比如dotRadius * (0.5 0.5 * Math.sin(rad))这样圈上会有一个“鼓包”在转视觉上更有动感。颜色做渐变。不要用单一颜色用 HSL 色相随角度变化hsl(${dotAngle % 360}, 70%, 60%)转起来就是彩虹环。加一层底环。在点下面画一个半透明的完整圆环让旋转的点有参照物视觉上更稳。用阴影做发光。ctx.shadowBlur和ctx.shadowColor能给点加上光晕但要注意性能阴影是 Canvas 里比较贵的操作元素多了会掉帧。这几个参数我建议一个一个加每加一个看一眼效果不要一次性全堆上去不然出了问题不知道是哪个参数导致的。5. 性能优化让圈在低端设备上也转得顺5.1 帧率不稳的常见原因旋转动画最怕的就是卡顿。我排查过很多次原因基本集中在三个地方绘制操作太多、每帧创建对象、画布尺寸过大。绘制操作太多很好理解每帧画几百个元素GPU 再强也扛不住。解决办法是减少元素数量或者用离屏画布把静态部分缓存起来。每帧创建对象是指像new Path2D()、ctx.createLinearGradient()这种操作如果放在循环里每帧都创建新对象垃圾回收压力会很大。解决办法是把能复用的对象提到循环外面。画布尺寸过大是个隐蔽的坑。有人为了清晰把画布设成 2000×2000但实际显示只有 300×300。多出来的像素每帧都要清空和重绘纯属浪费。画布尺寸应该等于显示尺寸乘以 dpr不要更大。5.2 离屏画布缓存静态内容如果你的圈有一层不动的底环或者背景有复杂的纹理可以用离屏画布把它们缓存起来每帧只画一次drawImage而不是重新绘制所有细节。const offscreen document.createElement(canvas); const offCtx offscreen.getContext(2d); offscreen.width size * dpr; offscreen.height size * dpr; offCtx.scale(dpr, dpr); // 在离屏画布上画一次静态内容 offCtx.beginPath(); offCtx.arc(cx, cy, radius 10, 0, Math.PI * 2); offCtx.strokeStyle rgba(80, 160, 255, 0.1); offCtx.lineWidth 2; offCtx.stroke(); // 主循环里直接贴图 ctx.drawImage(offscreen, 0, 0, size, size);这个技巧在静态内容复杂的时候效果特别明显。我做过一个带刻度线和数字的仪表盘用离屏缓存之后帧率从 40 提到了稳定 60。5.3 控制元素数量的经验值到底画多少个点合适我的经验是普通设备上100 个以内的绘制单元基本无压力200 个开始要留意超过 500 个就要考虑优化了。这个数字不是死的跟每个单元的绘制复杂度有关。画小圆点比画带阴影的复杂图形轻得多。如果你确实需要很多元素比如做一个粒子旋转环那可以考虑用ImageData直接操作像素或者用 WebGL。但那是另一个量级的工作量了对于“旋转的圈”这个需求Canvas 2D 完全够用。6. 常见问题与排查实录6.1 圈转起来一顿一顿的这是最常见的问题。排查顺序是这样的先看requestAnimationFrame是不是被正确调用了有没有在循环里做耗时操作。然后检查deltaTime的计算如果lastTime没有正确更新增量会忽大忽小。最后看绘制操作是不是每帧都在创建新对象。我遇到过一次原因是clearRect的参数写错了只清了画布的一部分导致旧内容残留看起来像卡顿。这种问题用console.log打帧间隔时间就能定位正常应该是 16.7ms 左右如果忽高忽低就是有问题。6.2 高分屏上模糊前面说过是 dpr 适配没做。检查canvas.width是不是等于displaySize * dprctx.scale(dpr, dpr)有没有调用。还有一个容易漏的点如果画布尺寸在运行时改变了需要重新设置width和height并重新scale否则缩放会累积。6.3 旋转中心不对圈没有绕着自己中心转而是绕画布中心或者别的地方转。检查cx和cy是不是设成了画布中心而radius是不是从圆心算起的。如果你想让圈绕画布中心转那cx和cy就是size / 2。如果圈本身有偏移需要把偏移量加到坐标计算里。6.4 页面切到后台再回来圈跳了一下这是因为requestAnimationFrame在页面不可见时会暂停回来时currentTime跳了一大截deltaTime变得很大角度一下增加很多。解决办法是给deltaTime设一个上限比如最大 0.1 秒const deltaTime Math.min((currentTime - lastTime) / 1000, 0.1);这样即使页面切走很久回来时圈也不会跳。这个细节很多教程不讲但实际项目中一定会遇到。6.5 常见问题速查表现象可能原因排查方法解决方向转起来卡顿帧率不稳/绘制过重打帧间隔日志减少元素/离屏缓存画面模糊dpr 未适配检查 canvas.width乘 dpr 并 scale旋转中心偏移cx/cy 设置错误打印圆心坐标修正圆心或半径切后台回来跳帧deltaTime 过大打印 deltaTime设上限 0.1s颜色叠加变糊未清空画布检查 clearRect每帧清空元素多了掉帧绘制单元过多逐步减少数量控制 200 以内7. 这个圈还能怎么玩基础版跑通之后可扩展的方向其实挺多的。我列几个我实际做过或者见过效果不错的环形进度条。把旋转的圈和进度结合用arc的起始角和结束角控制弧长旋转的部分做成头部的小圆点。这个在数据可视化里特别常用加载、上传、完成度都能用。多层反向旋转。画两到三层圈每层转速不同、方向相反叠在一起有齿轮咬合的感觉。层数不要超过三层多了会乱。交互响应。监听鼠标位置让圈的转速或者方向跟着鼠标变。比如鼠标在左边时逆时针转在右边时顺时针转转速跟距离挂钩。这个做出来很有参与感。粒子拖尾。不用固定的点而是每帧在圆上随机位置生成粒子粒子有生命周期逐渐淡出。这样圈看起来像在甩出火花适合做加载动画。文字环绕。把点换成文字每个字旋转到对应角度整体转起来就是文字环绕效果。注意每个字要单独save、translate、rotate、fillText、restore不然文字方向会乱。这些扩展的共同点是核心的旋转逻辑不变只是改变每个绘制单元的样式和位置计算。把基础骨架搭好之后换样式就是改几行代码的事。我个人在实际操作中的体会是Canvas 动画这东西看十篇教程不如自己动手写一遍。很多坑只有跑起来才会遇到比如 dpr 模糊、切后台跳帧、清空画布这些文档里不会特意强调但实际项目中一个都躲不掉。建议你先把上面那份骨架代码复制到本地跑起来然后一个一个改参数看效果怎么变。改着改着你就知道每个参数是干什么的了。最后再分享一个小技巧调试 Canvas 动画的时候把requestAnimationFrame暂时换成setTimeout帧率降到 10fps 左右这样每一帧的变化看得清清楚楚方便定位坐标和角度的问题。调好了再换回去。这个法子我用了很多次比盯着 60fps 的画面瞎猜高效多了。