前阵子给某活动页面做氛围优化,需求方反复强调要有“互动感”。白底、黑字、产品图,排版再干净,鼠标移上去没有任何反馈,也确实容易让人产生“这是个静态PPT”的错觉。于是我想到了“鼠标粒子特效”——准确说,是一段HTML鼠标特效代码:鼠标每点击一下,就在点击位置炸开一小团彩色粒子,散开、旋转、淡出,整个过程不到一秒。这种效果在个人博客、活动落地页、在线答题页面、甚至网页版PPT演示里都能用,代码不需要依赖任何外部库,纯手写就能跑起来,理解门槛也低,适合想给页面“添点活气”的前端开发,不管是刚入门还是写了好几年。
今天想把这段代码、调优过程和一些踩坑记录完整分享出来。我不会只丢一个demo链接给你,里面涉及的实现原理、性能隐患、移动端兼容问题,都会一个一个讲清楚。代码不复杂,但背后的“为什么”才是真正值钱的部分。
1. 点击瞬间“炸开”的粒子,本质是一堆临时DOM动画
1.1 一句话概括实现原理
其实原理特别直白:监听鼠标点击事件,拿到点击坐标,然后动态创建十几个尺寸不同、颜色不同的小元素,让它们以点击位置为中心向四周飞散,飞散过程中逐渐缩小并淡出,最后从DOM里移除。整个过程不需要Canvas、不需要WebGL、不需要任何图形库,原生浏览器能力就能完成。
把“特效”拆成“事件-创建-动画-移除”四个环节,一个是理解,一个是排查问题。很多人写特效写乱了,就是没把这条链路拎清楚,结果粒子都堆积在页面上不清理,内存越涨越高,页面卡成幻灯片。
1.2 为什么不直接用现成的GIF或者SVG动画素材
这个问题我在之前某次内部沟通时被问过。当时同事觉得找几个粒子爆炸的GIF放在页面底部不就完事了?其实不行的。现成素材最大的问题有三个:
- 位置固定:GIF和SVG动画永远从自己的容器坐标开始播放,没办法从鼠标点击的位置“炸开”。
- 顺序固定:素材里粒子往哪个方向飞、飞多远,是预先做死的,每次点击看到的动作完全一样,看多了就腻。
- 反馈不及时:鼠标按下到播放动画,中间有延迟,交互上的“顺手感”会大打折扣。
自己写代码的好处是,粒子的数量、方向、颜色、速度全部可以在每次点击时即时生成,每一次点击都有细微差别,视觉上天然有“不可预期感”。这种每次都不一样的动态反馈,才是活动页面最需要的气氛点。
1.3 为什么选择DOM元素而不是Canvas做基础版本
这里有一个权衡。Canvas画粒子确实更流畅,支持上千个粒子同屏,但代价是代码复杂度高,需要自己管理坐标系、循环渲染、清除画布。而DOM方案有一个特别适合初学者的优势:CSS动画替代了手写渲染循环。
什么意思呢?粒子的飞散路径用CSS的transform: translate()来模拟,淡出缩小用opacity和scale来模拟,整个运动过程交给了浏览器合成器去处理,JS只需要负责“创建”和“移除”这两个动作。逻辑短、好调试、也不容易出错。
DOM方案的劣势是同屏粒子数量有限,一般500个以下比较稳,但日常点击爆发的场景根本达不到这个量级。所以对于大多数活动页、博客、演示场景,DOM版本是性价比最高的选择。
2. 最小可用版本:40行代码让页面活起来
2.1 先看完整代码
下面这个版本是我在实际项目里用的最初一版,经过删减去掉了业务相关逻辑,保留核心。直接在空白的HTML文件里粘贴,保存后浏览器打开就能看到效果。建议配上深色背景,视觉会更明显。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>点击粒子爆发</title> <style> body { min-height: 100vh; margin: 0; background: #0d1117; cursor: pointer; overflow: hidden; } .particle { position: fixed; left: 0; top: 0; border-radius: 50%; pointer-events: none; animation: burst 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; } @keyframes burst { 0% { transform: translate(0, 0) scale(1); opacity: 1; } 100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; } } </style> </head> <body> <script> document.addEventListener('click', (e) => { const count = 14; for (let i = 0; i < count; i++) { const p = document.createElement('span'); p.className = 'particle'; const size = Math.random() * 8 + 4; const angle = (Math.PI * 2 / count) * i + (Math.random() * 0.5 - 0.25); const distance = Math.random() * 90 + 50; p.style.width = size + 'px'; p.style.height = size + 'px'; p.style.left = (e.clientX - size / 2) + 'px'; p.style.top = (e.clientY - size / 2) + 'px'; p.style.background = `hsl(${Math.floor(Math.random() * 360)}, 80%, 60%)`; p.style.setProperty('--dx', Math.cos(angle) * distance + 'px'); p.style.setProperty('--dy', Math.sin(angle) * distance + 'px'); p.addEventListener('animationend', () => p.remove()); document.body.appendChild(p); } }); </script> </body> </html>2.2 逐行拆解:事件部分
document.addEventListener('click', handler)是入口。这里用的是点击事件,但要注意,用户如果按住鼠标不放,click事件只会触发一次。如果想要“按住持续爆发”的效果,需要换成mousedown或mousemove并配合节流。
坐标处理是很多人第一个容易出错的地方。e.clientX和e.clientY是相对于浏览器可视区域左上角的坐标,配合position: fixed的元素是准确的。但如果你用了position: absolute,并且外层容器不是body,就要注意是否被祖先元素的position影响了。所以我一律推荐用fixed定位去承载粒子,少踩一个祖级定位的坑。
2.3 逐行拆解:粒子生成与动画
循环里创建粒子的顺序是这样的:
- 确定粒子数量。14个不算多,但在视觉上和性能上比较平衡。数量太多会让单个粒子很小,反而失去“爆发”感。
- 随机尺寸。
size在4到12像素之间,可以用border-radius: 50%让每个粒子是圆形。 - 计算飞散角度。这里用了
(Math.PI * 2 / count) * i,意思是把一圈360度平均分给14个粒子,每个粒子负责一个方向的飞散。后面加的随机量是让角度有一点偏移,不至于像切蛋糕一样整齐对称。 - 计算飞散距离。
distance在50到140像素之间,确保大部分粒子在中程距离内散开,不至于飞出屏幕外。
这里最关键的技术点是CSS自定义属性传值:
p.style.setProperty('--dx', Math.cos(angle) * distance + 'px'); p.style.setProperty('--dy', Math.sin(angle) * distance + 'px');@keyframes burst中使用var(--dx)和var(--dy)来做位移。这样14个粒子虽然共用同一个动画时间轴,但每个粒子自己携带不同的位移参数,表面上看起来就像各自飞向不同方向。
2.4 为什么动画结束必须手动移除节点
代码里有一行:
p.addEventListener('animationend', () => p.remove());这行非常重要。很多人做这种特效,只记得往页面里加节点,不记得删除。结果是用户多点几下,DOM里囤积了几百个已经完成动画的残余节点,这些节点虽然不可见,但依然占内存。等下一位用户疯狂点击,内存占用会一路飙升,页面最终卡死。
在基础版本里,写在事件监听里直接绑定animationend是最简单可靠的清理方式。等后面升级到Canvas方案时再考虑对象池优化,基础DOM方案用remove()就足够了。
3. 把效果调“高级”:颜色、轨迹、节奏的调教
3.1 颜色策略:从随机的“花屏”到统一的“调性”
最开始我直接用了纯随机HSL色值,结果红蓝绿紫黄同时炸出来,页面瞬间变成“花屏大乱斗”,非常廉价。后来我按真实项目的需要,总结了三种比较耐看的配色策略:
- 同色系渐变法:每次都从同一条色带里取色。比如把基色调成暖橙色
hsl(25, 90%, 60%),然后让明度在50%到75%之间浮动,粒子看起来统一又有层次。 - 双色互补法:选一对互补色,比如青和品红,粒子随机在两者之间取色,爆发时像两股能量对冲。
- 品牌色提取法:直接从页面主视觉里取几个色彩值,做成数组,比如
['#ff6b6b', '#feca57', '#48dbfb'],每次随机挑一个。这个方案最适合商业活动页,因为它和整体视觉是协调的。
具体实现就是把background从纯随机hsl改成一个函数:
const colors = ['#ff6b6b', '#feca57', '#48dbfb', '#f368e0']; const color = colors[Math.floor(Math.random() * colors.length)]; p.style.background = color;3.2 轨迹设计:直线飞散只是及格线
基础版的粒子是直线飞散的,说实话有一点单调。进阶玩法是在直线运动基础上叠加“减速”或“回弹”,让粒子像小水花一样往四周溅开,最后微微向中心回缩,很像火星四溅然后被风吹灭的感觉。
这个效果可以用cubic-bezier缓动函数实现。默认的CSS动画缓动是ease,飞散过程会比较均匀。如果想让粒子“先快后慢”,用:
animation: burst 0.9s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;这个缓动曲线相当于给粒子加了一个“摩擦力”。前期速度快,后期逐渐停下并淡出,有一种真实的物理感,而不是被人为操控的感觉。
如果还想更复杂,可以给粒子增加一个“回弹”过程:让动画在第二个关键帧回拉一段距离。不过从实际效果看,粒子存活周期那么短,回弹距离很难被感知到,投入产出比不高,我后来就放弃了。
3.3 节奏控制:一阳指和连点狂魔的差别
不同用户的操作习惯会对特效体验产生很大影响。有人习惯慢慢点,有人会疯狂连点。基础版对两者都不做特殊处理,但在真实活动中,连点的情况下粒子会在短时间内大量叠加,反而变卡。
我的处理办法是加一个“爆发冷却”机制:两次爆发间隔小于300毫秒时,适当减少粒子数量,比如从14个降到6个。这样用户连点的时候,每次爆发小而密,不会因为叠加导致卡顿。实现方式很简单:
let lastClick = 0; document.addEventListener('click', (e) => { const now = Date.now(); const count = (now - lastClick < 300) ? 6 : 14; lastClick = now; // 后续循环用 count });4. 疯狂点击不卡顿:性能优化是必需品
4.1 卡顿的真正来源
先说卡顿从哪里来。最容易忽略的一点:每次点击创建DOM节点,浏览器都会触发一次布局重排?不对,这里我修正一下,如果所有粒子都用position: fixed定位,并且动画只改变transform和opacity,浏览器是可以把动画操作交给合成器层处理的,不触发重排也不触发绘画,这是性能最理想的情况。
真正的卡顿来源于三件事:
- 节点堆积:动画结束后不清理节点,越积越多,每次
appendChild都变慢。 - 未节流的移动事件:如果你把触发条件从
click换成mousemove做拖尾效果,移动事件每秒可能触发几十上百次,每次创建十几个粒子,CPU直接被打满。 - 大量粒子同时动画:如果粒子数量超过300个,并且所有粒子都在同一时间播放CSS动画,合成器压力会明显上升,帧率掉到30fps以下。
4.2 优化清单:我实际踩完一遍之后总结的
下面这份优化清单是我在某个模拟项目里反复测试后整理出来的,每一条都有明确的对应问题:
| 问题 | 优化措施 | 效果说明 |
|---|---|---|
| 动画结束节点不清理 | animationend中移除节点 | 避免DOM节点无限堆积 |
| 移动事件高频触发 | 用requestAnimationFrame节流,或加时间阈值 | 限制单位时间内的粒子创建频率 |
| 粒子一次性创建过多 | 限制同屏粒子总数,超过上限后丢弃新请求 | 保证同屏粒子在几百以内 |
| 频繁触发重排 | 统一使用fixed定位 +transform动画 | 让动画走合成器通道 |
| 颜色重复计算 | 预生成颜色数组,随机索引 | 减少字符串拼接和计算量 |
4.3 同屏粒子上限:一个实用的预估算法
如果不想写很复杂的动态管理,可以走简单路线:每次点击创建的粒子数乘以短时间内的点击次数,不能超过400。假设用户一秒点击5次,每次爆发14个粒子,那么同屏粒子最多是70个,离400还远得很,这时候不需要任何优化。
但如果做的是移动拖尾效果,鼠标每移动几像素就触发一次,一次创建3到5个粒子,一秒下来就可能创建300到500个。这时候就必须加节流。我常用的节流写法是配合requestAnimationFrame:
let ticking = false; document.addEventListener('mousemove', (e) => { if (ticking) return; ticking = true; requestAnimationFrame(() => { createParticleBurst(e, 4); ticking = false; }); });requestAnimationFrame会把创建粒子的动作合并在同一帧里执行,比用setInterval更平滑,也不会打断浏览器的渲染节奏。
5. 从点击到拖尾:换一种更柔和的交互形态
5.1 鼠标移动拖尾的实现思路
点击爆发是“炸一下”,拖尾则是“一直跟着走”。两者核心逻辑是一样的,只是触发事件从click换成了mousemove,同时粒子数量要少很多,单个粒子尺寸也可以更小。拖尾的效果适合用于背景装饰,不影响用户操作,也不会让人觉得鼠标太“重”。
我推荐的参数是:每次移动产生2到4个粒子,粒子存活时间0.6秒,位移距离不超过40像素。这样拖尾若有似无,点到为止,不会干扰内容阅读。
5.2 从DOM换到Canvas:什么时候才需要升级
DOM方案的极限大约在300到500个粒子。如果需求方明确要求“全屏烟花”“一按就爆发上百个粒子”,DOM版就顶不太住了。这时候应该换成Canvas方案。
Canvas版的思路是:在页面加一个全屏透明画布,粒子的所有运动由JS在每一帧里自己计算和绘制,不再依赖CSS动画。好处是粒子数量可以轻松上千,坏处是代码量翻倍,且需要注意Canvas在高DPI屏幕上的模糊问题。
下面给一个Canvas版的核心结构,方便你理解两者差异:
const canvas = document.getElementById('fx'); const ctx = canvas.getContext('2d'); let particles = []; function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resizeCanvas); document.addEventListener('click', (e) => { for (let i = 0; i < 20; i++) { const angle = Math.random() * Math.PI * 2; const speed = Math.random() * 6 + 2; particles.push({ x: e.clientX, y: e.clientY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, size: Math.random() * 4 + 2, color: `hsl(${Math.random() * 360}, 80%, 60%)` }); } }); function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.x += p.vx; p.y += p.vy; p.vy += 0.15; // 模拟重力 p.vx *= 0.98; // 空气阻力 p.life -= 0.016; ctx.globalAlpha = Math.max(0, p.life); ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); if (p.life <= 0) { particles.splice(i, 1); } } requestAnimationFrame(animate); } animate();Canvas版的优势在加了重力和空气阻力之后特别明显:粒子会呈现抛物线下落的轨迹,而不是CSS动画那种匀速直线。这是“真实感”的一个重要来源。
5.3 移动端适配:一次让人头大的兼容经验
活动页不可能只在桌面端运行。移动端有两个问题需要注意:
第一个是touchstart事件没有clientX以外的坐标兼容性问题,直接用e.touches[0].clientX就能拿到。坐标和桌面端逻辑一致,代码不用大改。
第二个问题是touchstart和click在移动端会先后触发,导致一次触摸出现双重爆发。解决办法是做一个标志位,判断当前环境是否支持触摸,支持的话就禁用click绑定:
if ('ontouchstart' in window) { document.addEventListener('touchstart', handler); } else { document.addEventListener('click', handler); }这个方案并不完美,因为部分平板设备同时支持触摸和鼠标,但在大多数手机浏览器上是可用的。也可以使用pointerdown事件统一处理,它能在触摸和鼠标两种输入下都触发,只在非常老的浏览器上才有兼容问题。
6. 踩过的坑:坐标、遮挡、双触发和动画清理
6.1 弹窗内点击时粒子位置偏了
某活动页面里有个弹层,弹层内部有按钮,点击按钮时粒子直接从弹层底部冒出来,位置完全错乱。排查发现,弹层本身用了position: fixed,内部按钮的前辈元素却加了transform属性,导致clientX和clientY依然是视口坐标,但粒子的fixed定位却受到了transform的影响,最终定位到错误的位置。
解决方法是:不要在带有transform的元素内部使用fixed定位。要么把粒子容器放到body下的独立层,要么在创建粒子时改用getBoundingClientRect()换算坐标。我在项目里选择了前者,因为结构改动最小。
6.2 粒子挡住按钮,点击事件失灵
最开始的粒子没有加pointer-events: none,鼠标扫过正在飞散的粒子时,会把点击事件吃掉,按钮点了没反应。这是新手最容易踩的交互坑。
解决方式就是在粒子样式里加一行:
pointer-events: none;这一步是必须的。粒子是视觉元素,不应该参与事件捕获,否则它就是用户操作路径上的“障碍物”。
6.3 快速拖动时事件堆积
桌面端按下鼠标拖动,虽然触发的是mousemove,但如果既绑了mousemove又绑了 `click``,拖动结束时会同时触发一次点击,粒子会多炸一下。
要判断用户到底是拖还是点,可以用一个位移阈值:
let downX = 0, downY = 0; document.addEventListener('mousedown', (e) => { downX = e.clientX; downY = e.clientY; }); document.addEventListener('click', (e) => { const distance = Math.hypot(e.clientX - downX, e.clientY - downY); if (distance > 8) return; // 超过8像素视为拖动,不触发爆发 createBurst(e); });这样按住拖动不会在松手时突然炸一下,体验正常很多。
6.4 动画清理时机的一些细节
animationend事件在标签页切到后台时有时不触发,导致粒子节点残留。保险起见,可以加一个兜底的超时删除:
setTimeout(() => p.remove(), 1200);animationend正常触发时会立即删除,超时删除只是防止意外情况。实测中加了这个保险之后,节点堆积问题彻底消失了。
最后再分享一个小技巧。如果想让粒子特效看起来更精致,不用买素材也不用引入大库,把粒子的形状从纯圆形改成随机多边形就够了。比如在Canvas版里,ctx.arc换成ctx.ellipse或者直接绘制短线段,视觉风格立刻就不一样。这段代码我后来沿用到了公司内部好几个演示项目里,每次只需要调整颜色数组和粒子数量,就能适配不同主题的页面,零成本改造。如果你也在做活动页,建议先把DOM版跑通,再根据实际效果决定要不要升级Canvas。代码不多,但每一步都能学到浏览器渲染和事件机制的真实细节,比背一百遍文档都有用。