☰
气泡跟随鼠标移动的前端实现:插值、Canvas与性能优化全解析
2026/10/18 4:11:20 网站建设 项目流程

很多前端同学第一次在作品集或者活动页里看到“气泡跟随鼠标移动”这种效果时,第一反应是这东西是不是用了什么复杂的物理引擎或者 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 class="bubble"></div>

CSS 部分,几个关键点需要特别解释:

.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 驱动平滑插值(lerp)

JavaScript 的核心就是那个插值公式。我直接贴出可以跑的完整代码:

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。如果你需要兼容非常古老的浏览器,再退回到mousemove+touchmove的组合。

另外,页面在移动端上下滚动时,手指移动也会触发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 分钟,也比上线后设计师拿着截图一个个对着改要省心得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询