ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas爱心动画代码逐句拆解:从sin/cos参数方程到requestAnimationFrame

Canvas爱心动画代码逐句拆解:从sin/cos参数方程到requestAnimationFrame 网上流传的自动画爱心JavaScript代码看着挺玄乎复制下来确实能出效果但大多数人并不知道它每一行在干什么问起来也说不太明白。我这几年做前端Canvas这块没少折腾今天就把这段代码彻底掰开揉碎逐句翻译一遍。它到底是什么、能做什么、适合谁看先交代清楚这是一段跑在浏览器里的爱心动画代码配合HTML的canvas标签让一群带有发光效果的小爱心从屏幕底部不断上升适合想学JavaScript基础、对Canvas动画感兴趣、或者单纯想给网站加一点小彩蛋的朋友。不管你是刚入门前端还是写过一阵子只要跟着把每一句过一遍收获都会比单纯复制粘贴大得多。1. 整体设计这个爱心动画到底是怎么动起来的在逐句看代码之前我建议先把套路摸清楚不然一句一句看也是白看。这个自动画爱心的JavaScript动画拆开来看其实就三件事第一找一块可以画图的画布第二让代码知道爱心长什么样第三每秒钟反复画很多次画的位置不停变化看起来就像在动。这三件事分别对应HTML里的canvas标签、心形曲线的数学公式、以及requestAnimationFrame这个浏览器自带的动画循环。很多人一看到代码里有sin、cos就有点发怵其实这里没有多高深的东西就是一条参数方程。Canvas不是魔法它只会做两件事移动画笔、连线。真正让形状变成爱心的是那条方程告诉它“哪些点连在一起会像一颗心”。所以这篇逐句翻译里我会把最多的篇幅花在drawHeart函数上因为整段代码的灵魂就在那里。再说说为什么不用CSS动画或者直接用一张爱心图片。CSS做心形也不是做不出来不外乎旋转、缩放那一套但要做“自动画出来”的效果也就是像写字一样把线条一步一步呈现在屏幕上用CSS会非常别扭。而Canvas天然就是逐帧绘制工具每一帧画什么、画多大、画在哪全部由JavaScript说了算。这也是这类小动画的主流实现方案。如果你想给网站加一点交互感或者以后想写粒子特效、小游戏这套“Canvas requestAnimationFrame”的组合是绕不开的所以这颗爱心并不只是浪漫彩蛋它其实是个非常合适的前端入门练习。2. 先在本地跑起来完整示例代码先把完整代码放出来。你可以把它保存成一个html文件比如heart.html用浏览器直接打开就能看到效果。后面的所有讲解都会围绕这份代码展开。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleCanvas 爱心动画/title style body { margin: 0; overflow: hidden; background: #000; } /style /head body canvas idheartCanvas/canvas script const canvas document.getElementById(heartCanvas); const ctx canvas.getContext(2d); const hearts []; const TOTAL 30; let angle 0; function resizeCanvas() { canvas.width window.innerWidth; canvas.height window.innerHeight; } window.addEventListener(resize, resizeCanvas); resizeCanvas(); function createHeart() { return { x: Math.random() * canvas.width, y: canvas.height Math.random() * 100, scale: Math.random() * 1.5 0.5, speed: Math.random() * 1 0.5, alpha: Math.random() * 0.6 0.4, }; } function drawHeart(x, y, s, alpha) { ctx.save(); ctx.translate(x, y); ctx.scale(s, s); ctx.rotate(angle * 0.01); ctx.beginPath(); ctx.moveTo(0, 0); for (let i 0; i 360; i) { const t (i / 180) * Math.PI; const hx 16 * Math.pow(Math.sin(t), 3); const hy -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); ctx.lineTo(hx, hy); } ctx.closePath(); ctx.fillStyle rgba(255, 60, 90, ${alpha}); ctx.shadowColor rgba(255, 60, 90, 0.8); ctx.shadowBlur 12; ctx.fill(); ctx.restore(); } function update() { ctx.clearRect(0, 0, canvas.width, canvas.height); angle 0.5; for (let i 0; i hearts.length; i) { hearts[i].y - hearts[i].speed; hearts[i].x Math.sin(angle * 0.02 i) * 0.5; if (hearts[i].y -100) { hearts[i].y canvas.height 100; } drawHeart(hearts[i].x, hearts[i].y, hearts[i].scale, hearts[i].alpha); } requestAnimationFrame(update); } for (let i 0; i TOTAL; i) { hearts.push(createHeart()); } update(); /script /body /html这份代码我故意没有做任何压缩变量命名也尽量直白就是为了方便逐句拆解。接下来我们就按代码从上到下的顺序一句一句翻译。3. 逐句翻译第1部分拿到画布做好准备3.1 这一句是全部的地基const canvas document.getElementById(heartCanvas);这句的意思是告诉浏览器去页面里找一个id等于heartCanvas的元素找到之后让canvas变量指向它。你可以把canvas理解成一块白板后面所有绘制操作都要在这块白板上进行。紧接着这一句const ctx canvas.getContext(2d);这是第二句也是让新手最容易懵的一句。getContext(2d)是canvas提供的方法它在白板上返回一支“2D画笔”。后面你调用的所有beginPath、lineTo、fill之类的方法其实都是这支画笔的方法。如果你传的不是’2d‘比如’webgl‘拿到的就是另一套3D接口了。当然我们这里用2D就够了。3.2 为什么用const而不是var或let从ES6开始const用来声明一个不能重新赋值的变量。这里canvas和ctx自始至终没有重新赋值过用const是最合适的。一是防止你后面手滑把画布变量覆盖掉二是代码可读性更好看到const就知道这个变量不会变。这里我多说一句经验新手最容易犯的错就是把canvas变量名改了但HTML里的id没有同步改结果getElementById返回null后面所有代码全报错。这类问题排查起来非常快打开浏览器控制台看到类似“Cannot read properties of null”的报错基本就能定位到。3.3 数组和数字为爱心雨做准备const hearts [];这个空数组是整段动画的动态数据中心。我们要做的不只是一个孤零零的爱心而是一群持续上升的小爱心。每颗爱心的位置、大小、透明度、速度都不一样用对象存起来再塞进数组统一管理是最自然的做法。你以后写任何粒子特效十有八九也是这个套路一个数组装着一堆带属性的对象每帧更新属性再重新绘制。const TOTAL 30;表示同时存在的爱心总数是30。这个数字不是随手写的。爱心太少画面会显得空太多又会卡。实际运行起来你会发现30颗带阴影的小爱心在页面上匀速上升刚好是一个比较舒服的密度。如果你想做更密集的星空效果可以把它调成80或者100但要注意电脑性能差异后面第8章我会专门讲性能问题。let angle 0;这个angle变量在后面会不断累加用来让爱心产生轻微的摆头和旋转效果。我用let而不是const就是因为它的值在每一帧动画里都会变化。这也是const和let最典型的区别不会变的用const需要变的用let。4. 逐句翻译第2部分让爱心跟着窗口自适应4.1 尺寸适配不然后果很尴尬function resizeCanvas() { canvas.width window.innerWidth; canvas.height window.innerHeight; }这段代码的作用是让画布铺满整个浏览器窗口。如果不做这一步canvas默认只有300x150像素你会看到一个爱心挤在左上角剩下的全是黑边跟预期效果差得很远体验会很糟糕。window.innerWidth和window.innerHeight是浏览器窗口的宽度和高度把它们赋值给canvas.width和canvas.height画布就撑满了整个可视区域。window.addEventListener(resize, resizeCanvas);这句是事件绑定意思是当浏览器窗口大小变化时自动调用resizeCanvas函数重新设置画布尺寸。没有这句的话用户手动把窗口拉大画布还是原来的大小画面就会缺一块。resizeCanvas();最后还要主动调用一次。因为addEventListener只是“监听”并不会在页面加载时立刻执行。这行代码的作用是在脚本运行的瞬间先把画布尺寸设定好否则刚打开页面时画布尺寸还是默认的300x150。注意这是很多人在自写代码时容易踩的坑。如果你把resizeCanvas的定义放在后面却忘了在脚本最开始主动调用一次那么页面首次加载时画布永远是默认尺寸只有当你手动拖动窗口大小后才会恢复正常。这种问题控制台不会报错排查起来最让人头疼。4.2 为什么放在body末尾不用等DOMContentLoaded这份代码里的script标签写在canvas标签后面所以脚本执行时canvas元素已经存在了。如果script放在head里getElementById很可能因为页面还没解析到canvas标签而返回null这时就要用window.onload或者DOMContentLoaded事件包一层。这一点是很多人初学时会困惑的地方理解了这个顺序你就明白了为什么这段代码要把script放在body的最下面。5. 逐句翻译第3部分每颗爱心的出生记录5.1 createHeart函数返回一个对象function createHeart() { return { x: ..., y: ..., scale: ..., speed: ..., alpha: ... }; }这个函数不看别的只看它返回的内容一颗爱心在某一时刻的所有状态。x和y是它的坐标scale是大小缩放倍数speed是上升速度alpha是透明度。把所有状态打包成一个对象返回是JavaScript里非常常见的写法也是后面数组管理的核心。具体每一句x: Math.random() * canvas.width随机生成0到画布宽度之间的一个横坐标。y: canvas.height Math.random() * 100让爱心的初始纵坐标在画布底部下方0到100像素的位置这样它们是从画布底部“冒”出来的而不是一开始就出现在屏幕中间。scale: Math.random() * 1.5 0.5生成0.5到2.0之间的小数让爱心大小不一。speed: Math.random() * 1 0.5生成0.5到1.5之间的上升速度。alpha: Math.random() * 0.6 0.4生成0.4到1.0之间的透明度。5.2 Math.random的取值范围和通用写法Math.random()返回0包含到1不包含之间的随机小数。所以Math.random() * 1.5 0.5的取值范围就是0.5到2.0。这个式子其实是“最小值 随机数 * 范围”的通用写法。我想要0.5到2.0范围是1.5最小值是0.5所以写成Math.random() * 1.5 0.5。理解了这个小公式以后你想要任何范围的随机数都能随手写出来。5.3 为什么要随机如果所有爱心都从同一个点出生大小一样速度一样透明度也一样那画面就会像复制粘贴一样死板。随机化是让动画看起来自然的核心手段。你仔细观察那些好看的粒子动画本质都是一堆随机参数组合出来的结果。这个思路在很多视觉项目里都通用不只是画爱心。6. 逐句翻译第4部分真正的核心爱心是怎么画出来的6.1 save和restore先拍快照再恢复ctx.save();把当前画布状态保存起来。状态包括坐标系的变换、透明度、阴影等你可以把它理解成拍了一张快照。ctx.translate(x, y);把坐标系原点移到(x, y)位置。也就是我们画这个爱心的时候不再以画布左上角为(0,0)而是以(x, y)为新的(0,0)。这样一来后面绘制爱心时只需要围绕(0,0)点计算坐标不需要每次手动偏移。ctx.scale(s, s);把后面画的所有东西放大s倍。s是0.5到2.0之间的随机数所以每颗爱心有大有小。ctx.rotate(angle * 0.01);把坐标系旋转一个小角度。angle在每一帧都会增加所以爱心会缓慢地旋转。ctx.restore();把刚才save时保存的状态恢复回来。translate、scale、rotate的影响只在当前这颗爱心上生效画完就还原不影响下一颗爱心的绘制。这三件套save、变换、restore是Canvas绘制里最常见的套路。如果不save的话所有变换会不断累积画完一颗爱心后坐标系已经歪了下一颗爱心就可能跑到屏幕外面去。这个坑我见过不少人踩过尤其是刚接触Canva的时候。6.2 开始描绘路径ctx.beginPath();告诉画笔我要开始画一条新路径了。路径可以理解成一笔画beginPath就是在“起笔”把之前的路径信息清空掉避免和旧路径混在一起。ctx.moveTo(0, 0);把画笔抬起来移动到(0,0)这个点不画线。这个(0,0)不是画布左上角而是上面translate之后的新原点也就是爱心的中心位置。然后是一个大循环for (let i 0; i 360; i) { const t (i / 180) * Math.PI; const hx 16 * Math.pow(Math.sin(t), 3); const hy -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); ctx.lineTo(hx, hy); }这段是整个代码的灵魂。它的意思是从0度到360度每隔1度取一个点一共取360个点然后从上一个点连到下一点连完一圈之后就是一个爱心轮廓。const t (i / 180) * Math.PI;把度数转成弧度。为什么是除以180而不是除以360因为i的范围是0到359除以180后t的范围是0到约2π正好覆盖一个完整的圆周。你写成i / 360 * 2 * Math.PI也是一样的效果只是写法不同。6.3 心形方程这行就是爱心形状的来源const hx 16 * Math.pow(Math.sin(t), 3);这是心形曲线的横坐标方程。爱心最经典的一条参数方程是x 16 * sin³(t)y 13 * cos(t) - 5 * cos(2t) - 2 * cos(3t) - cos(4t)这个方程画出来的曲线长得很像一颗规整的心形。Math.pow(Math.sin(t), 3)的意思是sin(t)的三次方因为JavaScript里没有直接写x³的语法只能用Math.pow(底数, 指数)这种方式。const hy -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));这一行就是上面的y方程只是前面加了个负号。原因很直接数学坐标系里y轴向上是正方向而屏幕坐标系里y轴向下是正方向。如果不加负号爱心会倒过来。这个细节也是很多人在复现代码时最容易忽略的。6.4 lineTo、closePath、fill把它变成实心的ctx.lineTo(hx, hy);画一条从当前点到(hx, hy)的线段。循环执行360次之后爱心的边缘轮廓就出来了。ctx.closePath();把最后一个点和第一个点连接起来让路径闭合。如果不闭合填充颜色的时候可能会有一条细缝看起来就不完整。ctx.fillStyle rgba(255, 60, 90, ${alpha});设置填充颜色。rgba四个参数分别是红、绿、蓝、透明度。红色分量255绿色60蓝色90所以是偏玫瑰红的颜色透明度由参数alpha控制。ctx.shadowColor rgba(255, 60, 90, 0.8);和ctx.shadowBlur 12;是给爱心加发光效果。原理是Canvas会沿着路径边缘绘制一圈模糊的阴影阴影颜色是半透明红色模糊范围12像素看起来就像爱心自己在发光。这个效果让画面质感提升了很多。ctx.fill();真正执行填充把路径里的区域用刚才设置的颜色填满。6.5 数字16为什么要放在这里细心的人会发现横坐标方程里有一个16纵坐标的表达式里却没有单独的16。这个16的作用是控制爱心整体的大小。因为后面有scale控制缩放所以实际画面中这个16已经被scale一起放大了。如果你想画更大的爱心可以调大16也可以调大scale。但要注意直接调16会同时影响所有爱心的大小而调scale只会影响当前这一颗。我们这段代码里用scale做随机大小控制所以保留方程里的16不动是最合理的做法。7. 逐句翻译第5部分每帧都在做什么动画是怎么跑起来的7.1 update函数是动画的引擎function update() { ... }这个函数负责“更新并绘制一帧”。动画原理其实很简单每秒钟画面刷新很多次每次刷新时所有爱心的位置都移动一点点然后重新绘制一遍。整个过程重复进行人眼看到的就是连续动画。ctx.clearRect(0, 0, canvas.width, canvas.height);这一行把整个画布清空。不清空的话上一帧留下的爱心会和这一帧叠加在一起最后画面会越来越糊直到完全看不清。清空区域的参数分别是左上角x、y、宽、高这里直接覆盖整个canvas。angle 0.5;每帧递增0.5。angle会被用于爱心的旋转和左右摆动数值越大摆动和旋转的节奏也越快。7.2 循环更新每一颗心for (let i 0; i hearts.length; i) { hearts[i].y - hearts[i].speed; hearts[i].x Math.sin(angle * 0.02 i) * 0.5; if (hearts[i].y -100) { hearts[i].y canvas.height 100; } drawHeart(hearts[i].x, hearts[i].y, hearts[i].scale, hearts[i].alpha); }hearts[i].y - hearts[i].speed;让爱心向上移动。因为屏幕坐标系里y轴向下为正y值减小就是往上走。speed是每帧移动的像素距离数值越大上升越快。hearts[i].x Math.sin(angle * 0.02 i) * 0.5;让爱心在水平方向做轻微的左右摆动。Math.sin的取值范围是-1到1乘0.5后变成-0.5到0.5像素。angle不断增大所以每颗心会左右晃动。后面加上i是为了让同一帧里不同爱心的摆动相位错开避免所有心整齐划一地左右跳那样会显得很机械。if (hearts[i].y -100) { hearts[i].y canvas.height 100; }判断爱心是否已经飞出屏幕顶部如果飞出去了就把它重置到屏幕底部下方这样循环上升爱心雨就能一直下。最后调用drawHeart完成这颗爱心的绘制。7.3 requestAnimationFrame才是真正的驱动requestAnimationFrame(update);这一行放在update函数末尾作用是告诉浏览器“下一帧动画准备好了你再调用一次update。”这样update会被浏览器以大约每秒60次的频率反复调用动画就一直持续下去了。为什么不用setInterval因为requestAnimationFrame会跟随屏幕刷新率并且当页面切到后台时浏览器会自动暂停不浪费CPU。它还有一个好处是自带帧同步动画不容易出现撕裂感。这是所有Canvas动画的标准驱动方式以后你写任何动画都可以沿用这个模式。7.4 初始化数组并启动for (let i 0; i TOTAL; i) { hearts.push(createHeart()); } update();这两段代码非常好理解先循环30次把30颗爱心放进数组然后调用一次update动画就启动了。注意第一次调用update后requestAnimationFrame会在末尾自己调度下一次所以我们只需要在页面启动时调用这一次update后面就不用管了。8. 常见问题与排查技巧实录8.1 页面打开一片黑什么也看不到八成是canvas没有铺满窗口或者绘制颜色和背景一样黑。先检查resizeCanvas有没有在页面加载时执行再检查fillStyle是不是真的设成了可见的红色。还有一个常见原因脚本路径写错了比如script标签写在了canvas标签前面导致getElementById找不到canvas元素。解决办法把script放到body最末尾或者包一层window.onload事件。8.2 爱心有了但方向是倒着的前面我提到hy前面加负号是为了适配屏幕坐标系。如果你在复现时漏掉了负号或者把方程抄反了爱心就会上下颠倒。解决方案就是检查hy的计算确保它等于-(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))。8.3 动画很卡帧率上不去最常见的原因是爱心数量太多或者阴影效果开得太大。shadowBlur是一个非常消耗性能的属性我实测在普通笔记本上爱心数量超过100颗并且每颗都带阴影帧率会明显下降。优先降低TOTAL比如从30降到20如果还想再多一些可以把shadowBlur缩小到6或者直接去掉阴影。8.4 我改了一个数字结果爱心全跑偏了多半是缩放或者坐标变换没有理解对。比如把scale的范围改得太大爱心可能被放大到好几倍直接占满整个屏幕。你要记住爱心绘制时的坐标原点已经translate过scale的控制范围建议在0.2到3.0之间超过这个区间就很容易出问题。8.5 我想把爱心改成别的颜色直接改fillStyle和shadowColor里的RGB值即可。比如改成金色fillStyle可以写成rgba(255, 215, 0, alpha)shadowColor用rgba(255, 215, 0, 0.8)。如果想让背景不是黑色改body的background颜色就行。8.6 控制台报错说xxxx is not defined通常是某个变量名拼错了或者把getElementById的参数写错了大小写。HTML里的id是区分大小写的heartCanvas和heartcanvas是两个不同的字符串后者会返回null。另外如果你在页面里看到javascript:void(0)这类链接报错那是链接自身的写法问题跟这段爱心代码没有关系不要混为一谈。报错信息里一般会指明是哪个变量名照着查就行。9. 最后再分享一个我自己常用的调整技巧代码跑通之后我建议你一定要亲手改几个数字感受一下。比如把scale的范围改成0.2到1.0爱心会小很多画面显得更秀气把speed的范围改大上升速度会明显加快把angle 0.5改成angle 2旋转会明显加快整个画面会多出一种活泼的感觉。这些参数没有标准答案只有自己在浏览器里试过之后才知道什么氛围最适合你的页面。另外一个我比较喜欢的小技巧如果你想做成“点击页面任意位置出现爱心”的交互只需要再加一个click事件监听在事件处理函数里创建一个heart对象push到hearts数组里。这样用户点哪里哪里就会冒出一颗新的爱心交互感和成就感会一下子提升不少。说实话爱心代码本身看起来就是个花活但你把Canvas、参数方程、requestAnimationFrame、对象数组这套流程完整跑通之后再去看粒子系统、屏幕保护、数据可视化这类项目会发现底层逻辑全都打通了。这也是我愿意花这么多篇幅去逐句翻译它的原因表面上是在讲一颗爱心的画法实际上是在帮你建立Canvas动画的整体认知框架。
返回列表