如果你也干过这种事——从网上看到一段“用JavaScript自动画爱心”的代码,眼前一亮,赶紧复制下来存成.html文件,双击打开,结果页面一片空白,或者在控制台看到一片红色报错,再或者爱心是画出来了,但你想改个颜色、换个大小,完全不知道从哪下手——那这篇文章就是为你准备的。
我早年学JavaScript的时候也这样,代码拿过来是拿过来了,但每一行都像天书。后来硬着头皮一行一行拆,才发现这类“爱心动画”代码其实没那么神秘:无非是HTML搭个画布、JavaScript算坐标、浏览器的绘图接口把点画出来,再用动画循环让画面动起来。这篇文章就把“自动画爱心JavaScript代码”完整地逐句翻译一遍,每一行在做什么、为什么这么写、背后用到了哪些JavaScript基础知识,全给你掰开揉碎讲清楚。
不管你是一边看教程一边敲代码的新手,还是已经能写点基础逻辑、但碰到Canvas动画就发懵的前端初学者,这篇都适合。看完之后,你不仅能把网上各种爱心代码看懂,还能自己动手改出独一无二的版本。
1. 先搭舞台:HTML、Canvas 与 JavaScript 的分工
很多第一次接触这种代码的人会有一个困惑:这到底算HTML、CSS还是JavaScript?其实答案很简单——三者都在里面。它们的分工很明确,搞清楚这个,后面读代码会顺畅很多。
1.1 一个HTML文件里的三种角色
我们打开这类爱心代码,看到的第一个东西通常是一个.html文件,里面同时塞了HTML标签、CSS样式和JavaScript脚本。
- HTML负责“搭骨架”:告诉浏览器页面上有哪些元素。对于爱心动画来说,核心元素就是一个
<canvas>,也就是一块画布。 - CSS负责“穿衣服”:控制画布在页面上的位置、背景颜色、大小等。很多初版代码里CSS部分很简短,甚至只有几行,因为画布本身的内容是靠JavaScript画出来的。
- JavaScript负责“干活”:计算爱心的坐标、把粒子画上去、让画面动起来。这是整段代码的核心,也是我们要逐句翻译的重点。
这种“一个文件搞定一切”的写法,在学习阶段特别方便,不用起服务器、不用装依赖,用浏览器直接打开就能跑。这也是网上大量爱心代码都是这种形态的原因。
1.2 Canvas坐标系:和数学课上的坐标系不一样
在看代码之前,必须先把Canvas的坐标系讲清楚,不然你会卡在“为什么爱心Y轴是反的”这个问题上。
Canvas的坐标系原点在画布左上角,也就是(0, 0)位置。x轴向右增大,y轴向下增大。也就是说,y坐标越大,点越靠下。
这跟数学课上的笛卡尔坐标系刚好相反——数学里y越大越靠上,Canvas里y越大越靠下。
这个差异直接影响了爱心公式的处理方式。后面代码里我们看到的爱心参数方程,是用数学坐标系表达的,画到Canvas上时,往往需要做一步“翻转”操作,最常见的手段是:canvasY = centerY - mathY,用画布中心点的高度减去数学坐标值,这样图像就正过来了。
1.3 为什么这种动画用Canvas而不是CSS或SVG
你可能见过一些纯CSS画爱心的教程,也有用SVG描路径的,为什么这段代码偏偏选Canvas?
我自己的体会是:Canvas是像素级绘图,适合粒子系统这种大量图形元素频繁重绘的场景。以文章后面这段代码为例,屏幕上一两百个粒子,每一帧都要重新计算位置并绘制,如果用SVG,每个粒子都得对应一个DOM节点,几百个节点同时更新,页面会很吃力。用CSS就更麻烦了,粒子的坐标来自数学公式,CSS动画很难精确控制这种逐帧变化。
Canvas的思路则简单粗暴:每一帧先把画布清空,然后重新画所有粒子。这个循环在浏览器里每秒执行几十次,视觉上就是一个连续的动画。理解了这个机制,后面看clearRect和requestAnimationFrame这两行代码时,就顺理成章了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逐句翻译核心代码:从数学公式到一帧动画
下面这段代码是一个比较经典的粒子爱心动画实现,我删掉了一些噪声,保留了最核心的结构。我建议你先把它完整看完,再看后面的逐句拆解。逐句拆解时我会把整份代码切块,一行一行讲。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>自动生成爱心动画</title>
<style>
body {
margin: 0;
background: #0a0a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
canvas {
background: transparent;
}
</style>
</head>
<body>
<canvas id="heartCanvas" width="600" height="600"></canvas>
<script>
const canvas = document.getElementById('heartCanvas');
const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const scale = 12;
const particles = [];
const count = 300;
function heartX(t) {
return 16 * Math.pow(Math.sin(t), 3);
}
function heartY(t) {
return 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
}
for (let i = 0; i < count; i++) {
const t = (i / count) * 2 * Math.PI;
particles.push({
targetX: centerX + heartX(t) * scale,
targetY: centerY - heartY(t) * scale,
currentX: centerX + heartX(t) * scale,
currentY: centerY - heartY(t) * scale,
size: Math.random() * 2 + 1,
color: `hsl(${Math.floor(Math.random() * 60) - 10}, 80%, 65%)`
});
}
let angle = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(Math.sin(angle) * 0.06);
ctx.translate(-centerX, -centerY);
for (const p of particles) {
p.currentX += (p.targetX - p.currentX) * 0.08;
p.currentY += (p.targetY - p.currentY) * 0.08;
ctx.beginPath();
ctx.arc(p.currentX, p.currentY, p.size, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.restore();
angle += 0.02;
requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>
2.1 拿到画布和画笔:getElementById 与 getContext
javascript复制const canvas = document.getElementById('heartCanvas');
const ctx = canvas.getContext('2d');
第一句是在整个HTML文档里找一个id为heartCanvas的元素。这正是之前HTML里的<canvas id="heartCanvas" width="600" height="600">。
这里为什么用getElementById而不是querySelector?因为id在页面中是唯一的,getElementById是浏览器里最直接的查找方式,性能好,写法也少。很多框架代码喜欢用querySelector,但它更通用,要写完整的选择器,这里没有必要。
第二句拿到的是2d绘图上下文。你可以把canvas想成一块真的白板,而ctx相当于你拿在手里的笔。白板本身不能画画,只有通过这支笔才能画点、画线、画圆、上颜色。getContext('2d')是Canvas最常调用、也是目前兼容性最好的接口,返回值上挂着各种绘图方法,比如后面会用到的arc、fill、clearRect。
2.2 中心点与缩放因子:把爱心放在画面正中间
javascript复制const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const scale = 12;
这两个变量表示画布中心点的坐标。画布宽600,中心x就是300;高600,中心y就是300。把爱心绘制在中心点附近,视觉上最稳。
scale是缩放因子。细心的人会问:爱心公式算出来的坐标范围大概是多少?以x = 16sin³t为例,sin的取值范围是[-1, 1],三次方后还是[-1, 1],再乘16,x范围大概是[-16, 16]。y那串就更大一些,大概在[-17, 13]之间。如果不做缩放,这个爱心所有点都挤在原点周围二三十像素的范围内,几乎看不见。所以需要乘一个倍数把图形放大——这里乘12,爱心尺寸大概在几百像素级别,配合600x600的画布正合适。
这个缩放因子的选取没有标准答案,画布尺寸越大,scale一般也要跟着调大。你把scale改成8试试,爱心会变小;改成20,爱心可能超出画布边界。
2.3 爱心形状的数学来源:那个神奇的参数方程
javascript复制function heartX(t) {
return 16 * Math.pow(Math.sin(t), 3);
}
function heartY(t) {
return 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
}
这是整段代码的灵魂。
网上流传很广的“爱心公式”有很多种,这个是用参数方程表示的经典版本。t是一个角度参数,范围从0到2π,也就是绕圆周一圈。每给定一个t,就能算出一个点的(x, y)坐标。t从0到2π连续取值,把所有点连起来,就形成了爱心轮廓。
我先说结论:这两个函数的返回值描述的是“数学坐标系”里的爱心形状,x方向宽度大约正负16,y方向从大约-17到14,整体形状偏向竖直的长椭圆,尖角朝下。之所以有这种形状,是因为sin³t在t接近π/2和3π/2时产生上下两个尖,而13cost - 5cos2t - 2cos3t - cos4t那串三角函数叠加,让上部圆润、下部收拢。
Math.pow(Math.sin(t), 3)计算的是sin(t)的三次方。Math.pow是幂运算函数,pow(a, b)也就是a的b次方。这个写法其实也可以用Math.sin(t) * Math.sin(t) * Math.sin(t)替换,效果完全一样。
y那行看着吓人,其实就是几个cos项叠加。13、5、2、1这些系数是前人调出来的“好看参数”,改动了爱心形状就会变形。我自己试过把系数乱改,画出来的东西有时像叶子,有时像陀螺,有时就是一团混乱——有兴趣可以拿这个当小游戏玩,能加深对三角函数的理解。
2.4 生成粒子数组:把公式变成屏幕上的坐标集合
javascript复制const particles = [];
const count = 300;
for (let i = 0; i < count; i++) {
const t = (i / count) * 2 * Math.PI;
particles.push({
targetX: centerX + heartX(t) * scale,
targetY: centerY - heartY(t) * scale,
currentX: centerX + heartX(t) * scale,
currentY: centerY - heartY(t) * scale,
size: Math.random() * 2 + 1,
color: `hsl(${Math.floor(Math.random() * 60) - 10}, 80%, 65%)`
});
}
particles是一个数组,用来存放所有粒子的数据。count = 300表示总共300个粒子,数字越大爱心越密,但也越消耗性能。
for循环里,i从0变到299,对应300个粒子。这里最关键的一行是const t = (i / count) * 2 * Math.PI;——它把300个粒子均匀分布在0到2π的角度区间上。你可以把t理解成“这个粒子在爱心轮廓的哪个位置”。当i=0时t=0,i=150时t正好是π,i=299时t接近2π。这种“均匀采样”是画闭合曲线最常见的做法。
每个粒子存6个属性:
targetX、targetY:粒子的最终目标坐标,即爱心轮廓上的位置。currentX、currentY:粒子当前坐标。初版代码里直接让它等于目标坐标,所以初始状态爱心已经完整成形。size:粒子半径,用Math.random() * 2 + 1生成1到3之间的随机数,让粒子大小有参差感,画面更自然。color:颜色,用hsl()格式生成。这段话稍微拆一下,Math.random() * 60 - 10生成的是-10到50之间的整数,也就是色相在红色到橙黄色之间浮动;80%是饱和度,65%是明度。这样整颗爱心以暖色系为主,但每个粒子颜色略有差异,比纯色更耐看。
注意这里有个名字上的细节:targetX和targetY在后续动画逻辑里会被当成“静止的目标”,而currentX和currentY是“不断移动的当前位置”。这是粒子动画里极常见的设计模式。
2.5 动画循环的核心:requestAnimationFrame 驱动
javascript复制let angle = 0;
function animate() {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 动态旋转
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(Math.sin(angle) * 0.06);
ctx.translate(-centerX, -centerY);
// 绘制粒子
for (const p of particles) {
p.currentX += (p.targetX - p.currentX) * 0.08;
p.currentY += (p.targetY - p.currentY) * 0.08;
ctx.beginPath();
ctx.arc(p.currentX, p.currentY, p.size, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.restore();
angle += 0.02;
requestAnimationFrame(animate);
}
animate();
这段是动画的心脏。浏览器每准备刷新一帧画面,就会调用一次animate函数。requestAnimationFrame(animate)这句的意思是“请浏览器在下一帧刷新前再调用我一次”,这样才形成了无限循环。
clearRect负责清空画布。如果不清空,上一帧画的粒子还留在画布上,下一帧的粒子会叠加,最后变成一团模糊的色块。
save和restore是一对。save把当前画布状态存起来,restore把画布恢复成之前保存的状态。这对组合包裹的是画布整体旋转的逻辑:先把坐标系平移到画布中心,旋转一个小角度,再平移回去。这样整个爱心会绕着中心轻微摆动,像在呼吸一样。Math.sin(angle) * 0.06让角度在±0.06弧度之间平滑振荡,所以爱心不会一直转圈,而是左右轻微晃动。
粒子位置更新那两行:
javascript复制p.currentX += (p.targetX - p.currentX) * 0.08;
p.currentY += (p.targetY - p.currentY) * 0.08;
这是“渐进逼近”的经典写法。每次计算当前位置和目标位置差值的一部分,然后加到当前坐标上。0.08表示每一步只移动差值的8%。因为差值会越来越小,粒子每次移动的距离也在减小,产生“先快后慢”的缓动效果。如果改成1,粒子直接跳到目标位置,动画就没有过渡了。如果改成0.5,移动就更快,但少了那种轻柔的迟滞感。
绘制粒子本身是Canvas的基础操作:
beginPath():开始一段新路径。arc(x, y, radius, startAngle, endAngle):画一个圆。这里起始角度是0,结束角度是Math.PI * 2,也就是整圆。fillStyle = p.color:设置当前填充色。fill():把刚才定义的圆填充成设定的颜色。
最后,angle += 0.02让整个旋转角度随时间缓慢变化,驱动“呼吸”效果。
3. 这些代码背后:一次把JavaScript基础知识点查漏补缺
看完逐句翻译,你可能已经能跟读代码了,但有些知识点还隐隐约约不够透。这一节我把爱心代码里真正涉及到的JavaScript基础概念集中梳理一遍,尤其是那些新手容易混的地方。
3.1 DOM操作与getElementById
document.getElementById是浏览器提供的DOM查询接口,返回匹配id的元素对象。这里必须注意一个常见的坑:script标签放在body里的canvas之后。浏览器解析HTML是顺序执行的,如果script放在canvas前面,执行getElementById时canvas还没被解析出来,结果就是null,下一行调用null.getContext直接报错。我把脚本放在<canvas>后面,就是刻意规避这个问题。很多新手代码一调整结构就白屏,多半栽在这里。
如果想保险,也可以用window.onload或DOMContentLoaded把初始化逻辑包起来,但那种写法会让代码多几层嵌套,学习阶段反而增加理解成本。
3.2 普通函数、箭头函数与this的坑
我在上面的代码里用了普通函数来定义heartX和heartY,但如果你看到其他版本用了箭头函数:
javascript复制const heartX = (t) => 16 * Math.pow(Math.sin(t), 3);
这两种写法在大部分场景下等价,但有一个重要差异:箭头函数没有自己的this,它继承外层作用域的this。在绘制动画这类场景里,箭头函数反而更省心——不用时刻惦记着this指谁。
新手最容易踩的坑是:在对象方法或者定时器回调里用了function() {},里面的this跟预期不一致,于是调用ctx时莫名报错。遇到这类问题,可以把普通函数换成箭头函数试试,或者干脆不在回调里依赖this,而是把需要的变量值提前存到局部变量里。
3.3 Canvas绘图API:路径、圆、填充的配合
beginPath、arc、fill三件套是Canvas绘图的入门组合。很多人看不懂这三行为什么要分开写。
beginPath相当于跟画布说“我要开始画一块新图形了”。arc相当于“在这块新图形里添加一个圆的轮廓”。fill相当于“把这块轮廓用颜色填满”。
每画一个粒子都要重新beginPath,是因为如果忘了,新的圆会跟上一次的圆形路径连成一块,fill的时候会得到一团奇怪的不规则形状。在画大量相同类型的图形时,这个“合在一起”的行为是Canvas性能优化的一种手段,但如果你不想要合并效果,就务必每次先beginPath。
3.4 Math对象:sin、cos、pow、PI在动画里的角色
这段代码里Math对象几乎无处不在。Math.sin(t)、Math.cos(t)是三角函数,用于计算爱心轮廓;Math.pow(x, 3)是幂运算,用来算三次方;Math.PI是圆周率,配合2 * Math.PI表示整圈;Math.random()是随机数,用来生成粒子大小和颜色;Math.floor是向下取整,把随机小数转成整数,这样色相值才合法。
我建议你把这几个函数单独在控制台里跑一跑,尤其打印一下Math.sin(0)、Math.sin(Math.PI / 2)、Math.sin(Math.PI)的结果,你会立刻理解三角函数在“描圆形路径”时为什么这么有用。它本质上帮你把“角度”转换成“坐标”。
3.5 requestAnimationFrame 为什么比 setInterval 更适合做动画
很多入门教程讲动画用的是setInterval(fn, 16),也就是每16毫秒执行一次,约等于60帧。但requestAnimationFrame更专业,原因有三点:
- 浏览器自动在每帧刷新前调用回调。
setInterval只管定时,不管浏览器是不是正在渲染,可能造成画面撕裂。 - 页面隐藏时自动暂停。
setInterval即使页面切到后台还在跑,白白消耗CPU;requestAnimationFrame会识别到页面不可见,把帧率降到0,省电。 - 帧率自适应屏幕刷新率。60Hz和120Hz的显示器上,它会自动匹配,不需要你手动判断设备。
所以在现代浏览器里,做动画的首选就是requestAnimationFrame。有的代码会加一层“兼容性降级”,在老旧环境里回退到setInterval,我们这里的例子不需要,但将来你写生产环境代码时值得留个心眼。
4. 爱心不动了:运行报错与动画卡死排查实录
代码是讲完了,但真到自己上手跑的时候,几乎每个人都会遇到几个问题。我把自己踩过的坑和网上最常见的问题整理成了一份排查记录,帮你少走弯路。
4.1 控制台报错Cannot read properties of null,canvas拿不到
这是所有Canvas动画里最经典的报错之一。报错信息一般是:
code复制Uncaught TypeError: Cannot read properties of null (reading 'getContext')
我早年第一次遇到时完全懵了,后来才明白:getElementById('heartCanvas')没找到元素,返回了null,你却还试图在null上调用getContext。原因不外乎两种:
- script标签放在canvas之前,执行顺序太早。
- canvas的
id与脚本里的不一致,比如HTML里写的是heartCanvas,脚本里写的是heart-canvas或heartCanvas(大小写搞错)。
排查方法很简单:打开浏览器开发者工具(按F12),切到Console面板,先看看document.getElementById('heartCanvas')到底返回了什么。返回null,按上面两条原因检查;返回元素对象,再看下一步。
4.2 双击打开HTML文件后白屏,但没报错
双击打开是file://协议访问文件。如果代码里引用了外部的JavaScript文件或字体、图片,并且路径写的是相对路径或绝对路径,有可能因为浏览器安全限制加载失败,导致画布空白。这虽然不直接报错,但行为会变得诡异。
我踩过一次这个坑:整个爱心动画文件我拆成了index.html和heart.js两个文件,双击打开时页面白屏,控制台报“Cross origin requests are only supported for protocol schemes: http...”,其实这是浏览器的本地文件跨域限制。解决办法有两个:一是全部写进一个HTML文件,也就是我前面用的内联script方式;二是起一个本地开发服务器,访问http://localhost。学习阶段我强烈推荐第一种,简单、零环境依赖。
4.3 代码写在链接里点了没反应:javascript:void(0) 到底是什么
有些教程会把爱心动画塞在<a>标签的href里,比如:
html复制<a href="javascript:void(0)" onclick="runHeart()">点我</a>
javascript:void(0)是一种古老的写法,它执行一段JavaScript表达式并让结果返回undefined,这样点击链接时页面就不会跳转。这里的void(0)是个运算符,void后面跟任何表达式都返回undefined。
为什么有人会看到javascript:void(0)就以为代码报错?是因为在控制台里输入或点击这类链接时,如果后面的表达式本身有语法错误,浏览器就会报Uncaught SyntaxError,但实际上void(0)本身是合法且无害的。如果你看到一个页面里的链接是href="javascript:void(0)",它只是占位,真正的行为逻辑在onclick里,点链接没反应,要检查的是事件处理函数有没有绑定成功,而不是怀疑void(0)写错了。
4.4 动画卡顿、闪烁、CPU很高
粒子数量从300改成2000,通常就开始掉帧了。原因是每一帧都要循环几千次计算,并执行几千次Canvas绘制。我的经验是:粒子数量控制在100-500之间,视觉上已经非常漂亮。如果你确实需要海量粒子效果,那就得考虑优化——比如用离屏Canvas缓存静态轮廓,或者用WebGL绘制,但这些是进阶话题,不展开。
闪烁问题的根源通常是clearRect和绘图顺序。如果先画完粒子再清屏,等于把刚画的东西又擦掉了,画面就会闪。一定要保证“清屏”逻辑在“绘制”逻辑之前。
我刚开始改代码时,把clearRect误删了,结果爱心慢慢变成一团红色的光雾。那其实是个很有趣的实验,但如果你想要清晰的爱心轮廓,清屏这步不能漏。
4.5 怎么快速定位一段不认识的爱心代码出错位置
一个很有用的技巧:在脚本第一行加console.log('script started'),在animate函数里加console.log('frame:', angle),然后打开控制台看输出频率和位置。如果只输出了script started而没有frame,说明脚本在进入动画循环前就报错了;如果frame在输出但画面不动,说明绘制逻辑有问题。用二分法逐步注释掉可疑代码段,很快就能锁定问题。
这种“加日志”的方法看起来很原始,但在排查任何一段不熟悉的代码时都非常高效,比死盯代码猜要快得多。
5. 让爱心更炫:参数调整表与三种扩展思路
看懂、跑通之后,下一步就是改成自己的东西。我把自己折腾过的几个方向整理一下,每个都是改几个数值或加一小段逻辑的事。
5.1 参数速查表
先给你一张常用参数调整表,对照着改就知道效果了。
| 参数 | 当前值 | 作用 | 调整建议 |
|---|---|---|---|
count |
300 | 粒子总数 | 100-500之间较合适,越大越密但越耗性能 |
scale |
12 | 爱心整体缩放 | 画布600x600时8-16都OK,越大爱心越大 |
p.size |
1-3 | 粒子半径 | 在Math.random() * n + m里调,数字越大粒子越粗 |
angle += 0.02 |
0.02 | 旋转动画速度 | 改成0.05摆动更快,改成0则完全静止 |
rotate(Math.sin(angle) * 0.06) |
0.06 | 摆动幅度 | 改成0.2爱心的呼吸感更明显,但太大容易变形 |
颜色色相范围 |
-10到50 | 红色到橙黄 | 改成180到260会变蓝色系,改80到160变绿色系 |
0.08 逼近速度 |
0.08 | 粒子移动速度 | 改成0.03有慢镜头感,改成1直接瞬移 |
5.2 加一个鼠标点击生成小爱心的交互
如果觉得单颗大爱心不够玩,可以加一个点击事件:鼠标在画布上点击时,在那个位置生成一串小粒子,散开成爱心形状。
实现思路是:定义一个数组extraParticles,每次点击时把一个小爱心所有粒子的目标坐标临时设到鼠标位置,然后每一帧同样做“渐进逼近”更新。代码大致是:
javascript复制canvas.addEventListener('click', function(event) {
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
for (let i = 0; i < 50; i++) {
const t = (i / 50) * 2 * Math.PI;
extraParticles.push({
currentX: mx,
currentY: my,
targetX: mx + heartX(t) * 3,
targetY: my - heartY(t) * 3,
size: Math.random() * 2 + 1,
color: `hsl(${Math.floor(Math.random() * 60)}, 80%, 65%)`
});
}
});
然后在animate里遍历extraParticles并做同样的位置逼近和绘制,因为粒子从点击位置慢慢展开拼成爱心轮廓,效果非常像碎裂后重组。
5.3 让爱心渐变色、拖尾和背景切换
我调试时发现,如果把clearRect改成“用半透明颜色覆盖画布”,就能得到拖尾效果:
javascript复制ctx.fillStyle = 'rgba(10, 10, 46, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
每一帧都用带透明度的背景色覆盖,而不是完全清空,粒子运动的残影就会留在画布上,产生拖尾流光的质感。背景色改成其他颜色时,注意透明度数值越小拖尾越长,但粒子也会越淡。
渐变色的做法更简单:不把颜色写死,而是根据粒子在爱心轮廓上的位置(也就是t的占比)动态生成色相。比如hsl(${Math.floor(i / count * 360)}, 80%, 65%),爱心会从红渐变到紫再回到红,看起来更丰富。
5.4 把文字藏进爱心动画里
这个玩法有点进阶,但我很喜欢。思路是:先创建一个离屏Canvas,用ctx.fillText('你的文字', ...)把文字画上去,然后通过getImageData读取文字区域的像素位置,把那些有颜色的点作为粒子的目标坐标,让粒子拼成文字形状。
这样做的好处是,不仅限于爱心,任何图形、任何文字,只要你能画到Canvas上,就能用粒子重新拼出来。我有时会做一个“先散成爱心、再聚成文字”的循环动画,视觉冲击力很强。核心代码就多了一个“读取像素坐标”的环节:
javascript复制const offCanvas = document.createElement('canvas');
const offCtx = offCanvas.getContext('2d');
offCanvas.width = 300;
offCanvas.height = 200;
offCtx.font = 'bold 80px sans-serif';
offCtx.fillStyle = '#fff';
offCtx.fillText('LOVE', 20, 100);
const imageData = offCtx.getImageData(0, 0, 300, 200);
// 遍历 imageData.data,每隔4个字节是一个像素的RGBA
拿到像素数据后,把alpha值大于128的像素坐标收集起来,再用这些坐标做粒子的目标位置。这个思路能延伸出很多玩法,算是Canvas动画里值得自己动手实现的一个小里程碑。
最后,说点我自己的体会
这段爱心代码我前前后后折腾过很多次,最大的感受是:它看起来是一行一行“死板”的代码,背后其实是一整套“数学坐标 → 屏幕坐标 → 逐帧绘制 → 动画循环”的完整思维链。你把它吃透之后,再看任何基于Canvas的粒子动画、烟花效果、文字飘散,都会有一种“原来如此”的豁然开朗。
我最开始学的时候,最喜欢的做法是故意把代码改坏:把clearRect删掉,把scale改成100,把Math.sin改成Math.cos,然后看画面变成了什么样子。每次“搞破坏”都会让我更清楚地理解那个参数的真实作用。所以我强烈建议你也这么做——报错不可怕,浏览器控制台里的每一行红字都是免费的老师。改多了、看多了、错多了,这种代码对你来说就不再是一团黑盒,而是可以随手操纵的小玩具。
