Canvas气泡跟随鼠标动画实战:柔性跟随原理与性能优化全解析

发布时间:2026/10/18 4:11:19
Canvas气泡跟随鼠标动画实战:柔性跟随原理与性能优化全解析 很多人第一次看到这类效果是在别人的个人主页或者产品引导页上鼠标轻轻一动一串半透明的气泡拖着小尾巴跟在光标后面飘慢悠悠地浮起来又散开。我当时的第一反应是——这东西看起来挺简单不就是让几个圆跟着鼠标走吗等自己真动手做了一版才发现里面藏着不少细节既要跟得好看又不能跟得太死板要顺滑又不能吃性能还要照顾到不同屏幕和移动端。这篇文章我就把自己从零到一实现气泡跟随鼠标移动的动画的过程、选型思路和踩坑记录完整梳理一遍代码可以直接复制去改着玩。这个效果适合谁如果你正在做个人主页的装饰性背景、活动页的互动元素、大屏可视化的氛围层或者单纯想给自己的前端作品集加一个低成本高颜值的彩蛋这篇都够用。我会从最核心的跟随算法讲起再到 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 试了三版最后还是回 Canvas2.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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 idbubbleCanvas/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 少 restorectx.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 缩放加载高清字体资源。最后说一点我做这个效果的实际体会气泡跟随鼠标这类动画真正的难点从来不在画出气泡而在体验的细腻程度——滞后多少、密度多少、颜色怎么流动、静止时要不要停下来、遇上低端设备怎么降低开销。这些参数没有一个标准答案全靠在场景里反复试。我给自己的方法是先把核心逻辑跑通再准备一个参数面板把跟随系数、生成间距、气泡数量上限都提成变量反复拖动对比直到找到某一套组合让自己满意为止。这样做出来的动画才不会只是一堆随机参数的产物而是真正有手感的东西。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询