很多人第一次看到这类效果,是在别人的个人主页或者产品引导页上:鼠标轻轻一动,一串半透明的气泡拖着小尾巴跟在光标后面飘,慢悠悠地浮起来又散开。我当时的第一反应是——这东西看起来挺简单,不就是让几个圆跟着鼠标走吗?等自己真动手做了一版才发现,里面藏着不少细节:既要跟得好看,又不能跟得太死板;要顺滑,又不能吃性能;还要照顾到不同屏幕和移动端。这篇文章我就把自己从零到一实现"气泡跟随鼠标移动的动画"的过程、选型思路和踩坑记录完整梳理一遍,代码可以直接复制去改着玩。
这个效果适合谁?如果你正在做个人主页的装饰性背景、活动页的互动元素、大屏可视化的氛围层,或者单纯想给自己的前端作品集加一个低成本高颜值的彩蛋,这篇都够用。我会从最核心的"跟随算法"讲起,再到 Canvas 粒子系统的实现,最后把性能优化和一堆不调试根本发现不了的坑都翻出来说清楚。
1. 先拆需求:跟随不是拷贝坐标,是"柔性滞后"
1.1 你以为的跟随,和最终效果差了十万八千里
最本能的做法是什么?监听鼠标的mousemove,拿到坐标,让气泡的中心点直接等于这个坐标。这样做出来的效果非常"硬"——气泡就像焊死在光标上一样,鼠标一停,气泡瞬间就不动了,完全没有那种轻盈的、像在水中漂浮的感觉。
我们想要的其实不是"锁定",而是"追随"。换句话说,气泡的位置和鼠标的位置之间应该存在一个弹性关系:鼠标动了,气泡先慢半拍起步,然后逐渐加速,最后如果鼠标停住,气泡还要带着一点点惯性滑过去再停稳。这个手感,才是这个动画的灵魂所在。
1.2 柔性跟随的数学原理:一阶插值就够了
实现这种"柔性跟随"根本不需要复杂的物理引擎,一个一阶线性插值(lerp)足矣。公式长这样:
current = current + (target - current) * factor其中factor是一个 0 到 1 之间的系数,代表每一帧向目标靠近的百分比。系数越小,跟随越慢、越"飘";系数越大,跟随越快、越"贴"。我用 0.12 到 0.15 这个区间比较多,视觉上既有明显的滞后感,又不会觉得反应迟钝到拖沓。
在实际代码里,我通常把鼠标位置和跟随位置分开记:
let targetX = 0, targetY = 0; // 鼠标的真实位置 let followX = 0, followY = 0; // 气泡的跟随位置 window.addEventListener('pointermove', (e) => { targetX = e.clientX; targetY = e.clientY; }); // 在动画循环里做插值 followX += (targetX - followX) * 0.14; followY += (targetY - followY) * 0.14;这里有个容易被忽略的点:pointermove里只更新target,千万不要直接在事件回调里做插值或创建气泡。事件触发的频率不受你控制,特别是高刷新率的鼠标,一秒钟能触发上百次,如果在回调里直接改绘制状态,动画会变得一抽一抽的。正确的做法是事件回调只负责"记录目标",真正的计算和绘制全部交给requestAnimationFrame的主循环去处理。
2. 技术路线怎么选:DOM 试了三版,最后还是回 Canvas
2.1 三种方案的直观对比
我当时在选型上纠结了一天,市面上大致有三条路:纯 CSS 过渡、操作真实 DOM 节点的 div、以及 Canvas 绘制。我把它们的关键差异整理成了一张表,方便你直观判断:
| 方案 | 实现难度 | 粒子数量上限 | 动画自由度 | 典型问题 |
|---|---|---|---|---|
| CSS 过渡 + 绝对定位 div | 低 | 20 个左右 | 低,只能靠 transform 和 opacity 做简单变化 | 节点一多,浏览器布局压力直线上升 |
| DOM 节点 + JavaScript 控制 transform | 中 | 50~80 个可用 | 中,能做位移、缩放、颜色变化 | 频繁创建销毁节点,垃圾回收会成为卡顿源头 |
| Canvas 2D 绘制 | 中高 | 几百到上千都行 | 高,任意图形、渐变、叠加效果都能画 | 需要自己管理生命周期和坐标变换 |
纯 CSS 方案我第一个排除了。不是说不能做,而是它只适合"几个气泡跟着鼠标慢慢滑"这种极轻量的场景,一旦你希望气泡有随机大小、有上浮漂移、有呼吸缩放、有成串产生的效果,CSS 的每个属性都要单独写过渡和动画,组合起来的复杂度反而比 Canvas 更高。
DOM 方案我也试了一版:每个气泡是一个 div,用transform: translate()来移动。确实能做出来,而且利用 GPU 合成层的话,几十个节点还能保持流畅。但问题出在"气泡会不断产生、不断消失"这件事上。你想想,鼠标每移动一小段就要生成一个新气泡,几秒钟之内可能就积累上百个节点,为了让画面干净还得定时删除它们。节点频繁增删,DOM 树反复改动,浏览器要不停重排和合成,动画的即时性就会打折扣。
2.2 为什么说"能不用 DOM 就别用 DOM"
我做第二版的时候,气泡数量一过 100,滑动鼠标就开始有肉眼能察觉的掉帧。打开性能面板一看,几乎全部时间都花在了 Layout 和 Paint 上。原因很简单:每个气泡都有随机大小、随机透明度、随机颜色,这些属性一旦被 JavaScript 逐个修改,浏览器就得重新算布局、重新绘制页面。
Canvas 的优势在于,它本质上是一块"画布",所有气泡都是直接在这块画布上"画"出来的,完全不参与 DOM 的布局过程。你只需要在每一帧里clearRect清空上一帧的内容,然后把所有气泡重新画一遍即可。对浏览器来说,它只认这一块 canvas 元素的绘制指令,压力小得多。这也是我第三版彻底切到 Canvas 的原因。
2.3 基础骨架:一个 canvas 跑通全流程
搭建一个最基础的骨架比我预想的简单,一个 HTML 文件就能跑起来:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>气泡跟随鼠标移动</title> <style> body { margin: 0; height: 100vh; overflow: hidden; background: linear-gradient(135deg, #0d1021, #1b1f3a); } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <canvas id="bubbleCanvas"></canvas> <script> // 后续所有核心代码都写在这里 </script> </body> </html>这个骨架里我把 body 设成了overflow: hidden并撑满视口,避免出现滚动条影响坐标计算。canvas 用display: block消除行内元素默认的底部空隙。接下来要处理的就是画布尺寸、光标采集和气泡逻辑这三件事。
3. 气泡系统的核心实现:从"一个点"到"一串泡泡"
3.1 采集光标位置:pointermove 才是正解
很多老教程还在用mousemove,但在触屏设备上它根本不会触发。如果你想兼顾鼠标和触摸,统一用pointermove就好。这个事件对鼠标、手指、触控笔都有效,而且clientX和clientY给的直接就是视口坐标,配合全屏 canvas 使用刚刚好。
还需要补充两个细节。第一,页面如果有滚动,务必用clientX / clientY,不要用pageX / pageY,否则滚动之后坐标会偏。第二,如果你的 canvas 不在页面左上角,而是要嵌在页面中间的某个容器里,那么鼠标坐标要减去 canvas 自身的偏移,最常见做法就是rect = canvas.getBoundingClientRect(),然后x = e.clientX - rect.left。
3.2 气泡的出生、成长与寿命
气泡的生成逻辑我做了两版。第一版是每帧在跟随点位置创建一个气泡,结果鼠标一停下来,同一位置堆了一摞气泡,乱糟糟的。第二版改成了"按距离间隔生成":只有跟随点移动的距离累积到一定阈值(比如 18 像素)才会生成一个新气泡,这样生成频率就稳定了,鼠标停住时不会继续堆积。
具体的生成函数长这样:
let lastSpawnX = 0, lastSpawnY = 0; const SPAWN_DISTANCE = 18; function spawnAlongPath(curX, curY) { const dist = Math.hypot(curX - lastSpawnX, curY - lastSpawnY); if (dist < SPAWN_DISTANCE) return; const count = Math.floor(dist / SPAWN_DISTANCE); for (let i = 0; i < count; i++) { const t = i / count; const bx = lastSpawnX + (curX - lastSpawnX) * t; const by = lastSpawnY + (curY - lastSpawnY) * t; bubbles.push(new Bubble(bx, by)); } lastSpawnX = curX; lastSpawnY = curY; }每个气泡本身是一个对象,包含位置、半径、漂移速度、呼吸相位、透明度和寿命等字段。我的Bubble类大致是这样的:
class Bubble { constructor(x, y) { this.x = x; this.y = y; this.r = 4 + Math.random() * 10; // 半径:4~14px this.driftX = (Math.random() - 0.5) * 0.6; // 水平漂移 this.driftY = -0.2 - Math.random() * 0.8; // 轻微上浮 this.phase = Math.random() * Math.PI * 2; // 呼吸初始相位 this.pulseSpeed = 0.5 + Math.random() * 1.5; // 呼吸速度 this.alpha = 0.25 + Math.random() * 0.4; // 初始透明度 this.age = 0; this.life = 120 + Math.random() * 180; // 寿命:120~300帧 } update() { this.age++; this.x += this.driftX; this.y += this.driftY; this.phase += this.pulseSpeed; // 寿命将尽时缓慢变透明 if (this.age > this.life * 0.7) { this.alpha = Math.max(0, this.alpha - 0.012); } } }观察这几组随机参数你会注意到,我把driftY设成了负值,也就是所有气泡都会有一个"往上飘"的趋势。这个小小的设计模仿了水中气泡受浮力上升的感觉,配合水平方向的随机漂移,就让整串气泡有了生命力,而不只是僵硬地重复鼠标的轨迹。
3.3 主循环里的每一帧都干了什么
动画主循环我用requestAnimationFrame驱动,它会把动画刷新率和屏幕刷新率对齐(通常是 60FPS,高刷屏可能是 120FPS)。每一帧内做的事情顺序很关键:
function animate() { // 1. 清空画布 ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); // 2. 柔性跟随 followX += (targetX - followX) * 0.14; followY += (targetY - followY) * 0.14; // 3. 按距离生成新气泡 spawnAlongPath(followX, followY); // 4. 倒序遍历气泡,更新并绘制,同时清理死亡气泡 for (let i = bubbles.length - 1; i >= 0; i--) { const b = bubbles[i]; b.update(); drawBubble(b); if (b.y < -30 || b.y > window.innerHeight + 30 || b.x < -30 || b.x > window.innerWidth + 30 || b.age > b.life) { bubbles.splice(i, 1); } } // 5. 数量兜底,防止极端操作导致内存飙升 if (bubbles.length > 600) { bubbles.splice(0, bubbles.length - 600); } requestAnimationFrame(animate); } requestAnimationFrame(animate);这里有两个容易犯错的地方。第一,清理气泡必须倒序遍历,因为splice会改变数组索引,正序删会漏掉元素。第二,清空画布用的是clearRect,如果后续想做"拖尾残留"效果,就不能彻底清空,而是要在上面盖一层半透明底色,这个我在后面扩展部分会细说。
3.4 气泡的"呼吸感"是怎么画出来的
纯实心圆看着太死板了,我给气泡加了两层处理,效果立刻就活了。
第一层是呼吸缩放:利用正弦波让气泡的半径在基准值附近轻微波动,营造出气泡在水中被挤压的弹性感。第二层是渐变填充:用径向渐变画出"亮斑在左上角、边缘虚化"的高光质感,让它看起来更像真实的透明气泡,而不仅仅是一个半透明圆。
function drawBubble(b) { const pulse = 1 + Math.sin(b.phase) * 0.12; const radius = b.r * pulse; ctx.save(); ctx.globalAlpha = b.alpha; // 背景渐变圆 const gradient = ctx.createRadialGradient( b.x - radius * 0.35, b.y - radius * 0.35, radius * 0.15, b.x, b.y, radius ); gradient.addColorStop(0, '#ffffff'); gradient.addColorStop(0.3, 'rgba(180, 200, 255, 0.9)'); gradient.addColorStop(1, 'rgba(120, 140, 255, 0)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(b.x, b.y, radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }createRadialGradient每次调用都会新建一个渐变对象,气泡数量不多时完全没问题。如果气泡数量要上到几百上千,就需要考虑缓存渐变或者直接用纯色加globalCompositeOperation来优化。在我这个场景下,两百个以内的气泡用渐变很合适,视觉质感提升非常明显。颜色方面我用的是一组偏蓝紫的色系,也可以按页面主视觉换掉。
4. 性能与细节:丝滑和卡顿之间只差这几步
4.1 高清屏适配:DPR 不处理,边缘必发虚
第一次在 Retina 屏上打开我的效果,气泡边缘全是毛刺,看起来非常廉价。原因是逻辑像素和设备物理像素不一致,一个 CSS 像素在 Retina 屏上其实对应 2x2 个物理像素。如果 canvas 的分辨率没有跟着提升,浏览器就会用拉伸的方式显示,边缘自然会发虚。
解决办法是给 canvas 设置物理像素的宽高,再用ctx.scale把坐标系统一回去:
function initCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; ctx.scale(dpr, dpr); }窗口尺寸变化时(比如手机横竖屏切换、桌面端拖拽改变窗口大小)记得重新调用initCanvas(),同时膨大画布会清空内容。这个函数里已经有显示了。
4.2 对象池复用:别让内存悄悄涨上去
如果只是做个人主页点缀,直接new Bubble()再splice也没问题。但我把气泡数量放开到几百个之后,就发现一个隐患:频繁创建和销毁对象会加大垃圾回收的压力,垃圾回收一执行,主线程卡顿一下,动画就跳一帧。
更稳妥的做法是用对象池。我额外维护一个pool数组,气泡被移除时不是直接丢掉,而是把它的属性重置后放回池子;需要新气泡时优先从池子里取,取不到再new。一个小 demo 可能看不出区别,但当你把逻辑迁移到活动页或者产品引导这种需要长时间运行的场景时,对象池带来的稳定性是很值得的。
const pool = []; function getBubble(x, y) { if (pool.length > 0) { const b = pool.pop(); b.x = x; b.y = y; b.age = 0; b.r = 4 + Math.random() * 10; // ... 其余属性重置 return b; } return new Bubble(x, y); } // 清理逻辑改为回收 function recycleBubble(b) { pool.push(b); }4.3 绘制层面的小优化:少 save 少 restore
ctx.save()和ctx.restore()表面上看很方便:保存整个上下文状态,改完再还原。但如果一帧里对每个气泡都调用一次,性能开销实际不小,因为这两个方法要拷贝上下文的状态栈。我优化了一下,把状态设置集中到必要的部分:
globalAlpha只在变化时才赋值,大部分时候走默认值;- 渐变填充本身不需要
save/restore,画完之后下一帧本来就会被清空; - 我把透明度计算前置到对象属性里,绘制时只做赋值,不反复通过状态堆栈保护现场。
在有 300 个气泡的测试下,单帧绘制时间从原来的 8~10ms 降到了 5ms 左右,虽然绝对值不大,但给其他业务逻辑腾出了余地。
4.4 移动端与低端机的兜底方案
移动端主要靠触摸,pointermove能覆盖到。但触摸和鼠标有个体验上的差异:手指在屏幕上滑动会遮挡大半个屏幕,气泡如果数量太多反而显得脏。我给移动端做了一套降级逻辑:检测到navigator.maxTouchPoints > 0时,气泡半径调大一点、生成间隔调大一点、上限数量减半,同时取消一些高开销渐变,改为纯色加高光点。
const isTouch = navigator.maxTouchPoints > 0; const MAX_BUBBLES = isTouch ? 150 : 500; const SPAWN_STEP = isTouch ? 28 : 18;这种"自适应降级"很有必要,尤其是把页面挂到低端 Android 机上一试,你会发现桌面端优雅的气泡数量在那边直接变成了粘滞卡顿。给不同设备设置合理的天花板,是判断一个前端开发者有没有经验的试金石。
5. 边写边踩的坑:这些问题不调试根本发现不了
5.1 滚动页面时光标坐标全偏了
我最初用pageX / pageY来追踪鼠标,页面不做滚动时一切正常。但一旦页面能上下滚动,并且把 canvas 固定为背景层,坐标就全乱套了:鼠标往下滚动后再移动,气泡出现在完全错误的位置。
根因是pageX / pageY返回的是相对于文档左上角的坐标,包含了滚动的偏移量。而 canvas 作为视口内的固定层,期望的是相对于视口左上角的坐标。解决方案很简单:统一改用clientX / clientY,或者在每次事件里手动减去scrollX / scrollY。以后凡是做全屏固定层的交互,我都默认用clientX / clientY,这个习惯帮我避免了不少后续问题。
5.2 浏览器切后台再回来,气泡"瞬移"
有一阵子气泡数量会诡异地暴涨。排查了很久发现,用户切到别的标签页后,requestAnimationFrame会暂停,但pointermove事件不会暂停——如果用户在页面外还有一个鼠标移动事件被捕获,坐标会继续更新。切回来的时候,targetX / targetY已经跑到很远的地方,跟随坐标在下一帧疯狂追赶,生成了一大堆气泡。
解决思路有两个:一是监听visibilitychange,页面隐藏时重置跟随位置;二是在动画循环里限制单帧生成气泡的最大数量。我采用了后者,在spawnAlongPath里限制了单帧生成不超过 5 个,防止极端情况下的气泡爆发。
5.3 气泡重叠时的视觉脏感
默认的绘制方式是后绘制的气泡会盖住先绘制的,重叠区域边缘模糊,颜色混在一起显得脏。我查找资料后把ctx.globalCompositeOperation设成了'lighter',重叠部分会做加法混合,亮度和颜色都叠加起来,气泡之间的重叠反而变成了一种发光的、梦幻的效果,和深色背景非常搭。
用'lighter'时要注意:它会让整个画布亮得很快,如果气泡密度太高,鼠标停留区域会白成一片。我的做法是同时在气泡颜色里控制透明度上限(不超过 0.6),并且设置气泡总数的兜底值,给叠加留出呼吸空间。
5.4 隐藏鼠标指针的交互细节
当鼠标停在页面上不动的时候,气泡会慢慢飘走、消失,整个页面变得安静。但如果这时候鼠标指针还悬停在某个操作按钮上,旁边又是一片深色背景,总感觉缺了点什么。
后来我给气泡加了一个"靠近光标才出现"的判定:只有当鼠标实际移动过,且最近一次移动时间在 3 秒以内,才保持生成气泡;鼠标静止超过 3 秒则逐渐停止生成,让画面趋于干净。这个细节虽然简单,但整个页面在"活跃"和"安静"两种状态间的过渡会变得非常有节奏感,是我个人最喜欢的小改动之一。
6. 玩出花样:几个让我爱不释手的扩展方向
6.1 颜色从"随机"升级为"渐变流"
默认配色是从固定调色板里随机取,做出来也能看,但气泡之间的颜色没有任何关联,容易显得杂乱。我后来改成根据鼠标移动方向来动态决定色相:鼠标向右移动时色相从蓝色缓缓过渡到紫色,向下移动时从紫色过渡到粉色。
实现也不复杂,维护一个hue变量,在动画循环里根据followX的速度增量给它做插值,然后每个气泡生成时以当前hue为基准,在正负 20 度范围内取色。这样整串气泡就呈现出一种"随着手势流动的颜色渐变",视觉效果一下子高级了很多。
6.2 气泡拖尾效果:从"一串"到"一条光带"
默认做法是每帧clearRect彻底清屏,气泡之间是独立的,没有首尾相连的感觉。如果你想要一条飘逸的"光带",可以把清屏改成"半透明覆盖":画布不清空,而是每帧在上面盖一层低透明度的背景色,这样上一帧的内容会残留一点点,形成拖尾。
ctx.fillStyle = 'rgba(13, 16, 33, 0.08)'; ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);注意拖尾模式下的清理逻辑要更积极,否则旧气泡的残影会越积越多。搭配globalCompositeOperation = 'lighter'使用,拖尾会呈现出类似光绘摄影的效果,非常惊艳。
6.3 点击事件与气泡爆炸的联动
鼠标移动有气泡跟随了,点按呢?我给页面加了一个pointerdown监听,在点击位置一次性生成 10~20 个气泡,并且给这些气泡赋予更大的初速度,向四周炸开。由于气泡本来就有上浮漂移逻辑,爆炸后的气泡会自然散开、放大、变淡,像是水里被投了一颗石子。
这个交互的回报率非常高:代码量很少,但很容易让看到的人忍不住多点几下。如果你要做产品落地页,在关键按钮上触发这种效果,还能变相引导用户注意交互位置。
6.4 让气泡变成品牌元素
最后一个方向是把气泡的圆形替换成品牌图形或文字。Canvas 里可以用roundRect做圆角方块气泡,也可以用fillText把气泡变成飘动的字符。我之前做过一版把气泡替换成极简线条小图标的效果,鼠标划过时像一串符号漂浮起来,整体艺术风格都很统一。
不过要提醒一句:一旦涉及文字图标,Canvas 在低分屏下渲染文字边缘会发虚,需要在绘制前设置ctx.textAlign = 'center'和textBaseline = 'middle',并且考虑按 DPR 缩放加载高清字体资源。
最后说一点我做这个效果的实际体会:气泡跟随鼠标这类动画,真正的难点从来不在"画出气泡",而在体验的细腻程度——滞后多少、密度多少、颜色怎么流动、静止时要不要停下来、遇上低端设备怎么降低开销。这些参数没有一个标准答案,全靠在场景里反复试。我给自己的方法是:先把核心逻辑跑通,再准备一个"参数面板",把跟随系数、生成间距、气泡数量上限都提成变量,反复拖动对比,直到找到某一套组合让自己满意为止。这样做出来的动画,才不会只是一堆随机参数的产物,而是真正有手感的东西。