
“Ready, Set, BANG ” 这个项目代号第一次出现在我面前时我第一反应是这大概是一个点击后炸出满屏爱心的小 demo。确实它最后被做成了一个偏营销互动方向的动效组件但真正让我想写这篇文章的不是那颗爱心炸得有多漂亮而是这个看似只有几行动画代码的效果在落到不同页面、不同机型、不同交互场景时会遇到一批你在 CodePen 上根本看不到的问题。把“一次点击 → 炸开爱心”这个流程做出来可能只需要半天。但要把它变成可以放心扔进生产环境、能应付弱网低端机、能让设计师自己调参数、又不会成为页面性能瓶颈的组件至少还需要补上几块关键拼图。这篇文章会从实现方案选型开始逐步拆到一个最小可运行的 Canvas 粒子系统再往下聊性能、参数化、接口设计、测试和上线检查。我不会把 demo 和工程混为一谈。1. 先想清楚这个“爆炸”效果到底在解决什么问题1.1 它不只是动画是一种用户反馈语言很多前端同学拿到这种需求时第一反应是把“BANG”理解成一个动画效果点击后产生一堆元素元素飞出去慢慢消失结束。但如果你只把它当作动画方案大概率会写成往 body 里不断插入 DOM 节点然后监听动画结束删除节点。对于几次点击来说这没问题甚至代码很简洁。可一旦这个效果出现在高频交互里比如连续点击、长按触发、多指触摸DOM 节点的创建和销毁就会变成一场灾难。我更愿意把这个效果理解成一种“反馈语言”。用户做了某个动作页面需要用一种符合品牌气质的、有情绪的反馈告诉他你触发了而且触发了成功。爱心爆炸是一种比按钮变色更有温度、也更有视觉冲击力的表达。它要承担的任务不是“让画面热闹一点”而是“让用户明确感知到操作结果”。所以在这个前提下第一个问题不是“用什么技术做”而是“这个反馈语言会在什么场景下被反复使用”。如果只是一次性的活动头图纯 CSS 也无所谓如果要放在签到、点赞、送礼物这类高频入口那就必须考虑性能、复用和降级。1.2 标题里的两个情绪符号其实是两条需求线“” 这个组合不是在开玩笑。它代表了这条需求的两条线 代表“情绪内核”视觉元素要软、要浪漫粒子形状是爱心颜色偏向红粉运动轨迹要有心动感。 代表“物理爆发”爆炸要有力度粒子要有初速度、重力、衰减不能只是慢悠悠飘出来。很多实现效果不佳不是因为动画库不行而是没有同时照顾这两条线。只做“爱心飘落”没有爆发感用户觉得普通只做“爆炸”粒子像碎纸片用户又觉得和品牌情绪不匹配。你在设计粒子系统时至少要同时定义粒子的形状、颜色、数量、初速度方向、速度大小、重力系数、透明度变化、缩放变化、生命周期。这本质上不是一个 CSS 动画能优雅搞定的问题它需要一整套物理和渲染逻辑。所以我最终选择了 Canvas 粒子系统作为基础方案。不是因为它最酷而是因为它能在“表现力”和“可控性”之间找到一个比较舒服的平衡点。2. 三个实现方案为什么我更推荐从 Canvas 开始2.1 纯 CSS 动画能做但很难控制爆炸粒子纯 CSS 实现这种效果通常有两个思路预置一堆爱心节点通过 JS 在点击时设置不同 transform 和 animation-delay。利用 CSS 自定义属性和 property 动态控制每个粒子的运动。优点很明显代码量少不需要维护 Canvas 生命周期随手就能在 React/Vue 组件里用。缺点也很明显当粒子数量较多时浏览器要对几十上百个 DOM 元素同时做 transform 和 opacity 动画合成层压力会变大。尤其是移动端每个粒子都是一块独立的纹理GPU 内存和合成时间都会上涨。更麻烦的是“精细控制”。CSS 动画适合做“从 A 到 B”的变化但爆炸粒子需要每帧根据物理参数计算位置、速度、旋转、透明度而且粒子之间可能有随机差异。你用 CSS 动画也可以做到但调试起来会非常痛苦你要不停修改 keyframes、贝塞尔曲线、延迟时间效果还是会显得“板”。2.2 Canvas 粒子系统表现力与可控性的平衡点Canvas 的优势在于整个爆炸过程是逐帧绘制的。粒子数量、大小、透明度、速度、重力、阻力、寿命全部是数据你用 JavaScript 控制这些数据再用requestAnimationFrame驱动绘制。这意味着粒子运动逻辑是“编程式”的而不是“声明式”的随机性、物理模拟、碰撞检测都更容易实现。绘制过程要自己管理性能但反过来也意味着你能用对象池、离屏 Canvas、像素压缩等手段精确控制开销。动画结束后的清理是显式的不会在 DOM 里留下一堆节点。对于“BANG”这种需要爆发感的效果Canvas 能让每个粒子拥有不同的初速度、发射角度、延迟和衰减表现力远高于 CSS 动画。2.3 WebGL 和第三方库要不要一开始就上WebGL 可以支持上千甚至上万个粒子性能天花板比 Canvas 2D 高很多。但代价是复杂度明显上升你不仅要写着色器程序还要处理纹理绑定、缓冲区和 GL 上下文生命周期。如果一个活动页最多同时出现几十到二三百个粒子Canvas 2D 完全够用没必要为了“性能指标好看”而引入 WebGL 工程成本。第三方库方面比如 PixiJS、Popmotion、GSAP或者专门的粒子库它们能帮你省掉很多底层工作。但引入一个几十 KB 的依赖只为了做一个点击爆炸效果对大多数项目来说性价比不高。我更建议先手写一个最小 Canvas 粒子系统等确实需要更复杂的能力比如 3D 变换、大量粒子、特殊融合效果时再评估引入 WebGL 库。核心判断这种“中等量级”的动效Canvas 2D 往往是性价比最高的实现方式。它不是最强方案但它是普通前端团队最容易长期维护的方案。3. 从零手写一个最小可运行的爱心爆炸组件3.1 准备一个干净的工作环境在不依赖任何框架的前提下先确认环境里能正常使用 Canvas 和 ES Module。我一般会这么搭一个最小工程mkdir ready-set-bang cd ready-set-bang npm init -y然后用一个最普通的 Vite 工程跑起来或者干脆先不打包直接在浏览器里加载一个index.html文件。先别急着接组件库和 UI 框架Clean 的环境能让你把注意力集中在动效本身。例如创建一个index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleReady, Set, BANG /title style body { margin: 0; overflow: hidden; background: #fff0f5; } canvas { display: block; } /style /head body script typemodule src./src/main.js/script /body /html这样我们就有了一个干净的活动画布。后续所有逻辑都写在src/main.js里。3.2 以数据结构为核心粒子对象的设计很多人写 Canvas 效果时喜欢把“画一个爱心”和“粒子的运动”混在一起。其实更容易维护的做法是先用一个对象描述粒子的状态function createParticle(x, y, index) { const angle Math.random() * Math.PI * 2; const speed 2 Math.random() * 6; return { x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 2, size: 8 Math.random() * 12, color: hsl(${340 Math.random() * 20}, 80%, 65%), alpha: 1, life: 1, decay: 0.008 Math.random() * 0.012, rotation: Math.random() * Math.PI * 2, // 这里还可以加一个 shape 字段方便以后扩展成星星、圆形等 shape: heart }; }这个对象是核心数据结构。x和y是粒子当前位置vx和vy是水平和垂直速度size是颗粒大小color控制颜色alpha和life控制生命和透明度decay控制生命衰减速度rotation让爱心不至于像贴纸一样呆板。为什么一上来就把数据结构设计清楚因为后面的更新和绘制都只依赖这个对象。如果你后面想加“回弹”“被风吹散”等效果也只需要在这个对象上增加对应字段。3.3 主循环更新、绘制、回收Canvas 动效的核心是requestAnimationFrame驱动的循环。每一帧做三件事更新粒子位置和生命状态。清除画布。绘制所有存活的粒子。一个常见的最小实现长这样const canvas document.querySelector(canvas); const ctx canvas.getContext(2d); function resizeCanvas() { canvas.width window.innerWidth; canvas.height window.innerHeight; } window.addEventListener(resize, resizeCanvas); resizeCanvas(); let particles []; function explode(x, y) { for (let i 0; i 30; i) { particles.push(createParticle(x, y, i)); } } function updateParticle(p) { p.x p.vx; p.y p.vy; p.vy 0.18; // 模拟重力 p.vx * 0.98; // 水平方向轻微阻力 p.vy * 0.98; // 垂直方向轻微阻力 p.life - p.decay; p.alpha Math.max(0, p.life); p.rotation 0.02; } function drawParticle(p) { ctx.save(); ctx.globalAlpha p.alpha; ctx.translate(p.x, p.y); ctx.rotate(p.rotation); ctx.fillStyle p.color; // 这里写一个 drawHeart 函数根据 p.size 画爱心 drawHeart(ctx, 0, 0, p.size); ctx.restore(); } function loop() { ctx.clearRect(0, 0, canvas.width, canvas.height); particles particles.filter((p) p.life 0); particles.forEach((p) { updateParticle(p); drawParticle(p); }); requestAnimationFrame(loop); } loop();这里要特别注意particles particles.filter(...)这行。它不是在“删除”粒子而是把已经死掉的粒子从数组里移出去。如果一帧里粒子数量很多频繁创建和销毁数组会有一些垃圾回收压力但这是最简单的写法。性能优化章节会专门讲对象池的改造。3.4 把爆炸源绑定到点击位置这里的“爆炸源”就是点击坐标。监听点击事件后把事件坐标转成 Canvas 坐标系再调explodecanvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; explode(x, y); });看起来很简单但有一个容易踩的坑如果页面可以滚动或者 Canvas 不是全屏e.clientX和e.clientY需要减去rect.left和rect.top。如果页面有 CSS 缩放还要考虑canvas.width / rect.width的比例。这个在前面小样本测试阶段不容易暴露等到移动端真机测试时就会卡住你。至此一个“点击后爱心爆炸”的最小链路已经跑通。你可以打开浏览器点击屏幕看见满屏爱心爆发、飞散、消失。提醒先别急着加各种花哨效果。最小可运行版本的意义是确认输入、输出、生命周期和绘制这条链路是通的。这条链路不断后续优化才有意义。4. 真正难的部分不是特效而是参数与性能4.1 先小样本验证再调参数很多人拿到能跑的最小版本后第一步就是调粒子数量、速度、重力想让它“更炸”。我建议反过来先把粒子数量定在一个很小的范围比如每次点击 20 到 30 个确认整个流程稳定然后再逐步加量。为什么因为粒子数量增加会带来三方面变化CPU 计算量增加每一帧要更新更多粒子的位置、速度和透明度。GPU 绘制压力增加Canvas 画布上要绘制更多图形。垃圾回收压力增加如果粒子对象一直被创建和销毁内存颠簸会带来肉眼可见的卡顿。调参本身不是坏事但调参应该基于“小样本验证 → 观测 → 调整 → 再观测”的循环。不要一次性把粒子数量调到 300然后发现页面卡了才回头查原因。你先用 30 个粒子跑通再把 30 提到 6060 提到 120每一步都观测性能这样你才能知道是哪个环节最先扛不住。4.2 性能问题排查链路从卡顿开始反推如果上线后出现卡顿不要急着把“粒子数量改小”。先按这个顺序排查看现象。是点击瞬间卡顿还是持续掉帧点击瞬间卡顿往往和初始创建大量对象有关持续掉帧可能和主循环中每帧的重计算有关。看输入。事件坐标是否正常移动端touch事件是否被误处理成click的多次触发多次触发会导致粒子数量翻倍。看环境。浏览器窗口尺寸很大时Canvas 画布面积也很大每帧clearRect全屏重绘的开销自然高。移动端和低端机上尤其明显。看参数。粒子数量、粒子大小、透明度渐变计算、save/restore调用次数是否过多。看工具边界。当前 Canvas 是普通2dcontext是否因为外部 CSS 设置了backdrop-filter导致整层合成压力变大是否因为浏览器多标签页导致 rAF 被降频这一套顺序能帮你快速定位问题出在哪一层。而不是一上来就重写整个渲染引擎。4.3 对象池和离屏 Canvas 的实际价值当粒子数量从几十涨到几百时两个优化手段非常有效对象池和离屏 Canvas。对象池的思路是不要每次爆炸都new一个粒子对象。提前创建好一批粒子对象爆炸时从池子里取“空闲”对象来初始化动画结束后把对象归还池子。这样能大幅减少垃圾回收压力。const particlePool []; const maxPoolSize 500; function getParticle(x, y) { let p particlePool.pop() || createParticle(x, y, 0); p.x x; p.y y; // 重新初始化其他字段... return p; } function recycleParticle(p) { if (particlePool.length maxPoolSize) { particlePool.push(p); } }离屏 Canvas 的思路是爱心图案不需要每帧都重新画路径可以提前把一个爱心绘制到一个离屏 Canvas 上然后在主循环里用drawImage把这张小图贴到主 Canvas。这样可以省掉大量重复的路径计算和save/restore调用。const offscreen document.createElement(canvas); offscreen.width 64; offscreen.height 64; const offCtx offscreen.getContext(2d); drawHeart(offCtx, 32, 32, 24); // 每帧绘制时 ctx.drawImage(offscreen, p.x - p.size, p.y - p.size, p.size * 2, p.size * 2);这两个手段合在一起能把同一台机器上能承受的粒子数量提升一大截。但要注意对象池增加的是代码复杂度离屏 Canvas 增加的是内存占用。不是所有项目都必须上这两个优化如果你确认当前粒子数不超过 100最朴素的写法反而更容易维护。5. 把 demo 变成可复用的前端组件5.1 参数化让设计师也能调样式如果这个爆炸效果只在一个页面用一次代码写死没问题。但如果要在多个入口复用比如 “点赞”“送心意”“节日挂件” 都要用就必须把它封装成一个可配置组件。我一般会提供一个配置对象例如const defaultConfig { particleCount: 40, minSize: 6, maxSize: 16, speed: 3, gravity: 0.18, decay: 0.01, colors: [#ff4d6d, #ff758f, #ffb3c1], shape: heart, zIndex: 999, duration: 1200 };组件初始化时接收配置运行过程中所有计算都从配置读取。这样设计师或产品经理改参数时不需要打开源码只需要改配置对象。更重要的是后续如果要跑“不同机型的颜色/大小差异”也可以通过配置中心下发。5.2 组件接口设计触发方式、生命周期、销毁封装成组件时接口要尽量简单。一个比较自然的接口是这样的const bang new Bang({ container: document.getElementById(app), config: defaultConfig }); bang.explode(x, y); // 主动触发爆炸 bang.setConfig(partialConfig); // 动态调整参数 bang.destroy(); // 彻底销毁组件需要注意的细节组件初始化时不要立刻绑定很多全局事件只在触发时绑定需要的东西。组件要提供destroy方法手动移除 Canvas 节点、取消resize监听、取消requestAnimationFrame定时器。如果组件在 React/Vue 里使用要在组件unmount时调用destroy避免内存泄漏。5.3 多场景适配移动端、低端机、滚动页面移动端是这类动效最容易出问题的地方。需要额外处理使用touchstart或pointerdown代替click减少 300ms 延迟。判断设备是否支持 Canvas如果不支持直接降级为一个简单的 CSS 弹出效果。低端机或低电量模式下可以动态减少粒子数量和关闭旋转效果。如果页面本身是可滚动的长页爆炸时不要让 Canvas 全屏铺满并拦截滚动事件。更好的做法是使用一个position: fixed的 Canvas 层并设置pointer-events: none让点击穿透到下层。.bang-canvas { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 999; }这样既能在任意位置爆炸又不会遮挡页面交互。6. 上线前容易踩的坑与长期维护建议6.1 可访问性与动效降级动效很炫但对部分用户可能是干扰。比如有前庭障碍或眩晕问题的用户会希望关闭非必要的动画。低端机用户为了省电会把系统“减弱动态效果”打开。前端可以通过prefers-reduced-motion媒体查询来尊重用户偏好const reduceMotion window.matchMedia((prefers-reduced-motion: reduce)).matches; if (reduceMotion) { // 不做粒子爆炸只做一个简单的透明度变化 }如果你觉得这个判断影响了视觉效果可以把它做成可配置项默认开启自动降级但允许业务方在特定入口强制开启动画。6.2 测试重点不是像素一样的动画而是不崩、不卡、不阻塞这类动效的测试重点不是“每一帧是否和设计稿一模一样”而是连续点击 20 次后页面是否仍然流畅。动画结束后内存是否回落到初始水平。页面滚动时触发爆炸滚动是否被阻塞。切换到后台再回来动画是否能自动恢复。在低端机上同屏粒子数达到上限时是否会掉到 30fps 以下。你可以用 Chrome DevTools 的 Performance 面板录制一段交互再切到移动端模拟器测试。更简单的方式是直接在代码里挂一个计数器记录当前存活粒子数避免无上限的粒子堆积。function explode(x, y) { const count Math.min(config.particleCount, 200 - particles.length); if (count 0) return; // ... }6.3 什么时候该用现成库什么时候继续自己维护如果一个团队已经有成熟的动画库比如 GSAP、PixiJS并且大家都熟悉那你直接在库的基础上实现这个效果也是合理的。但如果项目里没有引入这些库只为了一次“爱心爆炸”就增加一个依赖我会比较谨慎。自己维护的成本在于你要持续关注 Canvas 在不同浏览器里的兼容性、移动端触摸事件差异、以及后续可能的新需求。不过由于这个组件的逻辑足够小代码量不会超过几百行自己维护完全可控。如果在开发过程中发现需求越来越复杂比如需要“多层粒子”“拖拽交互”“3D 变换”再做一次技术升级也不迟。先跑通一个最小版本再从真实反馈里决定是否扩大复杂度这是我特别想强调的一个工程习惯。“Ready, Set, BANG ” 这个名字本身就很有画面感一个点击一次爆发满屏心动。但做一个 demo 和做一个能长期跑在真实项目里的动效组件完全是两件事。前者只需要几行代码后者需要你想清楚数据结构、渲染循环、对象生命周期、性能边界、组件接口、设备兼容和降级策略。如果你现在正要写一个类似的交互效果我的建议很具体先不要急着调最好看的粒子形状把那 20 个粒子的爆炸流程跑通然后记录下当前设备的帧率、粒子数量和消耗再逐步增加复杂度。所有让你觉得“很厉害”的动效背后不过是一套清晰的数据结构加一个稳定的渲染循环再加一点克制的性能意识。