news 2026/10/1 16:06:44

Canvas粒子系统实战:李峋同款动态爱心HTML源码全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Canvas粒子系统实战:李峋同款动态爱心HTML源码全解析

最近刷视频总能看到那种会动、会变色的爱心页面,评论区清一色在求源码。真把需求拆开看,核心就是一套 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 lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <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 id="heartCanvas"></canvas> <div id="tips">点击画面:散开 / 汇聚 | 右上按钮:切换文字与主题</div> <div id="controls"> <button id="modeBtn">文字模式</button> <button id="themeBtn">切换蓝色</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> </html>

5.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 翻转效果,所有思路在这个粒子框架之上都很容易实现。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 16:06:37

2026实测12款论文AI智能降重工具,效果最稳的竟然是它!

最近真的太多人来问我&#xff1a;"论文 AI 率太高怎么办&#xff1f;学校要求查 AI 检测&#xff0c;连人工改的都不过&#xff01;" 我懂这种焦虑&#xff0c;因为我自己前阵子也踩过坑。各种号称能降低 AI 率的网站试了一圈&#xff0c;有的乱扣格式&#xff0c;有…

作者头像 李华
网站建设 2026/10/1 16:06:33

餐饮IPO冰封期,袁记食品的“供应链平台”启示

​港股餐饮IPO&#xff0c;年内难产。今年前8个月&#xff0c;103家公司在港交所敲钟&#xff0c;科技股占了一半&#xff0c;餐饮企业一家都没有。老乡鸡、极物思维、比格披萨的招股书失效之后&#xff0c;连重新递表的都没有&#xff0c;乃至已经上市的绿茶集团、遇见小面&am…

作者头像 李华
网站建设 2026/10/1 16:06:14

Windows 95光盘版安装全指南:从DOS启动盘到虚拟机实操

1. 为什么现在还有人折腾Win95光盘版提到Windows 95光盘版&#xff0c;年轻一点的读者可能只在博物馆或者虚拟机截图里见过&#xff0c;但对我们这些经历过拨号上网、软盘启动时代的人来说&#xff0c;Win95就是图形界面时代的真正起点。别管后来Win98、Win2000、XP多么经典&am…

作者头像 李华
网站建设 2026/10/1 16:06:05

ENOVIA PLM实施要点:对象模型、流程配置与制造集成避坑指南

简介&#xff1a;这是一份达索系统ENOVIA产品生命周期管理系统的概览教程文档&#xff0c;面向工业软件学习者、制造业信息化人员及产品研发管理岗位&#xff0c;用于快速理解PLM体系的整体框架与应用价值。资源为单个docx文本&#xff0c;约33KB&#xff0c;正文从ENOVIA的历史…

作者头像 李华
网站建设 2026/10/1 16:05:15

【C++进阶】C++ 11(上)

目录 1 C11 发展历史 2 列表初始化&#xff08;统一初始化&#xff09; 2.1 C98 时代的{}局限 2.2 C11&#xff1a;一切对象都可以使用{}列表初始化 3 std::initializer_list 产生背景 上篇小结 1 C11 发展历史 C11 是 C 第二个主要版本&#xff0c;也是自 C98 之后最重…

作者头像 李华