Vue 3 自定义指令实现鼠标点击粒子特效:Canvas 动画与性能优化

发布时间:2026/10/2 19:16:01
Vue 3 自定义指令实现鼠标点击粒子特效:Canvas 动画与性能优化 之前给产品落地页加交互彩蛋时被设计同事问了一句这个鼠标点下去水花四溅的效果是怎么做的当时用的是现成库后面另一个项目又要同样的效果我就去翻了源码想改成自己可控的版本。这一翻不要紧发现从能用到敢在正式项目里用中间隔着至少三四个坑。这篇就把我在 Vue 项目里做鼠标点击效果这套方案完整写一遍从效果选型、粒子系统手写到封装成自定义指令再到上线后的性能与兼容性处理全部摊开讲。适合已经在用 Vue 3 开发、想做交互反馈特效但不想盲抄第三方库的项目也适合刚入门不久、想理解一个动效从想法到落地要经历哪些决策的开发者。1. 点击效果的形态选择别一上来就写代码1.1 从需求角度看效果类型鼠标点击效果是个很宽泛的说法不同场景需要的点击反馈完全不同。我在动手之前会先过一遍需求问自己一个问题这个效果是给谁看的要传达什么情绪效果类型典型场景表达情绪实现成本涟漪/水波纹按钮、卡片、列表项点击克制、正式、有反馈低粒子爆炸落地页彩蛋、点赞、中奖提示兴奋、庆祝、活泼中文字飞散品牌文案、空状态提示有趣、记忆点中光标跟随装饰性页面、个人站点轻松、娱乐中高图片碎散抽奖、卡片销毁解压、爽快高如果是给后台管理系统加按钮点击反馈涟漪就够用了上粒子爆炸反而显得花哨如果是给产品宣传页做个彩蛋粒子爆炸、文字飞散这类带情绪的效果更合适。我这次要处理的场景是页面任意位置点击都有视觉反馈属于粒子爆炸这一类。一个关键点效果要出现在点击坐标上而不是固定在按钮内部所以必须拿到每次点击的坐标并且让特效从那个坐标开始扩散。1.2 渲染方案的取舍CSS、DOM动画还是 Canvas明确了效果形态接下来要选渲染方案。我见过不少项目在这里走了弯路所以单独拿出来说。纯 CSS 方案适合涟漪。涟漪的本质是半径从 0 到 N 的圆环放大 透明度递减CSS 动画天生就能做甚至不需要 JS 计算Vue 里用Transition包裹一个动态生成的圆环节点就行。但粒子爆炸这类效果每个粒子的坐标、速度、方向都是动态的CSS 没法为每个粒子单独计算轨迹硬做会非常别扭。DOM 动画库方案比如 gsap 操作 DOM 元素能完成粒子效果做法是给每个粒子创建一个 div然后逐帧改 transform。问题在于几十个粒子的创建、移动、销毁会频繁触发 DOM 操作页面本身元素一多低端机马上卡。这个方案在特效数量很少、或者粒子只有四五个的时候可以选。Canvas 方案是我最终选的方向。Canvas 的核心优势是一帧画一整张图粒子数量几百个也扛得住粒子之间互相叠加、颜色混合都很方便。缺点是要自己维护动画循环和粒子生命周期代码量明显比前两种大。我的建议很直接要长期维护的点击特效无脑选 Canvas。1.3 Vue 自定义指令是容器不是方案技术选型定了 Canvas接下来要考虑的是怎么把这个能力接进 Vue 项目。直接写一个addEventListener工具函数当然可以但每次要用都要 import 再调用组件一多还容易忘记解绑。Vue 自定义指令正好解决这个问题。它的mounted钩子里可以安全地访问 DOM 并绑定事件unmounted钩子里可以解绑和清理副作用这两个生命周期和特效的创建、销毁完美对应。另外指令可以通过binding.value接收调用方的参数比如粒子颜色、数量、是否禁用做到一个指令多种效果。我最终采用的做法是一个全局特效管理器模块负责创建 Canvas、维护粒子、跑动画循环。一个自定义指令负责绑定事件、取坐标、调特效管理器。页面里用v-fx-click一行接入。这样做的最大好处是副作用被隔离在一个模块里其他组件只看得到指令能用/不能用不关心内部实现。2. 效果底座全局单例 Canvas 与事件绑定2.1 为什么必须是全局单例 Canvas一个很容易踩的坑给每个元素都创建自己的 Canvas。页面里如果有几十个按钮就会出现几十个透明画布叠在页面上内存直接翻车而且每个画布都要各自维护一套动画逻辑代码量爆炸。正确思路是全局只创建一个 Canvas放在document.body下面铺满整个视口所有点击特效都画在这一张画布上。粒子要飞满屏幕也不用担心被某个容器裁切。这个 Canvas 有两个关键样式属性position: fixed画布相对于视口固定滚动页面也不会影响它的坐标系。pointer-events: none让鼠标事件直接穿透画布落到底下的页面元素上。少了这个画布会变成一个隐形拦截层整个页面都点不动了。z-index 也要注意。一般特效层放在一个不算太高但足够显现的层级比如 9999但项目中如果有 Modal、Message 弹层它们的层级往往会更高需要根据实际情况调整避免特效被遮挡或反过来挡住弹层。function ensureCanvas() { if (canvas) return canvas document.createElement(canvas) canvas.style.cssText position:fixed;top:0;left:0;pointer-events:none;z-index:9999; document.body.appendChild(canvas) ctx canvas.getContext(2d) resize() window.addEventListener(resize, resize) }2.2 高分屏适配devicePixelRatio 处理如果直接按window.innerWidth和window.innerHeight设置 Canvas 宽高在 MacBook 或手机这种高分屏上画出来会模糊。原因是物理像素和 CSS 像素不一致Canvas 默认按 CSS 像素绘制而屏幕实际需要用多个物理像素显示一个 CSS 像素。解决办法是把 Canvas 的物理分辨率乘以devicePixelRatio简称 dpr然后通过ctx.setTransform把坐标系统一缩放回来。function resize() { const dpr Math.min(window.devicePixelRatio || 1, 2) canvas.width window.innerWidth * dpr canvas.height window.innerHeight * dpr ctx.setTransform(dpr, 0, 0, dpr, 0, 0) }注意我做了Math.min(dpr, 2)这是性能折中。有些安卓机 dpr 是 3 甚至更高如果按 3 倍分辨率开画布绘制面积是 CSS 面积的 9 倍动画帧率会明显下降。限制到 2 倍视觉上几乎看不出差别性能却能省下一大截。setTransform之后画布内部的坐标系就和 CSS 像素一致了后续拿事件的clientX、clientY直接画就行不用每个粒子再手动除以 dpr。2.3 事件绑定click 之外的选择传统写法是监听click但在移动端click有一段延迟历史现在的浏览器已经优化掉大部分场景不过还有一个场景会让点击效果手感不对用户手指按下去到抬起中间隔了几十毫秒特效却在抬起时才出现少了那种按下即反馈的爽快感。所以我的指令默认监听pointerdown。这样手指一碰屏幕、鼠标一按下去特效立刻出现覆盖了 PC 端和移动端一个事件搞定。但pointerdown有一个需要处理的问题它和click是两次不同的事件。如果指令绑定的元素同时有业务click处理逻辑两个都会触发大多数时候没关系因为特效是纯视觉的但要注意别在特效逻辑里做影响业务状态的事情。另外要过滤掉不适合做特效的交互比如输入框点击、文本选中的按住拖动。我的做法是在指令内部做一次判断如果事件目标是input、textarea或者带有contenteditable属性直接返回。如果不过滤用户想在输入框里点一下调光标结果满屏炸粒子体验会很怪。3. 粒子系统手写物理模型、动画循环与完整指令3.1 粒子数据结构每个粒子需要记录什么粒子系统的核心是定义一个数据结构记录粒子每一帧的状态和变化趋势。我定义的粒子对象长这样function createParticle(x, y, colors) { const angle Math.random() * Math.PI * 2 const speed 2 Math.random() * 5 const color colors[Math.floor(Math.random() * colors.length)] return { x, // 当前位置 y, vx: Math.cos(angle) * speed, // 水平速度 vy: Math.sin(angle) * speed - 1.5, // 垂直速度减 1.5 让爆炸略微向上溅开 size: 2 Math.random() * 5, // 粒子尺寸 color: color, // 颜色 life: 1, // 生命周期从 1 递减到 0 decay: 0.015 Math.random() * 0.02, // 每次刷新递减量 rotation: Math.random() * 360, // 初始旋转角 rotateSpeed: (Math.random() - 0.5) * 12 // 旋转速度 } }大部分字段都好理解重点说两个设计细节。life和decay控制粒子存活时间而不是用固定帧数倒计时。好处是每个粒子寿命有随机差异视觉上不会出现所有粒子同时消失的整齐感而是陆续淡出更接近真实爆炸。rotation和rotateSpeed是我后来加的。最初全部用圆形绘制效果很单调改成小矩形旋转之后视觉丰富度立刻上来。粒子在空中边下降边旋转就像碎纸屑飘落比纯圆点生动很多。3.2 物理模型重力、阻力与抛物线轨迹粒子不能直线飞出后突然消失必须有自然的运动轨迹。我用了最简单的物理模型每帧更新时做三件事位置叠加速度。垂直速度加上一个固定重力值让粒子向下坠。水平速度乘一个阻力系数模拟空气阻力。particle.x particle.vx particle.y particle.vy particle.vy GRAVITY // 0.12重力让粒子往下掉 particle.vx * AIR_RESISTANCE // 0.98水平方向逐渐减速 particle.rotation particle.rotateSpeed particle.life - particle.decay这个模型的效果是粒子爆炸瞬间以随机方向飞出先快后慢然后受重力影响逐渐下坠同时透明度随life降低。如果GRAVITY为 0粒子就是均匀向外扩散的一个圆看久了像套模板加上重力之后粒子有了炸开再落下来的烟火感这是点击效果好看和一般的重要分水岭。AIR_RESISTANCE控制在 0.95 到 0.99 之间比较合适太小粒子会瞬间停住太大则减速太慢飘太远。3.3 动画循环requestAnimationFrame 与停止策略粒子动画用requestAnimationFrame驱动每一帧做三件事清空画布、更新所有粒子状态、把存活粒子绘制到画布上。function tick() { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) particles particles.filter(p p.life 0) particles.forEach(drawParticle) if (particles.length 0) { rafId requestAnimationFrame(tick) } else { rafId null } }这里有个优化点很关键粒子全部消亡后要主动停止动画循环而不是让requestAnimationFrame一直空转。移动端浏览器对后台标签页的动画会降频但一个一直空跑的 Canvas 仍然会带来不必要的耗电。绘制粒子时我开启了globalCompositeOperation lighter这是一种叠加模式粒子重叠的地方颜色会变亮产生光点混合效果。粒子本身画成带旋转的小矩形视觉上比纯圆点更有质感。function drawParticle(p) { ctx.save() ctx.globalAlpha Math.max(p.life, 0) ctx.translate(p.x, p.y) ctx.rotate((p.rotation * Math.PI) / 180) ctx.fillStyle p.color ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size) ctx.restore() }ctx.save()和ctx.restore()成对使用保证每个粒子绘制时的旋转和透明度设置不会污染到下一个粒子。3.4 完整指令代码从特效管理器到全局注册把上面的逻辑组装成两个模块。第一个是特效管理器fx-manager.js负责画布生命周期和粒子系统第二个是自定义指令click-effect.js负责事件绑定和参数透传。// fx-manager.js let canvas null let ctx null let particles [] let rafId null const GRAVITY 0.12 const AIR_RESISTANCE 0.98 function ensureCanvas() { if (canvas) return canvas document.createElement(canvas) canvas.style.cssText position:fixed;top:0;left:0;pointer-events:none;z-index:9999; document.body.appendChild(canvas) ctx canvas.getContext(2d) resize() window.addEventListener(resize, resize) } function resize() { const dpr Math.min(window.devicePixelRatio || 1, 2) canvas.width window.innerWidth * dpr canvas.height window.innerHeight * dpr ctx.setTransform(dpr, 0, 0, dpr, 0, 0) } function createParticle(x, y, colors) { const angle Math.random() * Math.PI * 2 const speed 2 Math.random() * 5 const color colors[Math.floor(Math.random() * colors.length)] return { x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 1.5, size: 2 Math.random() * 5, color, life: 1, decay: 0.015 Math.random() * 0.02, rotation: Math.random() * 360, rotateSpeed: (Math.random() - 0.5) * 12 } } function drawParticle(p) { ctx.save() ctx.globalAlpha Math.max(p.life, 0) ctx.translate(p.x, p.y) ctx.rotate((p.rotation * Math.PI) / 180) ctx.fillStyle p.color ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size) ctx.restore() } function tick() { ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) ctx.globalCompositeOperation lighter particles particles.filter(p p.life 0) particles.forEach(p { p.x p.vx p.y p.vy p.vy GRAVITY p.vx * AIR_RESISTANCE p.rotation p.rotateSpeed p.life - p.decay }) particles.forEach(drawParticle) if (particles.length 0) { rafId requestAnimationFrame(tick) } else { rafId null } } export function burst(x, y, options {}) { ensureCanvas() const colors options.colors || [#ff6b6b, #ffd93d, #6bcb77, #4d96ff] const count options.count || 10 for (let i 0; i count; i) { particles.push(createParticle(x, y, colors)) } if (!rafId) { rafId requestAnimationFrame(tick) } } export function clearFx() { if (rafId) cancelAnimationFrame(rafId) rafId null particles [] if (ctx) ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) }第二个文件是自定义指令的定义// click-effect.js import { burst } from ./fx-manager const IGNORE_TAGS [INPUT, TEXTAREA] export default { mounted(el, binding) { el._fxPointerHandler (event) { if (binding.value binding.value.disabled) return const tagName event.target event.target.tagName if (IGNORE_TAGS.includes(tagName)) return if (event.target event.target.isContentEditable) return const options binding.value || {} burst(event.clientX, event.clientY, { colors: options.colors, count: options.count }) } el.addEventListener(pointerdown, el._fxPointerHandler) }, unmounted(el) { el.removeEventListener(pointerdown, el._fxPointerHandler) el._fxPointerHandler null } }全局注册入口// main.js import { createApp } from vue import App from ./App.vue import clickEffect from ./directives/click-effect const app createApp(App) app.directive(fx-click, clickEffect) app.mount(#app)页面里的使用方式template div v-fx-click classpage 点击页面任意位置试试效果 /div /template如果要做更定制化的效果就在组件里传参数button v-fx-click{ colors: [#f00, #ff0, #0f0], count: 16 } 金色粒子按钮 /button指令内部的el._fxPointerHandler是为了在unmounted钩子里精确移除同一个事件引用。如果直接传匿名函数removeEventListener将找不到目标这是内存泄漏的高发点。4. 上线后的真实坑位与排查链路4.1 特效只出现一次就消失第一次把指令接进项目时我发现点击后粒子爆炸了一次再点就没有反应了。第一反应是事件没触发打开控制台加了日志发现pointerdown每次都触发坐标也正常问题出在绘制环节。继续定位发现在burst函数里rafId一直不为空动画循环始终在跑但画面上没东西。再仔细看particles数组在第一次爆炸后确实被清空了但rafId没有置空导致第二次点击时不会重新启动动画循环。这就是只出现一次的根因。修复方式就是上面代码里的逻辑rafId为空时才启动requestAnimationFrame并在粒子数组清空后把rafId设为null。这个坑的本质是状态管理不严谨动画循环和粒子数组的生命周期没有对齐。建议大家在写类似系统时一定要明确启动动画和粒子存在的关系别让循环一直挂着。4.2 滚动页面后粒子错位在落地页上测试时一切正常但页面滚动一段距离后再点击粒子爆炸中心点和鼠标位置差了整整一个滚动距离。这个现象非常典型。排查后发现最初的 Canvas 坐标用的是event.offsetX和event.offsetY这两个值表示鼠标相对于事件目标元素左上角的偏移。页面滚动后元素相对视口的位置发生变化offsetX跟视觉位置就对不上了。解决方案其实在第二章已经埋下了Canvas 用position: fixed铺满视口事件坐标用clientX和clientY这两个值本身就是相对于视口的天然不受滚动影响。如果项目里特效层必须挂在某个滚动容器内那就不能直接用clientX了需要动态计算容器相对视口的位置再减去偏移。但这个方案复杂度会明显上升我的建议是能让特效层 fixed 就 fixed别自己给自己找麻烦。4.3 组件销毁后动画仍在运行路由切换后上一页面的点击特效还在运行这是另一个容易踩的内存泄漏坑。原因是全局单例 Canvas 挂在document.body下不归属任何 Vue 组件所以组件卸载时Vue 的unmounted钩子虽然移除了指令的事件监听但动画循环和 Canvas 还挂在全局。如果用户反复切换路由每次进来都创建新的特效实线页面里就会有多个隐藏 Canvas 在空转。我的治理方案是给特效管理器加一个引用计数let refCount 0 export function attachFx() { refCount ensureCanvas() } export function detachFx() { refCount-- if (refCount 0) { clearFx() if (canvas) { canvas.remove() canvas null ctx null } } }指令的mounted调用attachFx()unmounted调用detachFx()。最后一个使用特效的组件销毁时才真正销毁 Canvas 并停止动画循环。这个思路可以推广到任何多个组件共享一个全局资源的场景。4.4 低端机型点击卡顿测试机型换到一台老旧安卓机上连点屏幕时明显掉帧。用 Performance 面板记录了一轮点击发现问题不在渲染而在 JavaScript 执行粒子创建和 GC 频次太高。原因有两个一是粒子数量没有上限控制疯狂连点时粒子数组无限膨胀二是每帧遍历粒子时filter创建一个新数组配合对象字面量创建GC 被频繁触发。优化做了三件事单次点击粒子数动态调整桌面端 10 个不变移动端根据navigator.hardwareConcurrency或直接降到 6 个。全局粒子总数设上限比如 60 个。新粒子生成时如果数组已满丢弃最旧的粒子。尊重系统的减少动态效果设置matchMedia((prefers-reduced-motion: reduce))匹配时直接跳过特效。const prefersReducedMotion window.matchMedia ? window.matchMedia((prefers-reduced-motion: reduce)).matches : false function burst(x, y, options {}) { if (prefersReducedMotion) return // ... }顺手把particles.filter(...)改成了就地遍历加数组重写减少每帧的数组分配。这个优化对低端机的提升非常明显连点时的卡顿基本消失。5. 进阶玩法把点击效果用到视频播放页5.1 视频场景下的效果取舍聊到这里必须提一类很常见的需求视频播放页面。不少 Vue 项目用 hls.js 或 video.js 播放 m3u8 流播放器交互经常需要点击反馈——点赞连击、暂停播放提示、直播礼物特效。这类场景和普通页面不太一样有几个需要特别注意的地方。第一video元素自带的controls区域不能绑特效。拖动进度条时如果触发粒子爆炸会干扰 seek 操作。做法是只监听video元素主体区域并且过滤掉controls属性存在的节点。第二暂停播放的提示动画值得做。默认的播放/暂停按钮只出现在画面中心一小块用点击特效做一个从鼠标位置扩散的圆环反馈感强很多。圆环比粒子爆炸更克制不影响观看。第三点赞连击适合用粒子颜色梯度表达力度。点第一下是黄色粒子快速连点三下变橙色超过十下迸发彩色大爆炸。核心是维护一个按时间窗口计数的状态窗口内点击次数越多传给burst的count和colors参数越华丽。let clickCount 0 let timer null function handleClick(e) { clickCount clearTimeout(timer) timer setTimeout(() { clickCount 0 }, 800) if (clickCount 10) { burst(e.clientX, e.clientY, { count: 24, colors: [#ff4757, #ffa502, #2ed573, #1e90ff] }) } else if (clickCount 3) { burst(e.clientX, e.clientY, { count: 14, colors: [#ffa502, #ff4757] }) } else { burst(e.clientX, e.clientY, { count: 6, colors: [#ffd93d] }) } }5.2 视频页的性能预算控制视频播放本身就是 CPU 和 GPU 的大户m3u8 流即使在暂停状态解码进程也没有完全停止。在这个场景下特效必须轻量到近乎隐形。我的做法是给特效管理器加一个预算档位普通页面用默认档位视频播放中自动降到低档位视频暂停时可以恢复。低档位下单次点击粒子数不超过 8 个全局总数不超过 40 个dpr 上限从 2 降到 1.5。实现方式不复杂在fx-manager里加一个setBudget(level)方法内部保存当前粒子上限burst的时候根据上限截断粒子数量。配合 video 的play和pause事件切换档位。还有一个容易忽略的点移动端播放视频时是全屏沉浸状态特效层 z-index 要跟着全屏层走不然特效会被播放器全屏层挡住。这个取决于播放器的全屏实现方式有的播放器全屏是创建了一个独立容器需要把特效 Canvas 也挪进去或者在全屏状态改用播放器自带的事件回调。Vue 项目里如果用到了第三方播放器这一步很容易掉坑建议提前看文档确认全屏模式下的 DOM 结构。写在最后的一个小技巧做点击效果这两年我的核心体会是克制。特效是调味品不是主菜全页面到处炸粒子反而让用户疲劳。我现在的做法是日常管理系统只给按钮和卡片配了轻微涟漪落地页和活动页单独注册v-fx-click做彩蛋视频播放页只做暂停播放提示和点赞连击效果点到为止。如果你也想在自己的 Vue 项目里试试建议从单次点击、十几个粒子起步先把 Canvas 生命周期和指令解绑跑稳再慢慢加旋转、连击、颜色梯度这些花样。粒子特效最难的部分从来不是画出来而是让它不拖累页面性能、不泄漏内存、不干扰正常交互。这篇里的代码你直接抄去用没问题但里面的坑位排查思路比代码本身更值得记下来。