ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Canvas粒子系统打造点击爆炸动效:轻量、可定制、高性能

用Canvas粒子系统打造点击爆炸动效:轻量、可定制、高性能 “Ready, Set, BANG ”这个标题第一眼看上去像是游戏里的开火指令或者是某个线上发布流程的代号。但如果你是一名经常写页面交互的前端开发者你可能会心一笑这不就是“点击触发 → 动画播放 → 效果爆发”的三段式节奏吗把一个点击动作变成一次让人记住的视觉反馈正是很多网页在“仪式感”上欠缺的部分。我见过太多团队做这类特效时走两条弯路一是直接贴一张 GIF 素材体积大、清晰度差、风格难统一换个背景色就露馅二是用 CSS 死磕粒子飞散写几十个 keyframes结果效果生硬连挤压感都做不出来。真正能在一两天内搞定、且效果能打的方案反而是一个看起来有点“底层”的技术Canvas 粒子系统。这篇文章不会只给模板代码。我会从“为什么推荐用 Canvas 做爆炸类动效”讲起把粒子系统的核心概念拆明白然后带你从零写一个可复用的“心动爆炸”交互动画。点按页面任意位置会炸开一颗由红色心形粒子构成的“爱心爆弹”粒子飞散后会受重力影响回落像一场浪漫的微型烟花。整个过程不依赖任何框架纯 HTML CSS JavaScript 就能跑通非常适合直接嵌入个人主页、节日活动页或前端作品集。1. 这篇文章真正要解决的问题先问你一个问题当你需要在页面上实现一个“爆炸”类动效时第一反应是什么是去找一个现成的 JS 动画库还是用 CSS 硬写或者直接下载一张动图实际情况是这三条路都不够理想。动画库的问题在于“杀鸡用牛刀”。像 GSAP、Framer Motion 这类库很强大但如果你只需要一个几十毫秒的粒子飞出效果引入一整库的成本太高尤其是在移动端环境CSS 动效的问题在于“算不出物理规律”。爆炸类效果的精髓是粒子的随机方向、速度衰减、重力影响CSS 动画不是不能做但它的关键帧是离散的一旦粒子数量上到几十颗代码就急剧膨胀性能也开始吃紧GIF 素材就更不用说了它是一帧一帧的位图序列放大会糊、体积会胀、无法与页面背景交互。所以这篇文章要解决的痛点很具体如何用最轻量的原生技术栈实现一个可定制、可交互、性能可控的爆炸型动效。读完你会得到三样东西一套理解粒子动画的心智模型、一段能直接运行的完整代码、一份能在真实项目里落地时避开常见坑的清单。如果你正在做以下类型的工作这篇文章最值得读为自己的个人主页或作品集加一个让人印象深刻的点击反馈在公司活动页、节日专题页里做“点击送礼”“甜蜜告白”类互动在移动端 H5 中做轻量级特效但又不想引入重量级动画库想深入理解 Canvas 2D 动画与 requestAnimationFrame 配合的开发模式。2. 核心概念粒子系统与 Canvas 2D在写代码之前先把几个绕不开的概念讲明白。这节不会铺开讲图形学只讲你马上要用的那一部分。2.1 粒子系统不是“一个东西”而是一套流程“粒子系统”这个名字听起来高大上其实它本质上是一套循环流程。你只需要记住四个环节发射、更新、渲染、销毁。发射在某个位置生成一批粒子给每个粒子一个初始位置、速度、颜色、大小、存活时间更新每一帧告诉所有粒子“你现在该移动到哪、速度衰减多少、受不受重力影响”渲染在画布上画出每个粒子的当前位置销毁某个粒子存活时间到了或者飞出可视区域太远就把它从数组里移除。把这个流程对应到“心动爆炸”里就是手指点击的位置是爆点爆点一次性生成 80 个心形粒子它们以随机角度向外飞散飞行过程中逐渐减速同时受重力影响向下坠落粒子淡出之后被清理出数组。整个过程每秒钟执行约 60 次人眼看起来就是一场顺畅的爆炸。2.2 Canvas 2D 的两种绘制方式Canvas 2D 把粒子画到屏幕上有两条路路径绘制用beginPath()moveTo()lineTo()fill()画矢量图形优点是边缘清晰缺点是大批量绘制时路径开销高位图绘制用drawImage()把一张预先准备的小图贴到画布上优点是速度极快缺点是快速缩放时可能轻微失真。对于心形粒子最稳妥的做法是“先离屏绘制一张心形小图再通过 drawImage 批量贴图”。这样一来心形的路径只计算一次之后每帧只是做坐标偏移和透明度变化性能远远优于每帧重新计算 Bezier 曲线。2.3 requestAnimationFrame 与 setInterval 的差别很多新手做动画时会写setInterval(() {}, 16)想当然以为这就是 60fps。但实际上 setInterval 无法与显示器刷新率同步容易掉帧、卡顿甚至出现残影。requestAnimationFrame是浏览器专门为动画提供的 API它允许浏览器在下一次重绘前调用你的更新函数。这样做有几个直接好处自动与屏幕刷新率同步通常是 60fps高刷屏上还能更高页面切到后台时自动暂停省电省性能不用猜“该延迟多少毫秒”浏览器会告诉你什么时候可以画。对比项setIntervalrequestAnimationFrame与刷新率同步不同步可能跳帧自动同步页面隐藏时继续执行浪费资源自动暂停时间精度受事件循环阻塞影响以系统时间戳为基准适合场景定时轮询、非视觉任务动画、滚动特效、Canvas 绘制2.4 重力与阻尼让粒子看起来“真实”爆炸粒子如果直直向外飞看起来像礼花刚射出来缺少“火药味”。真实世界里的爆炸碎片会受两个因素影响初速度带来的惯性以及空气阻力/引力带来的减速与回落。在代码里这两个因素可以用两个变量表达阻尼damping每帧把速度乘以一个小于 1 的系数例如 0.98模拟空气阻力重力gravity每帧给速度的 y 分量增加一个固定值例如 0.15模拟重物向下拉。只要加上这两行效果就会从“机械匀速直线”变成“爆炸后舒缓散开再落地”感官差异非常明显。这也是为什么很多人照着网上的粒子代码抄却总是觉得效果“飘”多半是没加重力。3. 环境准备与前置条件这个项目不需要安装任何依赖也不需要配置构建工具。你只需要三样东西一个现代浏览器推荐 Chrome、Edge 或 Firefox用于运行和调试一个文本编辑器VS Code、Sublime Text、记事本都行一个能开启本地静态服务的工具。如果不想装工具直接双击 index.html 也能跑。如果你希望保存代码后自动刷新可以安装 VS Code 的 Live Server 插件或者使用 Node 环境下的npx serve。但这些都是可选项不影响核心代码运行。以下是本文使用的运行环境HTML5 CSS3 基本语法Canvas 2D API所有主流浏览器均支持原生 JavaScript建议使用支持 ES6 的浏览器环境没有使用 TypeScript因为示例希望足够精简方便你快速读懂并移植。整个项目的文件结构非常简单总共三个文件heart-bang/ ├── index.html ├── style.css └── main.js如果你是在已有项目中嵌入这段代码也可以只拿main.js中的核心逻辑配合你自己的容器元素来用。4. 核心流程拆解从“点击”到“爆炸”在给出完整代码前我们先拆清楚整个实现的工作流。很多人写动画代码喜欢想到哪写到哪结果设置状态、更新逻辑、渲染逻辑全混在一起最后调起来非常痛苦。这个项目虽然小但我建议你把逻辑分成四层一层管一件事。4.1 点击与事件绑定页面上会有一层全屏的 Canvas 画布点击事件绑定在canvas上。监听click事件拿到鼠标相对画布的坐标把这个坐标作为爆点爆发中心。这里有一个很容易踩坑的细节如果你用clientX和clientY直接作为画布上的坐标在页面发生滚动或者 Canvas 尺寸不等于视口尺寸时爆点会错位。正确的做法是使用getBoundingClientRect()把点击坐标换算成画布内部坐标const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top;4.2 粒子的数据结构一个粒子并不是一个“类”而是一个普通对象。每帧循环只需要读取和更新这些字段x、y粒子当前位置vx、vy粒子当前速度单位是像素/帧size粒子绘制尺寸心形小图的宽高rotation粒子的旋转角度alpha粒子透明度用于淡出decay每帧透明度衰减量life剩余存活帧数或用一个布尔值表示“是否还需要渲染”。把粒子设计成普通对象而不是实例化类是因为在批量场景下普通对象的创建和回收速度更快适合高频创建与销毁的场景。如果你喜欢用 class当然也可以但在性能敏感场景里批量对象池才是更优解。4.3 粒子更新物理模拟每帧对每个粒子做以下更新位置加上速度x vx; y vy;速度乘以阻尼vx * damping; vy * damping;速度叠加重力vy gravity;透明度衰减alpha - decay;旋转速度如果有rotation rotationSpeed;当粒子的透明度小于等于 0或者粒子飞出画布边界太远就标记为“死亡”。注意“死亡”不等于马上从数组中删除而是可以用一个alive属性标记后续统一过滤。4.4 渲染绘制心形粒子渲染分两步第一步初始化时用离屏 canvas 画出一张心形小图比如heartImage对象 第二步每帧对每个存活粒子调用ctx.save()、ctx.globalAlpha particle.alpha、ctx.translate()、ctx.rotate()、ctx.drawImage()、ctx.restore()。save()和restore()的作用是隔离画布状态避免一个粒子的旋转角度或透明度影响下一个粒子。如果你忘了调用就会出现“所有粒子都跟着上一个粒子的旋转角跑”的奇怪问题。4.5 动画循环启动与停止动画不是无限常驻的。页面初始化时不启动循环等用户第一次点击时启动。当粒子数组为空时停止循环避免后台空转消耗 CPU。这里我用一个animationId变量记录当前帧的 ID通过cancelAnimationFrame停止。5. 完整示例与代码实现下面给出三个文件。先看 HTML 骨架然后是样式最后是核心 JavaScript 逻辑。代码里我会写大量注释方便直接跑通后对照理解。5.1 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleReady, Set, BANG - 心动爆炸动画/title link relstylesheet hrefstyle.css /head body div classpage-wrapper h1 classpage-titleReady, Set, BANG/h1 p classpage-tip点一下页面看心动爆炸效果/p canvas idbangCanvas/canvas /div script srcmain.js/script /body /html5.2 style.css/* 文件路径style.css */ html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: PingFang SC, Microsoft YaHei, sans-serif; background: linear-gradient(160deg, #1b1035 0%, #321f5c 100%); } .page-wrapper { position: relative; width: 100vw; height: 100vh; overflow: hidden; } .page-title { position: absolute; top: 12%; left: 50%; transform: translateX(-50%); margin: 0; color: #fff; font-size: 48px; font-weight: 700; letter-spacing: 4px; text-shadow: 0 0 18px rgba(255, 105, 180, 0.6); user-select: none; pointer-events: none; z-index: 2; } .page-tip { position: absolute; top: 22%; left: 50%; transform: translateX(-50%); margin: 0; color: rgba(255, 255, 255, 0.65); font-size: 16px; letter-spacing: 2px; user-select: none; pointer-events: none; z-index: 2; } #bangCanvas { position: absolute; inset: 0; width: 100%; height: 100%; cursor: pointer; z-index: 1; }5.3 main.js// 文件路径main.js (() { const canvas document.getElementById(bangCanvas); const ctx canvas.getContext(2d); let width 0; let height 0; const dpr Math.min(window.devicePixelRatio || 1, 2); // 粒子数组 let particles []; let animationId null; // 物理参数你可以按喜好调整 const PARTICLE_COUNT 80; // 每次爆炸粒子数量 const DAMPING 0.98; // 阻尼模拟空气阻力 const GRAVITY 0.15; // 重力值越大粒子下落越快 const MAX_ALPHA 0.95; // 最大透明度 const ALPHA_DECAY 0.012; // 每帧透明度衰减 // 离屏画布预绘制一张心形小图 const heartCanvas document.createElement(canvas); const heartSize 24; heartCanvas.width heartSize; heartCanvas.height heartSize; const heartCtx heartCanvas.getContext(2d); drawHeart(heartCtx, heartSize / 2, heartSize / 2, heartSize / 2 - 2); // 画心形使用贝塞尔曲线绘制一个实心心形 function drawHeart(context, x, y, size) { context.save(); context.translate(x, y); context.scale(size / 12, size / 12); context.beginPath(); context.moveTo(0, 4); context.bezierCurveTo(0, 0, -4, -2, -4, -6); context.bezierCurveTo(-4, -10, 0, -10, 0, -6); context.bezierCurveTo(0, -10, 4, -10, 4, -6); context.bezierCurveTo(4, -2, 0, 0, 0, 4); context.closePath(); context.fillStyle #ff4d6d; context.fill(); context.restore(); } // 核心生成一个粒子 function createParticle(x, y) { const angle Math.random() * Math.PI * 2; const speed 2 Math.random() * 7; return { x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 1.5, rotation: Math.random() * Math.PI * 2, rotationSpeed: (Math.random() - 0.5) * 0.2, size: 12 Math.random() * 16, alpha: MAX_ALPHA, alive: true, }; } // 在一次点击的爆点位置生成一批粒子 function explode(x, y) { for (let i 0; i PARTICLE_COUNT; i) { particles.push(createParticle(x, y)); } if (animationId null) { animationLoop(); } } // 更新所有粒子的位置、速度、透明度 function updateParticles() { for (let i 0; i particles.length; i) { const p particles[i]; if (!p.alive) continue; // 位置更新 p.x p.vx; p.y p.vy; // 速度更新阻尼 重力 p.vx * DAMPING; p.vy * DAMPING; p.vy GRAVITY; // 旋转更新 p.rotation p.rotationSpeed; // 透明度衰减 p.alpha - ALPHA_DECAY; // 判断是否死亡 if ( p.alpha 0 || p.x -30 || p.x width 30 || p.y -30 || p.y height 30 ) { p.alive false; } } // 过滤掉已经死亡的粒子 particles particles.filter((p) p.alive); } // 绘制所有存活粒子 function drawParticles() { ctx.clearRect(0, 0, width, height); for (let i 0; i particles.length; i) { const p particles[i]; if (!p.alive) continue; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation); // 通过透明度渐隐实现“爆炸后消失”的效果 ctx.globalAlpha Math.max(0, p.alpha); // 以图片中心为原点绘制 ctx.drawImage( heartCanvas, -p.size / 2, -p.size / 2, p.size, p.size ); ctx.restore(); } } // 动画主循环 function animationLoop() { updateParticles(); drawParticles(); if (particles.length 0) { animationId requestAnimationFrame(animationLoop); } else { animationId null; ctx.clearRect(0, 0, width, height); } } // 调整画布大小适配高分屏 function resizeCanvas() { width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } // 点击事件把鼠标坐标换算成画布内部坐标 function handleClick(e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; explode(x, y); } // 初始化 window.addEventListener(resize, resizeCanvas); canvas.addEventListener(click, handleClick); resizeCanvas(); })();这段代码的完整逻辑是关键。你可以直接把三个文件放到同一个目录下双击 index.html在浏览器里点击页面任意位置就能看到心形粒子从爆点向四周炸开再在重力作用下坠落的视觉效果。6. 运行结果与效果验证启动方式非常简单直接双击 index.html用 Chrome 或 Edge 打开或者用 VS Code 的 Live Server 插件启动一个本地服务在浏览器中访问http://127.0.0.1:5500。正常效果描述如下页面背景是深紫色渐变居中显示“Ready, Set, BANG”标题你点击任意位置会从点击点炸出约 80 个粉红色心形粒子粒子先向四周飞散之后速度逐渐减慢并向下飘落同时透明度递减几秒钟内完全消失。每点一次都会重复这个过程多点几次会形成叠加的爆炸效果。如果你没有看到效果不要急着改代码。按以下顺序检查打开浏览器开发者工具F12进入 Console 面板看有没有报错信息。最常见的是document.getElementById(bangCanvas)返回 null说明 Canvas 元素没找到或脚本加载顺序不对观察 Network 面板确认main.js和style.css都成功加载在 Console 里手动执行document.querySelector(canvas)确认对象存在。判断性能是否合格的标准是连续点击 5 次以上动画依然流畅没有明显的卡顿或掉帧。如果你的设备比较旧还能接受但如果你发现点击后动画明显变卡可以尝试把PARTICLE_COUNT从 80 降到 50或者把dpr的最大值从 2 改成 1.5。如果你希望验证自己的代码不是只在这一台设备上能用还可以顺手用 Chrome DevTools 的设备模拟模式测试移动端表现。注意移动端点击会触发touch事件而不是click事件在真机上直接用该代码可能不会响应手指点击。真机测试时需要把事件绑定改为同时绑定click与touchstart并做一次去重处理。7. 常见问题与排查思路写这类动画最怕的不是代码看不懂而是效果出来但不知道哪里出了问题。下面这份排查表是我觉得最常见的几种情况。问题现象可能原因排查方式解决方案点击后没反应Canvas 元素没找到或脚本顺序错误Console 看报错Elements 面板检查 canvas 是否存在把 script 放到 body 底部或使用 DOMContentLoaded心形显示成方块离屏心形绘制失败或缩放比例不对单独绘制 heartCanvas 并输出到临时图片检查 drawHeart 的贝塞尔曲线参数动画卡顿粒子数量过多或高分屏渲染压力大Performance 面板采样记录降低粒子数量限制 dpr 最大为 2爆炸位置偏移未使用 getBoundingClientRect 换算坐标点击边缘位置观察爆点用 clientX - rect.left 和 clientY - rect.top粒子一直不消失透明度衰减设置为 0或过滤逻辑有误Console 打印 particles 数组长度确保 ALPHA_DECAY 大于 0并使用 filter 清理Canvas 模糊未按 devicePixelRatio 放大画布检查 canvas.width 与画布 CSS 宽度关系resizeCanvas 中设置画布物理尺寸并用 setTransform 缩放移动端点击无效click 事件在部分设备的响应有延迟或被拦截DevTools 开启移动模拟触发点击改为 click 与 touchstart 双绑定并做防重复处理这里单独说一下最容易被忽略的一条Canvas 的分辨率适配。Canvas 的 CSS 宽高和画布物理宽高是两套系统如果你只设置了canvas.style.width 100%而不设置canvas.width画布默认物理分辨率是 300×150在高分屏上会被放大看起来就是糊的。因此代码里resizeCanvas同时处理了物理尺寸和 CSS 尺寸并用setTransform把坐标系统一为 CSS 像素这是保证清晰度的关键。移动端事件是另一个高频问题。很多人在 PC 上写完直接打包上线结果用户手机上点了没反应原因可能是移动端对click有 300ms 延迟或者元素被其他层遮挡。更稳妥的绑定方式是同时监听两种事件canvas.addEventListener(click, handleClick); canvas.addEventListener(touchstart, handleClick, { passive: true });passive: true可以告诉浏览器不需要调用preventDefault这有助于减少滚动过程中的卡顿。如果你同时绑定了两个事件需要在上层做一次简单的去重防止一次手指点击触发两次爆炸。8. 最佳实践与工程建议小项目跑通之后如果你想把这个效果真正用进公司项目、作品集或开源仓库下面这几点值得认真考虑。8.1 把效果封装成独立模块不要直接把所有代码贴在页面底部。建议把粒子逻辑封装成独立的类或工厂函数暴露一个fire(x, y)方法。这样调用方不需要关心内部状态而且你可以方便地在多个页面复用。下面的代码展示了一个最小封装思路export class HeartBoom { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); this.particles []; this.animationId null; } fire(x, y) { // 内部生成粒子并启动动画 } destroy() { // 取消动画循环并清理资源 } }8.2 加入音量反馈与场景音频如果这个动画用在活动页上可以配合 Web Audio API 生成一个短促的“砰”声。注意音频需要由用户手势触发浏览器才允许播放所以把AudioContext.resume()放在点击事件处理函数里最合适。8.3 控制粒子数量的动态调配在移动端弱机型和桌面端旗舰机上的渲染压力完全不同。更工程化的做法是根据设备能力动态调整粒子数量比如检测屏幕宽度和devicePixelRatio小屏设备自动把粒子数量减半。或者用requestAnimationFrame的时间戳计算实际帧率当帧率低于某个阈值时降低粒子增量。8.4 使用对象池减少内存波动本示例中粒子数量少过滤数组的写法完全没问题。但如果只是扩展到上千粒子频繁创建、销毁对象会导致 GC 频繁触发表现为动画偶尔停顿。更进阶的做法是预分配一个粒子对象池死亡粒子不删除而是重置状态后复用。对象池的代码会增加不少复杂度建议在确认性能问题确实存在后再引入。8.5 设计上考虑背景与色调可配置粒子颜色、背景渐变、心形大小在不同场景里应该可配置。你可以把颜色提取到配置文件里或者直接通过 CSS 变量切换主题。这样后续做情人节、妇女节、店庆等不同活动时只需要改几个参数不用动业务代码。8.6 关于无障碍与用户偏好动画类效果要尊重用户的“减少动态效果”系统设置。你可以通过window.matchMedia((prefers-reduced-motion: reduce))检测用户是否开启了“减弱动态效果”如果开启了就直接跳过爆炸动画或者只显示一个静态心形标记。这个细节虽然小但在无障碍审查中非常重要也体现了前端开发的专业度。8.7 测试与构建如果你是通过构建工具引入记得把main.js打包进项目。如果使用 Vite、Webpack这段代码不需要任何第三方依赖直接作为模块引入即可。在 TypeScript 项目中使用时建议给粒子的属性定义 Interface避免使用any。如果后续有 Canvas 2D 类型的强校验需求可以引入types/node中提供的 NodeJS 类型声明或者直接依赖 DOM 库内置的 TS 类型。9. 总结与后续学习方向“Ready, Set, BANG ”并不是一个复杂到需要用大型动画库才能实现的效果。它背后真正值得掌握的是“粒子系统 Canvas 2D requestAnimationFrame”这套组合拳。这三个概念在任何用 Canvas 做可视化、游戏特效、数据动效的项目里都会反复出现。把本文的示例代码抄一遍、跑一遍、调一遍参数你对前端动画的理解会比刷一百个 CSS 动画教程更扎实。下一步我建议你做三个小实验来加深理解把心形换成五角星或圆形观察不同图形对爆发感的影响把爆炸改为“连续发射”让粒子在鼠标移动路径上持续生成体会从“一次点击”到“实时流式”的变化把 Canvas 2D 换成 WebGL 或 CSS 3D Transform对比同样的粒子数量在不同渲染方案下的性能差异。如果你打算把这个动效用于某个上线项目记得在真实设备上测一遍移动端效果特别是触摸事件的绑定和高分屏适配。这篇文章的代码可以作为你的起点但真正好用的动效库从来都是建立在你自己调试过的细节之上的。建议先收藏这篇周末花一小时跑一遍整个流程再改成你想要的样子。
返回列表