最近刷视频总能看到那种会动、会变色的爱心页面评论区清一色在求源码。真把需求拆开看核心就是一套 HTML CSS JavaScript 的动态爱心实现被《点燃我温暖你》带火之后大家习惯叫它“李峋同款爱心”最受欢迎的就是粉色和蓝色两个主题。这个项目看起来花哨实际技术含量集中在一个点上怎么用代码让粒子像有生命一样汇聚成一个持续呼吸的爱心形状。这篇文章我会从心形数学公式讲起到 Canvas 粒子系统的构建再到完整的粉色/蓝色双主题交互源码把整个实现链路一次讲透新手跟着敲也能跑出效果。1. 李峋同款爱心拆解不是单纯一个html文件而是一套组合效果1.1 这个动态爱心到底做了什么为什么看着高级先把我做出来的成品效果说清楚你才知道自己要从零实现什么。页面打开后整个屏幕是深色背景一开始能看到星星点点的粒子散落在各个位置大约一两秒内这些粒子会像被磁铁吸引一样从四面八方涌向画面中央逐渐拼出一个完整的爱心轮廓。爱心外围有一条发光描边会随着时间变化不断“呼吸”——亮度、粗细、光晕都在做正弦波动。爱心内部被粒子填满粒子的颜色整体是粉色系点击右上角按钮后所有粒子会在零点几秒内平滑过渡成蓝色系不是生硬的跳变而是渐变色。除了变色我还加了两层常用的交互。第一层是文字模式点击“文字模式”按钮原本拼成爱心的粒子会重新飞散组成 LOVE 字样但外部爱心描边仍然留在原地呼吸形成“文字在爱心里”的视觉效果。再点一下粒子又飞回爱心形状。第二层是画布点击鼠标点击任意位置粒子会瞬间向外炸散再点一下或者等一秒粒子自动回位。这两个交互在“李峋同款爱心”的各类换皮版本里非常常见。之所以这个效果给人“高级感”是因为它同时做对了三件事有明确的目标形态爱心/文字、有过渡动画粒子飞入飞出、有持续动态细节呼吸光效。而这三件事恰好就是 Canvas 粒子系统最核心的能力范围。1.2 技术选型为什么用 Canvas 2D 而不是 CSS 动画做这种动态爱心社区里主要有三条技术路线。第一条是纯 CSS 3D 方案通过一堆 div 拼装爱心再用 transform: rotate3d / translateZ 做翻转和立体效果。这个方案做静态 3D 爱心很合适但要做粒子汇聚、形状切换这类逐帧变化的动画CSS 就会非常吃力因为你需要维护几十上百个 DOM 节点的状态浏览器布局和样式计算会拖后腿。第二条是 SVG CSS/SMIL 动画适合做轨迹动画和路径描边爱心的 outline 呼吸效果用 SVG 做确实很顺手但粒子数量一多SVG 的 DOM 节点和 transform 开销同样会爆。第三条就是我选的 Canvas 2D JavaScript 逐帧绘制。所有粒子只是画布上的小圆点每帧通过 requestAnimationFrame 更新坐标再重新绘制。3000 多个粒子在 Canvas 2D 下性能完全没问题而且粒子之间的逻辑可以完全由代码控制想怎么飞就怎么飞。整个项目不依赖任何外部库一个独立的 html 文件就能跑对于想练手前端基础的同学来说非常友好。选择 Canvas 还有一个隐藏优势动态爱心的“目标坐标”不是死数据而是通过数学公式实时算出来的。这意味着你可以随时调整爱心大小、位置、比例甚至把爱心路径换成任何其他闭合曲线自由度远高于切图方案。2. 画一个标准爱心参数方程与坐标换算2.1 心形参数方程的关键写法爱心形状的画法我强烈推荐用参数方程来算而不是网上复制图片素材。经典公式长这样[ x 16 \sin^3(t) ] [ y 13\cos(t) - 5\cos(2t) - 2\cos(3t) - \cos(4t) ]这里的 t 从 0 取到 (2\pi)对应爱心的完整轮廓。用 JavaScript 写出来特别干净function heartPoint(t, scale, cx, cy) { const x 16 * Math.pow(Math.sin(t), 3); const rawY 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); return { x: cx x * scale, y: cy - rawY * scale }; }为什么用这个方程因为它直接给出爱心边界上每个点的坐标我只要让 t 按固定步长取值就能得到一串均匀分布的心形轮廓点这些点既能用来画描边也能用来生成内部粒子的目标位置。如果用图片或者 SVG path把外部轮廓坐标提取出来反而要多走一步。2.2 方向、缩放与画布坐标系的换算直接套用这个公式会踩一个非常隐蔽的坑屏幕坐标系和数学坐标系的方向不一样。Canvas 的 y 轴是向下增长的而数学坐标系里 y 轴通常向上。如果直接把公式算出的 y 当作画布坐标画出来的爱心会上下颠倒尖角朝上整个形状看起来是翻转的。解决方式只有一个在返回坐标时对 y 取反canvas 里就是cy - rawY * scale。这个细节不处理好后面所有粒子目标点全都会错位。爱心绘制到画布上还要解决缩放和居中问题。原始方程生成的心形宽度大约是 32 个单位高度约 35 个单位。所以缩放系数可以根据屏幕最短边动态算const size Math.min(W, H); const scale size * 0.022; const cx W / 2; const cy H / 2 - size * 0.01;这样爱心宽度大约是屏幕最短边的七成左右视觉上比例最舒服。cy 稍微往上偏移一点是为了抵消底部提示文字的占位让爱心在视觉上更居中。2.3 内部粒子目标点让爱心不只是空壳子我最终想要的效果是爱心内部被粒子填满而不仅是一圈轮廓。所以光有轮廓坐标还不够还需要在爱心内部生成均匀分布的目标点。常见做法是先用公式随机取一个爱心轮廓点然后用随机半径向中心收缩for (let i 0; i 3200; i) { const t Math.random() * Math.PI * 2; const p heartPoint(t, scale, cx, cy); const r Math.sqrt(Math.random()); heartTargets.push({ x: cx (p.x - cx) * r, y: cy (p.y - cy) * r }); }这里有个小细节半径缩放用的是Math.sqrt(Math.random())而不是直接Math.random()。直接随机的话粒子会过度集中在爱心中间边缘密度明显下降。开根号的作用是让点按面积均匀分布边缘和内部都有足够的粒子拼出来的爱心才饱满自然。3. 让文字“长”出粒子离屏Canvas取像素点3.1 getImageData 取像素的原理文字模式是李峋同款爱心最具辨识度的特征之一实现方式不复杂核心就是“离屏 Canvas getImageData”。思路分三步。第一步在内存里创建一个看不见的 Canvas把 LOVE 文字画上去注意文字填充的是纯白色背景保持透明。第二步调用 getImageData 方法把整张画布按像素点读出来得到一个包含所有像素 RGBA 值的大数组。第三步遍历像素数组检查每个像素的 alpha 通道值也就是透明度alpha 大于 128 的位置就说明这个像素属于文字区域把它的坐标记下来。把这些坐标存成一个数组 textPixels后续粒子系统就可以把这些坐标当作目标点。文字模式的原理和爱心模式完全一致只是目标点从“爱心内部坐标”换成了“文字像素坐标”。关键代码是这样function buildText() { const text LOVE; const fontSize Math.floor(Math.min(W, H) * 0.18); const off document.createElement(canvas); const octx off.getContext(2d, { willReadFrequently: true }); off.width Math.max(300, Math.floor(W * 0.9)); off.height Math.floor(fontSize * 1.5); octx.font 900 ${fontSize}px Arial Black, PingFang SC, Microsoft YaHei, sans-serif; octx.textAlign center; octx.textBaseline middle; octx.fillStyle #ffffff; octx.fillText(text, off.width / 2, off.height / 2); const imageData octx.getImageData(0, 0, off.width, off.height); const pixels imageData.data; textPixels []; const step 4; for (let y 0; y off.height; y step) { for (let x 0; x off.width; x step) { const alpha pixels[(y * off.width x) * 4 3]; if (alpha 128) { textPixels.push({ x: x (W - off.width) / 2, y: y (H - off.height) / 2 }); } } } }3.2 采样步长与粒子密度的平衡遍历像素的时候我设置了step 4意思是每隔 4 个像素采样一次。这个数字非常关键step 越小采样点越多文字越精细但粒子数量会爆炸式增长。step 越大采样点越稀疏粒子数少、性能好但文字边缘会出现明显的锯齿和空洞。在实际测试中桌面浏览器全屏窗口下3200 个粒子刚好能把 LOVE 四个字母的轮廓和内部填充完整表达出来。如果你把 step 改成 2同等尺寸下采样点可能到一万以上粒子系统就会从流畅变成卡顿。所以我建议首先保证粒子总数在一个安全范围再去微调 step。更精细的调参技巧我会在第 5 章参数速查表里详细说明。3.3 字体加载问题取像素最常见的空白坑buildText 这个函数有个很容易踩的坑如果你使用自定义字体比如从网上引入的 woff 文件而字体还没加载完成就执行 fillText画布上可能什么都不会渲染getImageData 读出来的全是最初的透明像素textPixels 就会是空数组。表现就是点击“文字模式”后粒子全都乱飞因为它们找不到目标坐标。我的解决思路是两招同时用。第一招优先使用系统字体像 Arial Black、PingFang SC、Microsoft YaHei这些字体基本不需要外部加载第二招在页面初始化后用document.fonts.ready.then(...)再重新执行一次 buildText 和 applyTargets确保字体就绪后能刷新一次文字像素点。这样即使字体加载慢最终也能正确渲染。4. 粒子系统吸附、延迟与三种交互状态4.1 粒子类与吸附算法所有动态效果的灵魂都在粒子系统。每个粒子其实就是一个对象记录当前坐标、目标坐标、半径、透明度以及一个“出生时间”。核心结构如下class Particle { constructor() { this.x Math.random() * W; this.y Math.random() * H; this.target { x: this.x, y: this.y }; this.r 1 Math.random() * 1.6; this.alpha 0.5 Math.random() * 0.5; this.born 0; this.startDelay 0; } setTarget(t) { this.target { x: t.x (Math.random() - 0.5) * 5, y: t.y (Math.random() - 0.5) * 5 }; } update() { const elapsed performance.now() / 1000 - this.born; if (elapsed this.startDelay) return; const ease 0.055; this.x (this.target.x - this.x) * ease; this.y (this.target.y - this.y) * ease; } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.fill(); } }粒子运动我没有用物理引擎就是一个最简单的线性插值吸附每一帧把当前位置和目标位置的差值乘以一个系数 0.055然后加到当前位置上。这个算法的特点是“先快后慢”粒子离目标远的时候移动速度快离目标近的时候速度逐渐降下来看起来就像被磁铁吸过去一样比匀速直线运动自然得多。这个系数需要控制好0.055 是我反复试验后觉得舒服的值。如果调大到 0.1 以上粒子到达目标后容易产生“过头”的振荡反而显得不稳如果调小到 0.02粒子会慢悠悠地挪半天观感拖沓。4.2 粉蓝主题切换颜色插值而非直接换色很多人以为切换主题就是直接改一个颜色变量再重新绘制实际跑一遍就会发现直接换色会导致整个画面的颜色生硬跳变非常突兀。我采用的是“当前颜色向目标颜色插值”的策略。初始化时定义两个颜色对象const currentRGB { r: 255, g: 79, b: 154 }; const targetRGB { r: 255, g: 79, b: 154 };点击主题按钮后只修改 targetRGB 的数值然后在每一帧动画里让 currentRGB 的三个通道分别向 targetRGB 靠近function updateThemeColor() { const ease 0.06; currentRGB.r (targetRGB.r - currentRGB.r) * ease; currentRGB.g (targetRGB.g - currentRGB.g) * ease; currentRGB.b (targetRGB.b - currentRGB.b) * ease; }这样颜色是渐变的整个过程大约零点几秒视觉上非常顺滑。粉色主题我用的是 RGB(255, 79, 154)就是那种偏玫红的粉蓝色主题我用的是 RGB(79, 143, 255)明亮但不刺眼。这两个值也是网上各种版本里口碑比较好的配色。4.3 文字/爱心模式与点击炸散恢复三种交互状态看起来复杂底层逻辑是一致的改变粒子的目标坐标。爱心模式粒子目标点设为 heartTargets 数组中的点。文字模式粒子目标点设为 textPixels 数组中的点。炸散模式粒子目标点随机设为画布外或画布边缘的点。切换模式的函数只需要重新采样目标数组再给每个粒子 setTarget 一次同时重置一个随机延迟时间粒子就会错开时间“飞”过去。这里重置延迟很重要如果没有延迟所有粒子同时出发同时到达画面看起来像是整体平移少了很多层次感。加了 0 到 0.7 秒的随机延迟后粒子会一批批涌向目标会有一种“活过来”的动态感。炸散模式我绑定在画布点击事件上点击后先让所有粒子散开一秒后自动恢复当前模式。如果用户在这期间再点一次就立刻恢复。恢复的时候也要重新 applyTargets让粒子回到对应的爱心或文字目标点。5. 完整源码与参数速查5.1 可直接复制的完整HTML文件我把上面所有模块整合成一个完整可运行的 html 文件。直接复制保存成 index.html然后用浏览器打开就能看到效果。没有引入任何外部依赖也不需要启动本地服务器。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title李峋同款动态爱心粉 / 蓝/title style html, body { margin: 0; padding: 0; overflow: hidden; background: #0d0d1a; height: 100%; } #heartCanvas { display: block; } #tips { position: fixed; bottom: 18px; left: 0; right: 0; text-align: center; color: rgba(255,255,255,.6); font-size: 13px; font-family: PingFang SC, Microsoft YaHei, sans-serif; pointer-events: none; z-index: 10; } #controls { position: fixed; top: 16px; right: 16px; display: flex; gap: 10px; z-index: 10; } #controls button { background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.25); color: #fff; padding: 8px 14px; border-radius: 20px; font-size: 13px; cursor: pointer; -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); transition: background .2s; user-select: none; } #controls button:hover { background: rgba(255,255,255,.24); } media (max-width: 600px) { #controls { top: 10px; right: 10px; gap: 6px; } #controls button { padding: 6px 10px; font-size: 12px; } } /style /head body canvas idheartCanvas/canvas div idtips点击画面散开 / 汇聚 右上按钮切换文字与主题/div div idcontrols button idmodeBtn文字模式/button button idthemeBtn切换蓝色/button /div script const canvas document.getElementById(heartCanvas); const ctx canvas.getContext(2d); const modeBtn document.getElementById(modeBtn); const themeBtn document.getElementById(themeBtn); const themes { pink: [255, 79, 154], blue: [79, 143, 255] }; const themeNames Object.keys(themes); let themeIndex 0; let W 0; let H 0; let DPR 1; let heartPath []; let heartTargets []; let textPixels []; let particles []; let mode heart; let bursting false; let burstTimer null; const currentRGB { r: 255, g: 79, b: 154 }; const targetRGB { r: 255, g: 79, b: 154 }; function heartPoint(t, scale, cx, cy) { const x 16 * Math.pow(Math.sin(t), 3); const rawY 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); return { x: cx x * scale, y: cy - rawY * scale }; } function buildHeart() { heartPath []; heartTargets []; const size Math.min(W, H); const scale size * 0.022; const cx W / 2; const cy H / 2 - size * 0.01; const OUTLINE_COUNT 360; for (let i 0; i OUTLINE_COUNT; i) { const t (i / OUTLINE_COUNT) * Math.PI * 2; heartPath.push(heartPoint(t, scale, cx, cy)); } const INNER_COUNT 3200; for (let i 0; i INNER_COUNT; i) { const t Math.random() * Math.PI * 2; const p heartPoint(t, scale, cx, cy); const r Math.sqrt(Math.random()); heartTargets.push({ x: cx (p.x - cx) * r, y: cy (p.y - cy) * r }); } } function buildText() { const text LOVE; const fontSize Math.floor(Math.min(W, H) * 0.18); const off document.createElement(canvas); const octx off.getContext(2d, { willReadFrequently: true }); off.width Math.max(300, Math.floor(W * 0.9)); off.height Math.floor(fontSize * 1.5); octx.font 900 ${fontSize}px Arial Black, PingFang SC, Microsoft YaHei, sans-serif; octx.textAlign center; octx.textBaseline middle; octx.fillStyle #ffffff; octx.fillText(text, off.width / 2, off.height / 2); const imageData octx.getImageData(0, 0, off.width, off.height); const pixels imageData.data; textPixels []; const step 4; for (let y 0; y off.height; y step) { for (let x 0; x off.width; x step) { const alpha pixels[(y * off.width x) * 4 3]; if (alpha 128) { textPixels.push({ x: x (W - off.width) / 2, y: y (H - off.height) / 2 }); } } } } class Particle { constructor() { this.x Math.random() * W; this.y Math.random() * H; this.target { x: this.x, y: this.y }; this.r 1 Math.random() * 1.6; this.alpha 0.5 Math.random() * 0.5; this.born 0; this.startDelay 0; } setTarget(t) { this.target { x: t.x (Math.random() - 0.5) * 5, y: t.y (Math.random() - 0.5) * 5 }; } resetDelay() { this.born performance.now() / 1000; this.startDelay Math.random() * 0.7; } update() { const elapsed performance.now() / 1000 - this.born; if (elapsed this.startDelay) return; const ease 0.055; this.x (this.target.x - this.x) * ease; this.y (this.target.y - this.y) * ease; } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.fill(); } } function sampleTargets(source) { const count Math.min(particles.length, source.length); const shuffled source.slice().sort(() Math.random() - 0.5); for (let i 0; i count; i) { const index Math.floor(i * source.length / count); particles[i].setTarget(shuffled[index]); particles[i].resetDelay(); } for (let i count; i particles.length; i) { particles[i].setTarget({ x: Math.random() * W, y: Math.random() * H }); particles[i].resetDelay(); } } function applyTargets() { const source mode heart ? heartTargets : textPixels; if (!source || source.length 0) return; sampleTargets(source); } function initParticles() { const count Math.min(3200, Math.max(1200, Math.floor(W * H / 380))); particles []; for (let i 0; i count; i) { particles.push(new Particle()); } applyTargets(); } function resize() { DPR Math.min(window.devicePixelRatio || 1, 2); W window.innerWidth; H window.innerHeight; canvas.width Math.floor(W * DPR); canvas.height Math.floor(H * DPR); canvas.style.width W px; canvas.style.height H px; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); buildHeart(); buildText(); initParticles(); } function updateThemeColor() { const ease 0.06; currentRGB.r (targetRGB.r - currentRGB.r) * ease; currentRGB.g (targetRGB.g - currentRGB.g) * ease; currentRGB.b (targetRGB.b - currentRGB.b) * ease; if (Math.abs(currentRGB.r - targetRGB.r) 0.5) currentRGB.r targetRGB.r; if (Math.abs(currentRGB.g - targetRGB.g) 0.5) currentRGB.g targetRGB.g; if (Math.abs(currentRGB.b - targetRGB.b) 0.5) currentRGB.b targetRGB.b; } function getColor(alpha) { return rgba( Math.round(currentRGB.r) , Math.round(currentRGB.g) , Math.round(currentRGB.b) , alpha ); } function drawHeartOutline() { const time performance.now() / 1000; const pulse 0.5 0.5 * Math.sin(time * 2); ctx.save(); ctx.strokeStyle getColor(1); ctx.globalAlpha 0.35 pulse * 0.4; ctx.lineWidth 1.5 pulse * 1.5; ctx.shadowColor getColor(1); ctx.shadowBlur 18 pulse * 22; ctx.beginPath(); for (let i 0; i heartPath.length; i) { const p heartPath[i]; if (i 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.stroke(); ctx.globalAlpha 0.2 pulse * 0.25; ctx.lineWidth 0.8; ctx.shadowBlur 0; ctx.stroke(); ctx.restore(); } function tick() { ctx.fillStyle #0d0d1a; ctx.fillRect(0, 0, W, H); updateThemeColor(); ctx.fillStyle getColor(1); for (const p of particles) { p.update(); p.draw(); } drawHeartOutline(); requestAnimationFrame(tick); } function setTheme(color) { targetRGB.r color[0]; targetRGB.g color[1]; targetRGB.b color[2]; } themeBtn.addEventListener(click, () { themeIndex (themeIndex 1) % themeNames.length; setTheme(themes[themeNames[themeIndex]]); themeBtn.textContent themeNames[themeIndex] pink ? 切换粉色 : 切换蓝色; }); modeBtn.addEventListener(click, () { if (bursting) return; mode mode heart ? text : heart; applyTargets(); modeBtn.textContent mode heart ? 文字模式 : 爱心模式; }); canvas.addEventListener(click, () { if (bursting) { clearTimeout(burstTimer); bursting false; applyTargets(); return; } bursting true; for (const p of particles) { p.setTarget({ x: W / 2 (Math.random() - 0.5) * W * 1.8, y: H / 2 (Math.random() - 0.5) * H * 1.8 }); } burstTimer setTimeout(() { bursting false; applyTargets(); }, 1000); }); function debounce(fn, wait) { let timer null; return function () { clearTimeout(timer); timer setTimeout(fn, wait); }; } window.addEventListener(resize, debounce(resize, 250)); resize(); requestAnimationFrame(tick); if (document.fonts document.fonts.ready) { document.fonts.ready.then(() { buildText(); applyTargets(); }); } /script /body /html5.2 参数调优速查表所有版本里最常被问到的问题就是“怎么调成我自己想要的样子”这里整理一张速查表参数位置默认值作用与调参建议粒子数量initParticlesW*H/380上下限 1200~3200数值越大越密性能开销越大低端机建议降到 2000 以下吸附系数 easeParticle.update0.055控制粒子飞向目标的速度0.1 以上容易振荡随机延迟 startDelayresetDelay0~0.7 秒控制粒子分批出发的错落感0 表示同时出发采样步长 stepbuildText4控制文字取点密度改小会变精细但粒子数暴涨爱心缩放 scalebuildHeartsize * 0.022控制爱心整体大小改大覆盖率更高粒子半径 rParticle 构造1 ~ 2.6调大粒子更粗壮但轮廓细节会变模糊粉色主题 RGBthemes.pink255, 79, 154任何想要的粉色都可以蓝色主题 RGBthemes.blue79, 143, 255想要天蓝或藏青直接改描边呼吸速度drawHeartOutlinesin(time * 2)2 是每秒呼吸次数改成 1 会变得缓慢温柔如果你想把 LOVE 改成中文“我爱你”直接改 buildText 里的 text 字段同时注意把字体栈改成包含中文字体的系统字体否则可能出现取点空白。如果只想保留单一颜色不想要切换按钮删掉 themeBtn 事件绑定和 DOM 按钮即可。6. 实测中踩过的坑与修复方案6.1 高清屏模糊问题第一次跑通代码时我把 canvas 的宽高直接设置成了 window.innerWidth 和 window.innerHeight。在普通 Windows 屏幕上看没问题但在高分屏MacBook、手机上打开整个爱心边缘发虚像蒙了一层纱。原因是 Canvas 的 CSS 尺寸和物理像素尺寸不一致导致的。高分屏一个 CSS 像素对应多个物理像素Canvas 默认只用一个物理像素来渲染自然就模糊了。解法是读取 devicePixelRatio把画布的实际分辨率放大到物理像素级别DPR Math.min(window.devicePixelRatio || 1, 2); canvas.width Math.floor(W * DPR); canvas.height Math.floor(H * DPR); ctx.setTransform(DPR, 0, 0, DPR, 0, 0);这里限制 DPR 最大是 2是因为再高的分辨率对视觉提升很小但粒子绘制压力会翻倍没必要。6.2 resize 触发导致卡顿和粒子重置问题桌面浏览器调整窗口大小的时候resize 事件会高频触发。如果每次触发都重新 buildHeart、buildText、initParticles页面会明显卡顿甚至出现粒子反复“满屏乱飞”的现象。我的做法是在 resize 事件外面包一层 debounce让函数在窗口停止调整之后 250 毫秒再执行。这样拖动窗口时不会反复重建粒子系统只有松开窗口后才会重新适配尺寸。另外要注意 resize 里重新 buildText 属于重量级操作因为 getImageData 要读取大量像素数据。如果以后想不断优化可以额外加一个“只在宽高变化超过一定阈值时才重建”的判断但目前的 debounce 方案在绝大多数场景下已经够用。6.3 性能上限与移动端适配粒子数量越多动画越流畅度越差这是 Canvas 2D 无法回避的问题。实测下来在我的中端笔记本上3200 个粒子稳定 60 帧没有压力在手机上如果粒子数超过 3000同时爱心描边开着 shadowBlur帧率会明显下降。所以代码里我给粒子数设了上下限const count Math.min(3200, Math.max(1200, Math.floor(W * H / 380)));手机屏幕小W*H 小算出来的粒子数会自动降低。如果你发现低端机还是卡优先把 shadowBlur 去掉再考虑降低粒子数。shadowBlur 是 Canvas 渲染里的性能大户我可以接受一个没有光晕但帧率稳定的版本也不希望满屏卡顿。6.4 运行环境相关提醒本地直接双击 index.html 打开Canvas 的 getImageData 是能正常工作的因为我没有加载任何外部图片资源画布不会被污染。但如果你以后改造这个项目引入了外部图片或者跨域字体再用 getImageData 读取像素浏览器会因为安全策略直接抛错画布内容会变成空白这一点越早了解越好。移动端还有一个体验细节给 canvas 绑定 click 事件在大多数现代浏览器上没问题但有些老 Android WebView 会对点击事件做约 300ms 的延迟判断导致点击散开效果“慢半拍”。可以用 CSS 的touch-action: manipulation消除这个延迟或者把事件绑定改成更底层的 pointerdown。实测下来touch-action: manipulation是改动最小、效果最稳定的方案。这套项目做下来我的感受是它非常适合作为前端 Canvas 入门的第一份完整案例因为粒子系统、坐标换算、像素级渲染、动画循环这些概念在项目里全都能碰到而且每一步都有很直观的视觉反馈。像我这种习惯边写边看效果的人只要看到爱心成型就会忍不住继续加新互动。你如果上手之后想继续扩展可以尝试给爱心加背景音乐、把文字模式改成弹幕式滚动、或者叠加一层 3D 翻转效果所有思路在这个粒子框架之上都很容易实现。