前阵子给某活动页面做氛围优化需求方反复强调要有“互动感”。白底、黑字、产品图排版再干净鼠标移上去没有任何反馈也确实容易让人产生“这是个静态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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 /html2.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个。这时候就必须加节流。我常用的节流写法是配合requestAnimationFramelet 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。代码不多但每一步都能学到浏览器渲染和事件机制的真实细节比背一百遍文档都有用。