
最近在给一个内部后台管理系统加交互动效临时想给页面加点“手感”就顺手做了个Vue全局鼠标点击特效。所谓鼠标点击效果说白了就是用户每次点击页面时在鼠标落点生成一个短暂的视觉反馈比如水波纹、粒子烟花、浮出文字等。这类效果在官网首页、个人博客、活动专题页里特别常见能明显提升页面的精致感和互动感。它的实现思路并不复杂核心就三件事监听全局点击事件、在点击坐标生成临时DOM或Canvas绘制、动画结束后立刻清理资源。这篇文章适合正在做Vue 3项目、想给页面增加交互动效的人也适合刚接触Canvas动画想找个入门练手场景的朋友。如果你用的是Vue 2思路基本一致把组合式API替换成options API就行。1. 先想清楚点击特效的本质与常见方案1.1 特效背后到底做了什么很多人第一次看到别人博客上的点击波纹觉得很高端其实拆开看就是一个非常标准的“事件监听 临时节点 动画清理”流程。整个生命周期是这样的用户点击页面触发window上的click事件。事件回调里拿到event.clientX和event.clientY这就是鼠标在视口中的坐标。在坐标位置创建一个临时元素比如一个span标签给它加上动画class。CSS动画从头播到尾比如从scale(0)变为scale(2.5)同时透明度从1变为0。动画结束后立刻把这个临时元素从DOM里移除。就这么简单。很多花哨的点击特效都是在这个基础上做变体粒子烟花是把一个span换成几十个粒子文字弹出是把span里的内容换成随机文案旋转光效是给span加一个旋转动画。理解了这个底层逻辑后面所有方案你都能自己推导出来。1.2 主流实现方案对比我整理了一下目前网上常见的几种实现路径各有优劣选型时主要看你项目的基础和效果复杂度。方案实现方式优点缺点适合场景DOM加CSS动画点击时appendChild一个span通过CSS animation控制缩放和透明度代码量极小性能好不容易出兼容问题只适合圆形扩散、简单缩放这类基础效果后台系统、官网、博客Canvas粒子维护一个canvas覆盖层点击时在画布上生成粒子数组逐帧绘制可以做烟花、爆炸、拖尾等复杂效果视觉上限高需要自己处理requestAnimationFrame循环和粒子生命周期活动页、个人主页、展示型项目第三方npm包引入canvas-confetti、css-particle-button之类的库接入快效果现成包体积不可控样式定制麻烦UI风格难统一临时或原型项目预置GIF/Lottie点击时插入一张透明背景动图或Lottie动画视觉最丰富的团队有设计师时效果很好资源体积大Lottie需要额外运行时品牌官网、运营活动如果是自己的项目我通常建议优先用方案一或者方案二自己写。不是说不让用第三方库而是点击特效这个需求太轻了用一个npm包有点杀鸡用牛刀。而且很多时候你需要的效果很特殊比如“点击出现公司Logo”、“点击出现专属吉祥物”这种东西第三方库给不了自己写反而更灵活。1.3 我为什么推荐用组合式API自己写Vue 3项目里我习惯把特效逻辑封装成一个组合式函数或者插件而不是在页面里堆代码。原因有三个。第一一个项目里可能很多页面都要这个效果全局注册一次所有路由页面自动生效不用每个页面都引入一遍。第二特效逻辑和业务逻辑是天然解耦的。点击特效属于“视觉增强”层不该污染业务组件里的代码。把特效独立出来做成一个插件或组件卸载的时候直接移除监听器和DOM节点不会留坑。第三Vue 3的script setup和组合式API写这类副作用逻辑特别顺手onMounted里挂监听、onBeforeUnmount里卸监听生命周期清晰。你还能把特效参数抽成props或plugin options比如颜色、大小、动画时长改起来不用碰组件内部逻辑。2. 最易上手的通用水波纹效果DOM加CSS动画2.1 先定好需求形态我先说一个最通用的版本点击页面任意位置鼠标落点出现一个圆形波纹从零开始放大边放大边变透明整个动画大概0.6秒结束。这个效果适合绝大多数后台管理页面和工具类网站不抢眼但能让操作反馈变得细腻。为了让它在任何页面都能正常工作特效层必须满足三个条件覆盖整个视口所以使用position: fixed四个方向的偏移都设为0。不拦截用户的真实点击必须给特效层设置pointer-events: none。z-index要足够高保证其他元素盖不住它建议直接在style里把这个值提到接近上限。这三点里最容易忽略的是pointer-events。我把这个坑踩过一次特效层加完之后页面所有按钮都点不动了排查了半天发现就是特效层把点击拦截了。2.2 核心代码一个全局ClickRipple组件下面是一份可以直接用的ClickRipple组件代码基于Vue 3组合式API。!-- ClickRipple.vue -- template div classclick-ripple-layer/div /template script setup import { onMounted, onBeforeUnmount } from vue const props defineProps({ color: { type: String, default: rgba(255, 255, 255, 0.7) }, duration: { type: Number, default: 650 }, maxCount: { type: Number, default: 20 } }) let clickHandler null let layer null function spawnRipple(event) { if (!layer) return if (layer.children.length props.maxCount) { layer.removeChild(layer.firstChild) } const size Math.floor(Math.random() * 30 44) const span document.createElement(span) span.className click-ripple-item span.style.width ${size}px span.style.height ${size}px span.style.left ${event.clientX - size / 2}px span.style.top ${event.clientY - size / 2}px span.style.borderColor props.color span.style.animationDuration ${props.duration}ms span.addEventListener(animationend, () span.remove()) layer.appendChild(span) } onMounted(() { layer document.querySelector(.click-ripple-layer) clickHandler (event) spawnRipple(event) // 用捕获阶段监听避免业务代码里的 stopPropagation 拦截特效 window.addEventListener(click, clickHandler, true) }) onBeforeUnmount(() { if (clickHandler) { window.removeEventListener(click, clickHandler, true) } if (layer) { layer.innerHTML } }) /script style scoped .click-ripple-layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; overflow: hidden; } .click-ripple-item { position: absolute; border: 2px solid rgba(255, 255, 255, 0.7); border-radius: 50%; transform: scale(0); opacity: 1; animation: clickRipple ease-out forwards; } keyframes clickRipple { 0% { transform: scale(0); opacity: 1; } 100% { transform: scale(2.6); opacity: 0; } } /style2.3 事件挂载与动画清理上面代码里有两个细节值得展开讲一下。第一个细节是window.addEventListener(click, clickHandler, true)里的第三个参数true。它表示在捕获阶段就触发特效逻辑。这在实战中非常重要因为很多组件库的按钮、弹窗内部会调用event.stopPropagation()阻止事件冒泡如果你不加捕获参数监听器在冒泡阶段就收不到事件了。加了之后事件从window往目标元素传播的过程中就会被捕捉到特效不受任何业务代码影响。第二个细节是动画结束后的清理。我用的方式是给span绑定animationend事件动画一旦结束立刻调用span.remove()。千万不要以为浏览器会自己回收DOM不手动移除的话点一次页面就多一个节点一个活动页点几百下页面就会出现几千个僵尸节点内存和渲染压力都会上来。作为兜底我还在生成新波纹前加了一个判断如果特效层里已经有20个节点就先移除最老的。这是给那些“狂点用户”准备的保护机制防止极端情况下节点数量失控。2.4 样式参数调校特效好不好看主要看样式调参。我把自己常用的参数配置列一下可以直接抄。边框粗细2~3px比较精致太粗会显得突兀太细在浅色背景下看不见。初始大小44~74px较为适中比这个更小显得小家子气更大就挡视线。放大比例scale(2.4)~scale(3)区间比较耐看放大倍数太小没有扩散感太大稀疏。动画时长新手建议从650ms开始调低于350ms会让人来不及感知高于1000ms会拖沓。颜色白色半透明在深色背景上效果最佳浅色背景项目建议改用rgba(0, 0, 0, 0.25)或主题色比如rgba(64, 158, 255, 0.5)。我个人偏好把波纹颜色和项目主题色绑定在引入插件时通过props传进去这样不同项目复用时只需要改一行配置。3. 进阶玩法Canvas粒子烟花效果3.1 为什么这次用Canvas水波纹是“单个元素放大消失”视觉上限有限。如果你想要的是那种鼠标一碰就炸开十几颗粒子、四散飞溅的效果DOM方案就很吃力了。这时候就得用Canvas。原理也很简单页面上盖一层透明canvas监听点击事件在鼠标落点生成一个粒子数组每个粒子记录自己的位置、速度、颜色、大小、生命周期。然后用requestAnimationFrame做逐帧动画每帧更新粒子的位置和状态并重新绘制整张画布。Canvas方案相比DOM方案的一个核心优势是粒子数量可以非常多。DOM节点一旦超过几百个页面就会明显卡顿而Canvas绘制几千个粒子也毫无压力因为它的绘制工作全部由GPU或底层原生代码处理不涉及DOM diff和样式重算。想要做出更复杂的拖尾、模糊、发光效果Canvas也更容易通过globalCompositeOperation、shadowBlur这些原生API实现。3.2 粒子系统设计与Canvas初始化先写一个组合式函数useClickParticles封装整个逻辑。// useClickParticles.js import { onMounted, onBeforeUnmount } from vue const PALETTE [ #ff6b6b, #ffd93d, #6bcb77, #4d96ff, #b980f0 ] export function useClickParticles(options {}) { const config { count: 18, speed: 3.6, gravity: 0.15, decay: 0.018, palette: PALETTE, ...options } let canvas null let ctx null let particles [] let rafId 0 function createParticle(x, y) { const angle Math.random() * Math.PI * 2 const speed Math.random() * config.speed 1 return { x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 1.8, size: Math.random() * 4 1.5, color: config.palette[Math.floor(Math.random() * config.palette.length)], life: 1, decay: Math.random() * 0.015 config.decay } } function spawn(event) { for (let i 0; i config.count; i) { particles.push(createParticle(event.clientX, event.clientY)) } } function tick() { if (!ctx) return ctx.clearRect(0, 0, canvas.width, canvas.height) particles particles.filter((p) p.life 0) for (const p of particles) { p.x p.vx p.y p.vy p.vy config.gravity p.vx * 0.99 p.life - p.decay ctx.globalAlpha Math.max(p.life, 0) ctx.fillStyle p.color ctx.beginPath() ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) ctx.fill() } rafId requestAnimationFrame(tick) } function handleClick(event) { spawn(event) } function resize() { if (canvas) { canvas.width window.innerWidth canvas.height window.innerHeight } } onMounted(() { canvas document.createElement(canvas) canvas.className click-particle-layer canvas.style.cssText position:fixed;inset:0;pointer-events:none;z-index:2147483647; document.body.appendChild(canvas) ctx canvas.getContext(2d) resize() window.addEventListener(resize, resize) window.addEventListener(click, handleClick, true) rafId requestAnimationFrame(tick) }) onBeforeUnmount(() { cancelAnimationFrame(rafId) window.removeEventListener(resize, resize) window.removeEventListener(click, handleClick, true) if (canvas) { ctx null canvas.remove() canvas null } particles [] }) return { getParticleCount() { return particles.length } } }3.3 动画循环与粒子更新上面代码的动画循环是整个方案的中枢我建议你把它逐行读一遍。主要做什么事情用ctx.clearRect清空上一帧画布过滤掉已经死亡的粒子然后遍历所有存活粒子更新位移、速度、透明度逐个画成小圆点最后调用requestAnimationFrame(tick)排队下一帧。这里有几个关键参数对最终效果影响很大我解释一下它们的物理含义vx和vy是粒子的水平和垂直速度。水平方向每个粒子方向随机垂直方向初始给予一个向上的偏移-1.8这样粒子会先散开再下落符合“喷发”的手感。vy gravity模拟重力加速度每次乘以0.99模拟空气阻力。不加速的话粒子会一直匀速飞非常不自然。life和decay控制粒子存活时间。每帧把life减去一个随机衰减量life小于等于0时粒子被过滤掉。这个衰减量越大粒子消失得越快。如果你想让烟花更“炸”一点就把count从18调到30到40同时把speed也调高。如果想让效果更柔和就把decay调小一点让粒子飞得更久、更飘。3.4 颜色、数量、命中率调优调优的时候可以直接用我这边总结的参数组合快速试效果。描述countspeedgravitydecay适用场景标准粒子183.60.150.018官网、博客爆炸感强365.20.220.025庆祝、节日模式轻盈飘散122.20.050.008禅意、木鱼风格流星拖尾306.00.30.012动态壁纸、游戏结算颜色方面我默认用的五色花瓣配色适合大多数场景但真正上线前我强烈建议换成项目主色调的相近色系。比如品牌色是蓝色就直接用“深蓝、亮蓝、浅蓝、白、蓝紫”这组品牌感知会更强。还有一个性能建议如果你的项目会跑在低端安卓机上请把count控制在20以内。高帧率动画在高端机上是享受在低端机上就是灾难页面滚动都会掉帧。4. 另一种常见形态点击弹出文字4.1 需求场景水波纹和粒子都很“通用”但有些项目需要更有内容感的反馈比如点击后从落点冒出一段文字慢慢向上飘散。这种效果常见于两类场景一类是纪念日网站、贺卡类项目里点击出现“love”“peace”等情感词汇另一类是引导页或产品页里点击出现卖点关键词比如“快”“稳”“省心”。我做过一个文创项目需求是在一个全屏H5里用户点击任意位置就随机出现一句品牌文案文案向上飘然后淡出。团队最后就采用的这个方案用户反馈互动感很强。4.2 组件实现实现思路其实和水波纹一模一样唯一的区别是临时元素的内容从“空span”变成了“带文字的span”。我直接写一个组件改造版。!-- ClickTextPop.vue -- template div classclick-pop-layer/div /template script setup import { onMounted, onBeforeUnmount } from vue const props defineProps({ texts: { type: Array, default: () [居然点到了, 666, 好耶] }, fontSize: { type: Number, default: 16 }, color: { type: String, default: #ffd700 } }) let clickHandler null function showText(event) { const layer document.querySelector(.click-pop-layer) if (!layer) return const el document.createElement(div) el.className click-pop-item el.textContent props.texts[Math.floor(Math.random() * props.texts.length)] el.style.left ${event.clientX}px el.style.top ${event.clientY}px el.style.fontSize ${props.fontSize}px el.style.color props.color el.addEventListener(animationend, () el.remove()) layer.appendChild(el) } onMounted(() { clickHandler (event) showText(event) window.addEventListener(click, clickHandler, true) }) onBeforeUnmount(() { window.removeEventListener(click, clickHandler, true) }) /script style scoped .click-pop-layer { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; overflow: hidden; } .click-pop-item { position: fixed; transform: translate(-50%, -50%); font-weight: 600; white-space: nowrap; pointer-events: none; animation: popFloat 1s ease-out forwards; } keyframes popFloat { 0% { opacity: 0; transform: translate(-50%, -30%); } 15% { opacity: 1; } 100% { opacity: 0; transform: translate(-50%, -180%); } } /style /style注意这里position用的是fixed而不是absolute并且加了translate(-50%, -50%)这样文字会以鼠标坐标为视觉中心弹出来不会偏到右下角。动画我用了四个关键帧一出来先快速显示然后慢慢向上漂最后渐渐消失。中间那段15%处opacity设为1是为了让文字“亮”起来的过程更有层次感。你也可以把这段动画延长到1.4秒配上旋转或者缩放比如从scale(0.8)变为scale(1.1)效果会更俏皮。4.3 样式设计文字类特效最核心的就是文案和字体的搭配。技术实现一小时文案可能改三天这不是玩笑因为用户第一眼看到的就是文字。几个我实践下来的经验文案要短最多四到六个字长了容易触发换行特效直接崩坏。文案要有情绪不建议写“点击成功”这种系统提示语换成“太棒了”“再来一次”这类有互动感的表达。字体方面中文可以用稍微粗一点的系统字体英文可以用衬线体。如果项目后端有CDN加载一款有设计感的可变字体效果会好很多。文字不要加描边或多重阴影容易在小字号时出现渲染毛刺。弹出数量同样要做上限控制比如同一时刻最多存在30个文字节点。加一个和波纹组件一样的maxCount逻辑就可以。5. 工程化落地封装成Vue插件5.1 把组件包装成插件前面的实现都是在页面里放一个组件但实际项目里往往需要全局生效或者说二十个页面都要有同样的效果。这时候直接在每个页面引入组件会很啰嗦所以更工程化的做法是封装成Vue插件在main.js里注册一次全局都能用。利用Vue 3的createVNode和render我们可以把特效组件挂载到一个独立div上彻底脱离业务组件树。这样路由切换不会导致特效组件销毁业务组件的卸载也不影响特效层的存在。// clickEffectPlugin.js import { createVNode, render } from vue import ClickRipple from ./ClickRipple.vue export default { install(app, options {}) { const container document.createElement(div) document.body.appendChild(container) const vnode createVNode(ClickRipple, options || {}) render(vnode, container) } }然后在main.js里使用import { createApp } from vue import App from ./App.vue import ClickEffectPlugin from ./plugins/clickEffectPlugin createApp(App).use(ClickEffectPlugin, { color: rgba(64, 158, 255, 0.6), duration: 700, maxCount: 30 }).mount(#app)这样整个应用只需要一行配置就拥有了全局点击特效。你甚至可以在同一个插件里根据options.type决定挂载波纹组件还是粒子Canvas做成一个多能力插件。5.2 使用方式与全局配置插件的形式让特效配置变得很灵活。我维护过一个内部组件库把点击特效做成了组件库内置能力使用者只需要在config里传入enableClickEffect: true就会自动加载特效还会根据主题色自动匹配颜色。如果你也想做成可配置的建议把下面几个参数做成open到插件options里typeripple | particle | text | nonecolor特效主色duration动画时长波纹和文字用的是毫秒粒子用的是逻辑参数count粒子数量zIndex特效层层级默认给到最大maxCount同屏最大特效节点数统一配置还有一个额外好处当运营想临时关闭特效比如某些低端机用户访问时不需要改动任何一个业务页面只要把全局config里的type改成none就行。5.3 与路由和打包的配合很多场景下点击特效不用全站生效比如只在首页生效或者只在某个活动路由下生效。这时候插件方案反而不灵活推荐用路由守卫控制。router.afterEach((to) { const enabled to.meta?.clickEffect ! false document.body.classList.toggle(global-click-effect, enabled) })然后用CSS选择器控制特效层的挂载范围。比如在body上打一个global-click-effect类名插件内部挂载时判断这个类名是否存在。打包方面有一个建议如果你的项目只有少数页面需要特效可以把特效层组件和逻辑做成单独模块通过动态import引入。这样主包体积不受影响特效代码只在需要时加载。示例写法const enableClickEffect async () { const { default: plugin } await import(../plugins/clickEffectPlugin) app.use(plugin) }6. 实战踩坑性能、事件覆盖、组件卸载等常见问题6.1 动画节点不清理导致内存爆炸这是点击特效最常见的坑没有之一。我刚做第一个版本的时候直接在点击回调里创建span却忘了在动画结束后移除节点。结果用户点了几分钟浏览器任务管理器里DOM节点数量直接破万页面开始明显卡顿最后被迫刷新。解决方案有两个层面。第一层是基础操作给每个特效节点绑定animationend后立刻移除。第二层是兜底防护每次生成新节点前检查当前节点数量超过阈值就移除最老的节点。兜底代码我前面组件里已经写了直接复用就行。6.2 点击事件被特效层拦截特效层是全屏fixed定位的如果忘记加pointer-events: none它就会像一块透明玻璃一样挡住所有点击事件。这是新手最容易忽略的问题。我建议把pointer-events: none写进特效容器的公共样式里而不是手写style。因为手写CSS很容易被scoped或打包工具的样式隔离策略漏掉公共样式可以保证始终生效。6.3 节流与触发频率用户快速点击时事件回调可能一秒钟触发几十次特效节点瞬间暴增。这种情况下即使每次创建的节点很轻量也会在短期内给浏览器带来不小的压力。我习惯加一个简单的时间戳节流控制在100毫秒内最多触发一次。代码很简单let lastTime 0 function throttleClick(event) { const now Date.now() if (now - lastTime 100) return lastTime now spawnRipple(event) }这里有一个取舍如果节流间隔太短连续点击时部分点击没有反馈手感会差如果太长又起不到保护作用。我试验下来100~120毫秒是比较合适的平衡点既不会吞反馈又不会让性能崩掉。6.4 移动端300毫秒延迟与touch事件移动端上click事件天然存在300毫秒延迟虽然现代浏览器和viewport设置了widthdevice-width后大部分场景下已经没有强制延迟但部分WebView里仍然存在。如果你想要移动端点击立即出特效建议改用pointerdown代替click。const eventName window.PointerEvent ? pointerdown : touchstart window.addEventListener(eventName, handlePointerDown, true)pointerdown的特点是按下立即触发不等抬起来所以特效反馈会比click及时很多。代价是用户滑动手势时也会触发特效好在这个场景一般是可接受的你甚至可以利用它做出“拖动点燃”的效果。6.5 常见问题速查表我把实战中遇到过的问题整理成了一个速查表拿去对号入座。现象原因解决方法特效不出现没有监听window事件或组件卸载时监听被移除检查onMounted/onBeforeUnmount成对出现检查事件名拼写按钮点击不了特效容器挡住了点击给容器加pointer-events: none页面越用越卡特效节点或粒子未清理动画结束后remove节点粒子过滤死粒子的逻辑不要漏特效在路由切换后消失特效组件被业务路由组件销毁改用插件挂载或提为全局组件特效在弹窗上不显示弹窗的z-index比特效层高调高特效层z-index或显式传入更大值低端机掉帧严重粒子数量过多或DOM节点过多调小count和maxCount或改用Canvas方案点击被业务stopPropagation拦截冒泡阶段监听被阻断window监听时使用捕获参数true特效层挡住滚动操作容器覆盖全屏必须确保持续为pointer-events: none排查这类问题时最有效的工具还是浏览器的DevTools在Elements面板里实时查看特效层节点数量在Performance面板里录制一段快速点击的片段基本能定位到90%的问题。最后一句话经验用代码生成的特效一定要在代码里负责销毁浏览器不会帮你做任何清理工作。我在实际项目里最终保留的是“波纹加粒子”两件套波纹作为默认方案粒子方案留给活动类和营销类页面。每次做新项目时我先把点击特效插件copy过去再根据项目主色改一版配色整个接入过程不超过十分钟。后续如果遇到好看的点击动效你也可以试着反推它是DOM方案还是Canvas方案解构原理之后你大概率能自己写出一版更贴合项目气质的版本。