气泡跟随鼠标移动的前端实现:插值、Canvas与性能优化全解析

发布时间:2026/10/18 4:11:29
气泡跟随鼠标移动的前端实现:插值、Canvas与性能优化全解析
很多前端同学第一次在作品集或者活动页里看到“气泡跟随鼠标移动”这种效果时第一反应是这东西是不是用了什么复杂的物理引擎或者 WebGL。其实拆开来看它就是一个非常典型的“平滑跟随”交互鼠标位置作为目标点气泡作为被驱动对象中间加一层插值或者惯性处理视觉上就有了那种黏黏的、软软的跟随感。这个效果很适合用在小型个人站点、落地页的氛围装饰、引导提示气泡甚至产品官网的互动 Banner 上。我做过不少类似的需求今天就把从零实现到进阶玩法的完整思路和踩坑记录整理出来前端新手能照着做有经验的也能从参数取舍和性能优化里找到点新东西。1. 效果拆解与方案选型先想清楚再写代码1.1 跟随的视觉本质目标点与“永远差一点”的滞后感气泡跟随鼠标听起来像是“鼠标在哪气泡就在哪”但如果你真的把气泡坐标直接等于鼠标坐标这个效果基本就废了。因为那样气泡会变得极其生硬像一块贴纸死死粘在光标上毫无“气泡感”。真正的视觉核心是滞后气泡永远在朝鼠标位置移动但永远差一点点才到。鼠标停住不动气泡会缓缓悠悠地飘过去鼠标快速甩动气泡会拉出一条柔软的轨迹像被一根看不见的橡皮筋拽着。这个“永远差一点”的技术名词叫插值interpolation更具体的说是线性插值加上一个阻尼系数。用生活化的例子理解你牵着一只气球走路气球不会瞬间跟着你的手位移到新位置它会摆动、延迟、追着你走。这个延迟感就是气泡动画的灵魂。整个效果里最值得调的就是这个延迟系数——太小气泡像喝醉了酒半天才反应过来太大气泡又像装了弹簧一样贴脸跟手两种极端都不好看。后面我给了具体的取值范围照着试就行。1.2 两种主流实现路线对比DOM 方案还是 Canvas 方案做这类效果业界基本两条路用 DOM CSS transform 实现少量气泡或者用 Canvas 实现大量粒子动效。选哪条取决于你到底要多少个气泡。对比维度DOM CSS transform 方案Canvas 粒子方案气泡数量适合 30 个以内适合几十上百个实现难度低代码直观中等需要自己管理粒子数据交互能力每个气泡都是真实 DOM能绑定点击事件需要自己做点击命中检测性能瓶颈大量 DOM 节点拖慢渲染单帧绘制全部粒子数量多时依然吃 CPU/GPU适用场景装饰元素、引导提示、品牌 Logo 跟随粒子背景、星空拖尾、大面积氛围动效我个人的选型准则很简单如果气泡只有几个到十几个并且还要响应点击、悬浮提示之类的事件优先选 DOM 方案。如果需求是“整个页面背景有几十个气泡在飘”那果断 Canvas不然 DOM 节点太多哪怕 transform 性能不错内存和事件处理也会变得麻烦。这里还要注意一个实现层面的大坑不管选哪条路都不要用top/left来驱动位移动画。这两个属性一旦变化浏览器就要重新计算布局Layout每一帧都重排会导致明显的卡顿。正确的做法是用transform: translate(...)或者 Canvas 里直接操作坐标。原因在浏览器渲染机制里transform只触发合成Composite阶段不需要重新布局和绘制GPU 可以直接处理这是这个效果能流畅跑起来的核心前提。2. 从零搭一个最小可用版本DOM 气泡的平滑跟随2.1 基础骨架HTML、CSS 与定位方式先写一个最精简版本后面所有进阶功能都基于它。HTML 只需要一个元素div classbubble/divCSS 部分几个关键点需要特别解释.bubble { position: fixed; left: 0; top: 0; width: 56px; height: 56px; border-radius: 50%; background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.7), rgba(94, 179, 255, 0.45) 30%, rgba(35, 122, 252, 0.6)); box-shadow: 0 8px 24px rgba(64, 158, 255, 0.25); pointer-events: none; z-index: 9999; transform: translate(-50%, -50%); }position: fixed意味着气泡始终相对于视口定位不会因为页面滚动而乱跑。left: 0; top: 0是代码的方便写法让气泡的初始位置在视口左上角之后我们用transform: translate(...)来移动它。pointer-events: none是绝对不能漏的——没有这行气泡会盖在页面上挡住所有鼠标事件按钮、链接全都点不了用户会以为网页坏了。transform: translate(-50%, -50%)是为了让气泡中心对准坐标点不然默认是左上角对齐。气泡的 CSS 渐变背景可以写得随意一点模拟玻璃质感或者半透明水泡都可以。核心是radial-gradient中间高光、边缘透明感看起来才像一个气泡。2.2 用 requestAnimationFrame 驱动平滑插值lerpJavaScript 的核心就是那个插值公式。我直接贴出可以跑的完整代码const bubbleEl document.querySelector(.bubble); // 当前坐标和目标坐标 let currentX window.innerWidth / 2; let currentY window.innerHeight / 2; let targetX currentX; let targetY currentY; // 插值系数建议范围 0.08 ~ 0.15 const factor 0.1; // 鼠标移动时只更新目标坐标 window.addEventListener(mousemove, (event) { targetX event.clientX; targetY event.clientY; }); function loop() { // 核心插值公式current (target - current) * factor currentX (targetX - currentX) * factor; currentY (targetY - currentY) * factor; // 用 transform 移动气泡中心对准目标点 bubbleEl.style.transform translate( currentX px, currentY px) translate(-50%, -50%); requestAnimationFrame(loop); } loop();这个插值公式是理解整个效果的关键。factor代表每一帧气泡向目标方向“补足”多少比例。假设 factor 是 0.1那么第一帧气泡会走完剩余距离的 10%第二帧又走剩下那 90% 的 10%理论上无限逼近但永远追不上——这正是我们要的滞后感。不同 factor 值的实际表现差别很大我实测后的感受放在表格里factor 值视觉效果适用场景0.16 ~ 0.2跟随非常紧几乎贴手需要气泡当作准星、精确指示时0.08 ~ 0.12柔和跟手稍微拖尾绝大多数装饰场景最推荐0.04 ~ 0.06软绵绵拖尾长想要明显的梦幻漂浮感小于 0.03反应迟钝像失灵不建议除非刻意做慢速飘动另外一个容易被忽略的细节监听mousemove时回调里只更新targetX/targetY不要在回调里直接改气泡的坐标或者样式。这样做的原因很务实——mousemove一秒钟可能触发几十上百次如果每次都直接操作 DOM 和计算布局会和requestAnimationFrame的更新打架造成掉帧。把目标坐标存下来让loop()统一去读就像消费者和生产者的模式干净又高效。2.3 呼吸感小动作给气泡叠加随机漂移只做直线插值气泡虽然跟手了但看起来有点“死”——像被一根钢丝牵着走。真实的气泡应该有自己的小动作所以我习惯在每一帧的坐标上叠加一个微小的三角函数扰动// 初始化随机相位避免所有气泡动作整齐划一 const phaseX Math.random() * Math.PI * 2; const phaseY Math.random() * Math.PI * 2; let time 0; function loop() { time 0.015; // 振幅建议在 1~3px 之间太大的扰动会喧宾夺主 const breathX Math.sin(time phaseX) * 1.5; const breathY Math.cos(time phaseY) * 1.5; bubbleEl.style.transform translate( (currentX breathX) px, (currentY breathY) px) translate(-50%, -50%); requestAnimationFrame(loop); }Math.sin和Math.cos的扰动量很小只有一两像素但视觉上能让气泡摆脱“纯机械跟随”的感觉。注意两个轴分别用了不同的相位初始值否则气泡会做斜向的规则运动反而显得更假。这个细节是很多人忽视的但做出来的效果差异非常明显。3. 进阶玩法视差分层、按压反馈与多气泡矩阵3.1 视差分层让每个气泡保持自己的“深度”单气泡只能算 demo用到真实项目里通常是一组气泡。如果所有气泡都按照同一参数跟随鼠标画面会非常整齐但整齐过头了缺少层次感。实际我建议引入“深度系数”每个气泡拥有不同的factor插值系数和跟随幅度离用户视觉越远的气泡移动越缓慢、幅度越小越靠前的气泡移动越灵敏、幅度越大。这就形成了视差效果。代码上用数组管理一组气泡const bubbleData []; const COUNT 12; for (let i 0; i COUNT; i) { const el document.createElement(div); el.className bubble; // 随机尺寸 24 ~ 64px const size 24 Math.round(Math.random() * 40); el.style.width size px; el.style.height size px; // 半透明、随机颜色微调这些都可以根据设计稿调整 el.style.opacity 0.4 Math.random() * 0.4; document.body.appendChild(el); bubbleData.push({ el, size, // 深度系数决定跟随幅度和速度 depth: 0.2 Math.random() * 0.8, factor: 0.06 Math.random() * 0.06, currentX: window.innerWidth / 2, currentY: window.innerHeight / 2, targetX: window.innerWidth / 2, targetY: window.innerHeight / 2, phase: Math.random() * Math.PI * 2, }); } window.addEventListener(mousemove, (event) { for (const bubble of bubbleData) { bubble.targetX event.clientX; bubble.targetY event.clientY; } });关键就在每个气泡的depth上。驱动位置时不是让所有气泡都把鼠标坐标当目标而是把鼠标坐标相对视口中心做一次缩放function loop() { for (const bubble of bubbleData) { const centerX window.innerWidth / 2; const centerY window.innerHeight / 2; // depth 越大气泡离鼠标越近,移动幅度越大 const targetX centerX (bubble.targetX - centerX) * bubble.depth; const targetY centerY (bubble.targetY - centerY) * bubble.depth; bubble.currentX (targetX - bubble.currentX) * bubble.factor; bubble.currentY (targetY - bubble.currentY) * bubble.factor; bubble.el.style.transform translate( bubble.currentX px, bubble.currentY px) translate(-50%, -50%); } requestAnimationFrame(loop); }这么一改鼠标移动时深度浅的背景气泡只微微飘动深度深的前景气泡则紧跟鼠标肉眼立刻能感受到空间层次。注意这一大组气泡共用一个mousemove监听和同一个requestAnimationFrame循环性能上不会翻车。3.2 点击与按压反馈给气泡一个“回弹”的弹性既然是气泡被点击时总该有点反应。最自然的反馈是挤压一下——按压时变小松开时弹回像玩具一样有弹性。这里有个非常容易踩的坑我必须放前面提醒不要让按压动画和跟随动画共用同一个transform: translate(...)。因为我们的跟随动画每一帧都在改写transform如果按压效果又通过transform: scale(0.8)来实现两个动画会互相覆盖导致气泡要么只跟随不缩放要么缩放一下立刻被下一帧的 transform 覆盖掉看起来就是闪烁一下。解决办法很绕但很简单用现代浏览器支持的独立scale属性或者给气泡内部再加一个子元素来承载缩放。我习惯用前者代码更干净.bubble { transition: scale 0.15s ease-out; } .bubble.is-pressed { scale: 0.8; }JavaScript 里监听按压事件bubbleEl.addEventListener(pointerdown, () { bubbleEl.classList.add(is-pressed); }); bubbleEl.addEventListener(pointerup, () { bubbleEl.classList.remove(is-pressed); }); bubbleEl.addEventListener(pointerleave, () { bubbleEl.classList.remove(is-pressed); });pointerdown和pointerup可以同时覆盖鼠标和触摸屏比分别监听mousedown/mouseup和touchstart/touchend省事得多。松开后transition会接管 scale 的恢复过程形成一个从 0.8 弹回 1.0 的柔和回弹效果。如果你想更夸张的弹性可以给 transition 加一个带 overshoot 的cubic-bezier(0.34, 1.56, 0.64, 1)回弹感更强像果冻一样。3.3 随机生成与数量管理多少气泡才合适生成气泡的参数看起来随便给就行但有几个原则值得说一下尺寸最好是某个范围内的随机数并且遵循“小气泡略多、大气泡略少”的分布。因为如果所有气泡一样大画面像阅兵方阵很死板。透明度和尺寸类似随机范围控制在 0.3 到 0.8 之间太透明看不见太实心则抢了正文内容的注意力。数量方面DOM 方案下 10 到 20 个是比较稳妥的区间。少于 10 个画面太空超过 30 个性能开始下滑尤其在低端移动设备上。如果设计稿明确要求满屏气泡趁早换 Canvas 方案别硬扛。4. 常见问题排查气泡跟丢、错位与性能优化4.1 滚动后气泡位置错位这个问题经常出现在页面内容超出一屏、出现滚动条的时候。很多同学会以为event.clientX是页面绝对坐标然后直接把气泡的position设成absolute滚动之后气泡就定位到了莫名其妙的位置。根源在于电脑上读取鼠标位置时clientX和clientY永远是相对于当前视口你屏幕看到的区域的坐标不包含滚动距离。而absolute定位是相对于整个页面的滚动后页面坐标和视口坐标就不一致了。搞清楚之后解法很简单气泡用position: fixed然后直接使用clientX/clientY别转来转去。这是为什么我在基础骨架里坚持用fixed而不是absolute的原因提前避坑。4.2 气泡闪烁或位置跳变症状是鼠标移动时气泡偶尔会闪一下或者突然跳到另一个地方。最常见的原因是你在requestAnimationFrame的循环里读了会触发强制布局的属性比如offsetWidth、offsetHeight、getBoundingClientRect()。这些属性一旦读取浏览器会被迫立即重新计算布局哪怕下一帧你只是改了一下 transform两者反复拉扯视觉上就是卡顿和闪烁。另外如果你在mousemove回调里既更新目标坐标又直接改 DOM 样式也会造成类似问题。正确的做法还是我之前说的回调里只存数据渲染统一交给动画循环。我见过一个更隐蔽的情况气泡元素被浏览器判定为“不可见”时下一帧位置照样更新但视觉上会从旧位置突然跳到新位置。这种情况多半是因为display: none或者visibility: hidden在切换检查一下动态类名有没有误加。4.3 性能优化为什么 transform 和 opacity 才是动画宠儿聊性能就得说浏览器渲染流程。浏览器把一帧画出来大致经过 JavaScript 执行、样式计算Style、布局Layout、绘制Paint、合成Composite这几个阶段。改动top/left/width会导致 Layout 阶段重排代价最高改动color/background之类的会导致 Paint 阶段重绘中等而改动transform/opacity只需要在合成阶段处理GPU 能直接高效完成这也是做平滑动画首选它们的原因。具体到气泡场景我建议移动位置一律用transform: translate(...)不要用top/left。透明度变化用opacity不要用display: none来显示隐藏气泡。will-change: transform可以加但只加到少数的前置气泡上别给十几个气泡全加上。will-change的本质是提前创建合成层数量太多会占据大量 GPU 内存反而让页面变卡。减少气泡的数量比任何代码优化都见效快。如果你做了上述优化还是卡排查方向应该转向单帧开销。Canvas 方案里瓶颈通常在clearRect和arc的数量上可以把气泡绘制成离屏 Canvas 贴图再drawImage绘制性能提升非常明显。4.4 事件绑定与触摸设备兼容细节桌面端监听mousemove没问题但手机和平板上没有鼠标需要监听pointermove推荐或者touchmove。用pointermove最大的好处是代码不用区分设备window.addEventListener(pointermove, (event) { targetX event.clientX; targetY event.clientY; });pointermove在现代浏览器上兼容性已经很好了包括 iOS Safari。如果你需要兼容非常古老的浏览器再退回到mousemovetouchmove的组合。另外页面在移动端上下滚动时手指移动也会触发touchmove气泡自然跟着手指走。但如果你不希望气泡滚动时还跟着手指动像一个小尾巴可以在气泡移动逻辑里加一个开关只有手指在屏幕上时更新滚动过程中暂停。一种实现是监听touchstart打开跟随scroll事件触发时关闭跟随touchend之后再打开具体看交互设计怎么定。5. 继续扩展Canvas 粒子版与其他框架的封装思路5.1 让粒子群自动“追寻”鼠标基础粒子系统需求一旦变成“满屏很多气泡”DOM 就开始吃力了。Canvas 方案里每个气泡不再是独立 DOM而是数组里的一个对象每次动画循环清屏、更新坐标、绘制圆形。我把最核心的惯性模型代码放在这儿因为它能实现比 DOM 的 lerp 更自然的物理感const canvas document.getElementById(scene); const ctx canvas.getContext(2d); let w window.innerWidth; let h window.innerHeight; function resize() { w canvas.width window.innerWidth; h canvas.height window.innerHeight; } window.addEventListener(resize, resize); resize(); const mouse { x: w / 2, y: h / 2 }; window.addEventListener(pointermove, (event) { mouse.x event.clientX; mouse.y event.clientY; }); // 粒子池 const particles []; const COUNT 20; for (let i 0; i COUNT; i) { particles.push({ x: Math.random() * w, y: Math.random() * h, vx: 0, vy: 0, radius: 8 Math.random() * 18, // 阻尼系数越大衰减越慢运动越滑 damp: 0.9, // 弹性系数决定气泡被鼠标“吸引”的强度 strength: 0.04, color: hsla( (190 Math.random() * 40) , 70%, 65%, 0.55), }); } function frame() { ctx.clearRect(0, 0, w, h); for (const p of particles) { const dx mouse.x - p.x; const dy mouse.y - p.y; // 弹性力让气泡加速趋向鼠标 p.vx p.vx * p.damp dx * p.strength; p.vy p.vy * p.damp dy * p.strength; // 速度更新位置 p.x p.vx; p.y p.vy; // 绘制半透明圆 ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fillStyle p.color; ctx.fill(); } requestAnimationFrame(frame); } frame();这个模型的物理手感比纯插值好很多。damp决定速度衰减值越大气泡惯性越大移动越“油”strength决定气泡向鼠标靠拢的加速度值越大响应越快。我常用的手感组合是damp在 0.88~0.92、strength在 0.04~0.06稍微偏“滑”气泡之间交叉移动时很有弹性。Canvas 方案还可以很轻松加上碰撞、连线、大小变化等高级效果但核心还是这套“目标点 速度 阻尼”的模型先掌握这一层其他都是衍生。5.2 在 React 或 Vue 项目中如何封装如果你是在组件化框架里做这个效果思路很直接把鼠标坐标放进一个响应式容器里动画循环里读取但要注意别让响应式系统过度触发渲染。React 里用一个ref保存 DOM 节点useEffect里绑定事件和启动动画循环。注意不要在useEffect的依赖数组里放鼠标坐标变量否则每次鼠标移动都会重新执行 effect事件绑定和动画循环会被反复重建性能极差useEffect(() { const node ref.current; if (!node) return; let currentX 0; let currentY 0; let targetX 0; let targetY 0; let rafId; const onMove (event) { targetX event.clientX; targetY event.clientY; }; const loop () { currentX (targetX - currentX) * 0.1; currentY (targetY - currentY) * 0.1; node.style.transform translate( currentX px, currentY px) translate(-50%, -50%); rafId requestAnimationFrame(loop); }; window.addEventListener(pointermove, onMove); loop(); return () { window.removeEventListener(pointermove, onMove); cancelAnimationFrame(rafId); }; }, []);Vue 里思路类似onMounted里做同样的事onUnmounted里清理。核心逻辑就在loop函数里框架只是负责给你一个 DOM 引用。5.3 给项目加一个更容易出彩的包装气泡的“距离感应”缩放如果你想让气泡效果在作品集里更出彩可以给气泡加一个基于距离的缩放逻辑鼠标离气泡越近气泡越大越远气泡越小。这种“斥力引力”混合的效果非常讨喜。实现不复杂在动画循环里计算鼠标坐标和每个气泡坐标的直线距离然后用一个函数映射到 scale 值上。保留前文“独立 scale 属性”的坑DOM 方案就把缩放写到 CSS 变量里Canvas 方案直接改绘制半径const dx mouse.x - p.x; const dy mouse.y - p.y; const dist Math.sqrt(dx * dx dy * dy); const maxDist 300; // 距离越近半径越大但设一个上限避免气泡大得离谱 const scale Math.max(0.6, 1.3 - dist / maxDist); ctx.arc(p.x, p.y, p.radius * scale, 0, Math.PI * 2);这个效果加上前面说的视差和呼吸漂移整个气泡群就不只是“跟手”而是有了自己的生命感。最后分享一点个人体会做这类氛围动画最容易翻车的地方不是技术而是克制。气泡只要过了十个每次调整参数后的视觉效果都不太一样好在关键参数比如factor、damp、strength都集中在动画循环开头我把它们抽成配置对象每次调都只动一个值当场看效果。你的项目如果也是装饰性的气泡动画我建议一开始就把数量、透明度、深度范围都做成可配置宁可在调试时多花 10 分钟也比上线后设计师拿着截图一个个对着改要省心得多。