最近在看一个内部项目的加载页时,见到了一种气泡Loading效果:屏幕底部不停涌出大小不一的气泡,上升过程中会左右晃动,到了顶部就轻轻裂开,整个过程非常解压。作为一个常年写前端的人,我第一反应是——这个效果看起来不难,但要做得好,细节还真不少。
这篇文章就来拆解这个"有意思的气泡Loading效果"。我会从它到底有意思在哪说起,对比CSS、SVG、Canvas三条实现路线的取舍,给出一份可以直接运行的Canvas实现代码,再聊几个接入真实项目时一定会遇到的调优和兼容问题。无论你是刚接触Canvas动画的新手,还是想给项目的加载状态加点质感的同学,都可以照着抄。
1. 第一眼看到气泡Loading,我为什么觉得它"有意思"
1.1 加载状态是产品体验里最容易被忽略的"闲笔"
很多团队对加载状态的重视程度,低到让人怀疑他们是不是从来没自己用过自己的产品。默认的转圈菊花、灰底白字的"加载中",都属于"能用,但用户不会多看一眼"的存在。可加载状态恰恰是用户在产品里最频繁经历的场景之一:路由切换要加载、接口请求要加载、图片懒加载要触发、表单提交之后也要加载。一个流畅的加载动画,能明显降低用户在等待过程中的焦躁感,这在体验设计里有个朴素的说法——等待时间感知。
气泡Loading有意思的第一点,就是它把"等待"这件事从机械感变成了自然感。转圈是机械运动,圆心定在那儿,箭头绕圈,用户看两秒就腻了;气泡是"活"的,每个气泡的出生位置、大小、速度、晃动幅度都不一样,像一杯刚倒好的碳酸饮料里的气泡往上涌。人的视觉系统对"有机随机"的东西天然会多看几眼,而对"规则循环"的东西会自动忽略。这个心理机制才是气泡Loading真正的价值所在,技术只是实现手段。
1.2 气泡的"物理感"来自哪里
仔细观察真实的气泡上升,会发现几个特征:气泡上浮不是匀速直线,而会有轻微的左右摆动;同一个画面里气泡大小差异很大,小气泡快而轻,大气泡慢而稳;气泡快到水面时会先加速,然后在表面破裂。这些细节单独拎出来都不起眼,但组合在一起,就能让一个动画从"能看"变成"有质感"。
说白了,我们看到的不是一个气泡,而是一套"假的流体规则"。做气泡Loading时,最大的挑战不是画圆,而是怎么用最少的代码模拟出这套随机感。如果每个气泡都一个大小、一个速度、一条直线上天,那它跟普通的点点动画没有区别,反而更显廉价。所以在设计阶段,需要先想清楚:一个气泡要有哪些可变参数,每个参数的取值范围应该怎么定,才能让画面看起来自然又不乱。
2. 实现路线取舍:CSS、SVG还是Canvas
2.1 CSS方案:够用,但很容易"假"
看到气泡Loading,很多人第一反应是用CSS做。思路也简单:在容器里放十来个气泡元素,每个元素用@keyframes定义从底部到顶部的translateY位移,再配合opacity从0到1再回0,最后用animation-delay错开每个气泡的出场时间。代码如下:
.bubble { position: absolute; bottom: -40px; width: 16px; height: 16px; border-radius: 50%; background: rgba(255, 255, 255, 0.35); animation: bubble-rise 3s ease-in infinite; } .bubble:nth-child(2) { left: 30px; animation-delay: 0.4s; } .bubble:nth-child(3) { left: 70px; animation-delay: 0.9s; } /* 更多子元素... */ @keyframes bubble-rise { 0% { transform: translateY(0) scale(0.7); opacity: 0; } 30% { opacity: 0.8; } 80% { opacity: 0.8; } 100% { transform: translateY(-340px) scale(1.2); opacity: 0; } }CSS方案的优点很明显:实现成本低,没有JavaScript,动画跑在transform和opacity上会被浏览器合成器优化,性能下限有保障。但问题也很致命:每个气泡的动画轨迹是写死的,想要"每个气泡大小不同、速度不同、晃动幅度不同",要么写二十条不同的keyframes,要么靠CSS变量硬凑,维护成本立刻上去了。而且CSS动画没有"随机出生"的概念,气泡永远在同一批位置循环,看久了还是会穿帮。
CSS方案适合的场景是:加载容器很小、气泡数量少、项目不需要兼容太复杂的交互逻辑。比如一个按钮里的mini加载态,用两三个CSS气泡足够。但如果你的加载页是一块完整的区域,希望气泡持续不断涌出,CSS就会变成一场选择器和关键帧的噩梦。
2.2 SVG方案:形状可控,但动态能力有限
SVG方案比CSS灵活一些。你可以用<circle>定义气泡,配合SMIL动画或者CSS动画驱动cy和cx属性。SVG的优势是矢量清晰,缩放不变形,而且气泡的形状可以做得复杂——比如带高光的椭圆、不规则的波纹边缘。但这几年我实际用下来的体会是:SVG的动画控制力比Canvas弱不少。你可以在SVG里让一个圆动起来,但很难做到"每个气泡都有独立的物理属性、按需出生、到达顶部触发破裂状态"。要实现这些,最后还是得靠JavaScript去操作SVG DOM,那性能反而不如直接上Canvas干净。
我一般只在两种情况下选SVG:一是平台对Canvas支持不佳(现在基本不存在了);二是动画本身非常规则,比如定速上升、没有随机性。气泡Loading这种核心卖点就是"随机"的效果,SVG属于两头不讨好。
2.3 Canvas方案:为"物理感"买单
最终我选择Canvas,原因只有一个:它能以最小的成本,把每个气泡当成一个独立对象来管理。
在Canvas里,气泡就是一个普通对象,拥有x、y、radius、speed、opacity、phase这些属性。每一帧,程序遍历所有气泡,按各自的属性更新位置、绘制形状。加新属性就是加一个字段,改行为就是改一次update逻辑,完全不需要跟CSS选择器搏斗。上一帧和下一帧之间,所有状态都掌握在JavaScript手里,这意味着随机性、突发状态、交互反馈都可以轻松加进去。
下图是我对三条路线的判断,适用到大多数Loading类动画上都成立:
| 维度 | CSS | SVG | Canvas |
|---|---|---|---|
| 实现成本 | 低 | 中 | 中高 |
| 随机性与物理感 | 弱 | 弱 | 强 |
| 每帧精细控制 | 无 | 弱 | 强 |
| 性能开销 | 低(GPU合成) | 中 | 中(取决于绘制指令数) |
| 对象生命周期管理 | 难 | 难 | 容易 |
| 典型适用场景 | 按钮级小动效 | 规则形状动效 | 粒子/物理/复杂动画 |
3. 手写一版Canvas气泡Loading:结构、算法与完整代码
3.1 气泡对象:先把"可变参数"定清楚
写代码之前,先定一个气泡长什么样。我把气泡抽象成这些字段:
x、y:气泡中心坐标,y从容器底部往上减。radius:气泡半径,随机范围定在4到16像素之间。太小的气泡看不清,太大的气泡会喧宾夺主。speed:上升速度,随机范围0.6到1.6。注意速度跟大小不绑定,这样可以出现"小气泡慢悠悠、大气泡噌噌往上"的反直觉组合,反而更有自然感。opacity:透明度,随机范围0.25到0.8。如果所有气泡都一样透明,画面会很平,没有层次。phase、wobbleAmp、wobbleFreq:这三个是左右晃动的参数。phase是相位,每个气泡出生时随机取一个初始值;wobbleAmp是晃动幅度,取0.6到1.8像素;wobbleFreq是晃动频率,取0.015到0.04。每帧让phase累加频率,绘制的x坐标就变成x + Math.sin(phase) * wobbleAmp。
这些取值范围不是拍脑袋定的,背后有两点考虑。第一,所有随机量都要在"看得出差异"和"整体统一"之间找平衡。比如晃动幅度如果超过3像素,气泡会像喝醉了一样到处飘,就不像是在液体里上升了。第二,随机范围最好用配置对象统一管理,方便后续调参。我把它们集中放在一个CONFIG里,接到不同项目时改颜色、改密度都特别省事。
3.2 生成与回收:把气泡池维持在一个稳定数量
气泡Loading的动画是无限循环的,所以不能"不断创建新对象",否则运行时间一长,内存里的垃圾对象会越来越多,触发GC后页面会卡顿。正确做法是维护一个固定大小的气泡池,数量设成20个左右。每个气泡有生命周期:
- 出生在容器底部下方(
y = H + 20),隐藏状态。 - 上升到顶部边缘,进入"破裂"状态,做一个放大+淡出的小动画。
- 破裂动画播放完,重置到底部,重新随机所有参数,开始下一轮。
这样整个动画过程就是一个固定数组在反复循环,不会产生新对象,也不会出现画面里气泡数量忽多忽少的问题。
初始化时可以直接把20个气泡随机散布在整个容器高度上,让页面一打开就是满屏气泡的效果,而不是等它们一个个从底部爬上来,首屏观感会好很多。
3.3 渲染一帧:径向渐变、高光点与破裂动画
绘制气泡时,简单画一个半透明圆是最省事的,但效果很平面,像贴纸。想要"体积感",我的做法是用createRadialGradient做径向渐变,光源假定在左上方。渐变三段的透明度设置很关键:
- 圆心偏左上位置,白色接近不透明,这是气泡最亮的高光区。
- 中间过渡段用淡蓝色,半透明,模拟气泡壁的反光。
- 边缘几乎全透明,让气泡和背景自然融合。
有了渐变之后,再在气泡左上角画一个小得多的实心圆点作为高光反射。这个小点一加,气泡立刻从"圆片"变成了"透明的球"。真就是这一步,让整个效果脱离廉价感。
破裂动画我放在气泡到达顶部(y <= radius)时触发。破裂时间大概16帧,用popT从0累加到1,配合ease = 1 - (1 - t) * (1 - t)的缓出公式,让气泡先快速放大,同时透明度快速降到0,模拟"啵"的一下破掉的感觉。破裂时y不再继续上升,而是停在原地放大,这个细节跟真实气泡在水面上破裂的位置感是一致的。
3.4 完整可运行代码
把上面的设计串起来,就是下面这份完整代码。我把它写成一个独立的HTML文件,拷到浏览器里直接就能看到效果。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>气泡 Loading 效果</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #1a1a2e; } .loading-box { position: relative; width: 240px; height: 340px; overflow: hidden; border-radius: 18px; background: linear-gradient(180deg, #16213e, #0f3460); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); } .loading-box canvas { position: absolute; left: 0; top: 0; } .loading-box .text { position: absolute; left: 0; right: 0; bottom: 22px; text-align: center; font: 14px/1.6 "PingFang SC", "Microsoft YaHei", sans-serif; letter-spacing: 4px; color: rgba(255, 255, 255, 0.65); } </style> </head> <body> <div class="loading-box"> <canvas></canvas> <div class="text">正在加载</div> </div> <script> (() => { const box = document.querySelector('.loading-box'); const canvas = box.querySelector('canvas'); const ctx = canvas.getContext('2d'); const DPR = Math.min(window.devicePixelRatio || 1, 2); let W, H; const CONFIG = { maxBubbles: 20, minRadius: 4, maxRadius: 16, minSpeed: 0.6, maxSpeed: 1.6, minOpacity: 0.25, maxOpacity: 0.8, popSpeed: 0.06 }; const rand = (min, max) => min + Math.random() * (max - min); function resize() { const rect = box.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR); canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } class Bubble { constructor() { this.reset(false); } reset(init) { this.x = Math.random() * W; this.y = init ? Math.random() * H : H + 20; this.radius = rand(CONFIG.minRadius, CONFIG.maxRadius); this.speed = rand(CONFIG.minSpeed, CONFIG.maxSpeed); this.opacity = rand(CONFIG.minOpacity, CONFIG.maxOpacity); this.phase = Math.random() * Math.PI * 2; this.wobbleAmp = rand(0.6, 1.8); this.wobbleFreq = rand(0.015, 0.04); this.state = 'rise'; this.popT = 0; } } let bubbles = []; function init() { bubbles = []; for (let i = 0; i < CONFIG.maxBubbles; i++) { const b = new Bubble(); b.reset(true); bubbles.push(b); } } function update() { for (const b of bubbles) { if (b.state === 'rise') { b.y -= b.speed; b.phase += b.wobbleFreq; if (b.y <= b.radius) { b.state = 'pop'; b.popT = 0; } } else { b.popT += CONFIG.popSpeed; if (b.popT >= 1) { b.reset(false); } } } } function draw() { ctx.clearRect(0, 0, W, H); for (const b of bubbles) { const ox = Math.sin(b.phase) * b.wobbleAmp; let scale = 1; let alpha = b.opacity; if (b.state === 'pop') { const t = Math.min(b.popT, 1); const ease = 1 - (1 - t) * (1 - t); scale = 1 + ease * 0.7; alpha = b.opacity * (1 - ease); } const r = Math.max(0.5, b.radius * scale); const x = b.x + ox; const y = b.y; const gradient = ctx.createRadialGradient( x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r ); gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha * 0.95})`); gradient.addColorStop(0.4, `rgba(200, 225, 255, ${alpha * 0.35})`); gradient.addColorStop(1, `rgba(180, 210, 255, ${alpha * 0.05})`); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); ctx.beginPath(); ctx.arc(x - r * 0.25, y - r * 0.3, r * 0.15, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.9})`; ctx.fill(); } } function loop() { update(); draw(); requestAnimationFrame(loop); } resize(); init(); loop(); window.addEventListener('resize', resize); })(); </script> </body> </html>这份代码我建议先跑起来,然后手动调CONFIG里的参数。把maxBubbles改成50看看画面多挤,把wobbleAmp上限改成5看看气泡多晃——动手试过之后,你对这些参数值的理解会比读十篇文章都深。
4. 让气泡"活"起来的调优细节
4.1 晃动参数是"活"的关键,但别调过头
最初版本的代码里气泡都是直线上升的,我自己跑了一遍,怎么看怎么像一排假泡泡。加上正弦晃动之后,观感立刻不一样了。但晃动幅度需要克制:1像素左右的晃动,画面是"宁静的液体感";2到3像素,开始有"水流扰动"的感觉;超过4像素,气泡就像在做布朗运动,很浮躁。Loading动画需要的是稳定中带一点变化,所以我把wobbleAmp的默认范围设在0.6到1.8,频率放慢,保证一个气泡在爬升的几秒里只摆动个两三下,而不是高频抖动。
有个容易忽略的点:phase的初始值一定要在0到2π之间随机,而不是固定从0开始。否则所有气泡的晃动规律会完全同步,视觉上会出现一排气泡"齐步走"的尴尬场面。随机初始相位这一步,成本为零,收益极大。
4.2 高光点与透明度层级,决定气泡的"体积感"
同样一个圆,直接填充半透明色和加上高光点,观感差距非常大。原理也好理解:真实气泡是透明的球体,光从上方打过来,会在球体的上沿形成一圈亮斑和一个小小的聚焦亮点。我们不需要真的做光线追踪,只需要画两个元素——径向渐变模拟大面积反光,小实心圆点模拟聚焦亮点——大脑就会自动帮你补全"这是个透明的球"的判断。
透明度层级同样重要。我所有的气泡透明度都控制在0.25到0.8之间,不允许出现完全不透明的气泡。因为Loading动画背后通常还有背景内容,完全不透明的气泡会像糊在页面上的白色油漆,很突兀。始终保留一点透明,气泡才能和背景融为一体,这是让效果"高级"的秘诀。
4.3 性能优化:从retina屏到低端机的兼容策略
Canvas动画最常踩的坑是模糊。Canvas的实际像素尺寸不等于CSS尺寸时,画面就会发虚,解决办法是读取devicePixelRatio,把画布像素放大到物理像素,再用setTransform把坐标系压回来。代码里DPR做了上限钳制,最多取2,这是为了照顾低端机——3倍屏下如果按真实DPR绘制,像素量是CSS面积的9倍,低端机根本扛不住。视觉上,DPR从2到3的清晰度差异在普通屏幕上并不明显,但性能开销差异是实打实的。
再说绘制开销。每帧为每个气泡调用一次createRadialGradient,20个气泡就是20次渐变对象创建,这在绝大多数设备上没有问题,但如果你把气泡数量加到50个以上,或者加载页上还有其他动画在跑,就建议优化。优化的主流做法是预渲染气泡精灵图。
原理很简单:先在内存里创建一个离屏Canvas,把不同尺寸的气泡画成几张静态图像,正式渲染时直接用drawImage把图贴上去,不再每帧重复计算渐变。因为drawImage的绘制开销远小于创建渐变加填充的路径,帧率会明显提升。代价是颜色和透明度变成了预置的,动态调色需要多预渲染几套,灵活度会下降。我的建议是:20个气泡以内的场景直接用渐变,不要过早优化;上到几十上百个气泡时再换精灵图方案。
还有一个容易忽略的性能点:页面切到后台时,requestAnimationFrame会自动暂停,这没问题。但切回来重新播放时,最好重新计算当前时间再继续,避免因为暂停导致气泡瞬间跳一大截。目前这份代码逻辑简单,暂停重启后气泡位置会从暂停处继续,观感上没有跳跃,暂时不需要处理。但如果以后你加上了跟真实时间绑定的逻辑,就要记得处理时间戳差值。
4.4 控制气泡数量:密度不是越高越好
我见过一些气泡Loading的案例,为了让效果"丰富",一个加载页塞了三四十个气泡,结果整个页面看起来像被扔进了浴缸,用户注意力完全被气泡带走,反而忘了页面还在加载内容。气泡数量应该跟容器尺寸成正比。240x340的区域20个气泡是合理密度;如果是全屏加载页,气泡数量可以适当翻倍,但要注意把它们主要集中在视觉中心区域,四周留白,避免分散视线。
5. 把它接到真实项目里:主题适配、动效偏好与工程化
5.1 用配置对象接住品牌色和深色模式
在实际项目里,Loading效果不是独立存在的,它需要跟产品视觉语言统一。我最常用的做法是把颜色从代码里抽出来,定义成一套配置。比如当前实现里气泡的渐变色是偏蓝的rgba(200, 225, 255),如果产品主色是暖橙,直接把这个值改成橙色系之后,整个动效的气质会完全不同。把颜色、气泡数量、速度区间全部收进CONFIG对象,好处是换肤或者做深色模式适配时,只需要改配置,不需要动动画逻辑。
深色模式适配要特别注意背景色。气泡的白色高光在深色背景下会很亮眼,但如果你把气泡Loading放到浅色背景上,白色系气泡几乎看不见。这时需要配合背景色动态调整气泡的高光透明度和描边色。一个简单的方案是检测matchMedia('(prefers-color-scheme: dark)'),根据结果切换两套气泡配色。
5.2 尊重用户的动效偏好设置
这是我做前端动画以来最坚持的一条原则:一定要处理prefers-reduced-motion。很多用户因为晕动症或者个人习惯,在系统里关闭了动画效果。如果在这些人面前强制播放气泡动画,轻则烦躁,重则真的会头晕恶心。代码里加一段判断并不复杂:
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); if (reduced.matches) { // 停掉气泡动画,换成静态的加载提示,或者直接显示一张静态Logo }我的处理方式是:检测到reduce时,把气泡数量降到2到3个,速度减半,去掉晃动和破裂动画,变成一个非常平静的淡入淡出加载提示。这样既保留了"加载中"的语义,又不违背用户的系统设置。别嫌这一步麻烦,它在真实用户那里带来的好感度,会远超你的想象。
5.3 进阶玩法:让气泡跟真实加载进度绑定
如果只是做一个装饰性的无限循环动画,气泡Loading到这一步已经很完整了。但有一次我在做一个数据报表导出功能时,想到了一个进阶玩法:让气泡跟真实进度绑定。导出接口的进度分别段返回,0到30%是准备阶段,气泡从底部缓慢上升;30%到80%是计算阶段,气泡密度变高、速度变快;80%到100%是收尾阶段,气泡变少,最后全部破裂消散,然后整个加载层淡出。这样用户看到的每一个气泡变化,其实都对应着后端任务的真实进度。
这个玩法的经验是:气泡动画跟进度绑定后,速度变化要平滑过渡,不要突然从慢速变成快速。我实现时用了线性插值,根据当前进度比例实时计算气泡的速度上下限。这样整个加载过程既是好看的动画,又是诚实的进度告知,用户的等待焦虑会进一步降低。
5.4 工程化收尾:防抖、容器尺寸变化与释放
最后说三个接入项目时容易踩的工程化问题。
第一个是容器尺寸变化。你的加载容器不一定是固定宽高,比如移动端横竖屏切换、侧边栏折叠,都会导致容器尺寸变化。我的代码里监听了resize事件并重新计算画布尺寸,但要注意重新计算时气泡的x坐标可能已经超出新的宽度范围。稳妥的做法是在resize后遍历一遍气泡,把越界的x重新随机到底部,再利用reset逻辑重新分配。
第二个是加载层的释放。Loading动画结束时,建议把整个动画层销毁而不是隐藏。尤其单页应用里,加载层如果不移除,Canvas会一直在后台跑requestAnimationFrame,白白消耗电池。我用的时候习惯在加载完成后调用cancelAnimationFrame,并移除Canvas容器节点。
第三个是防抖。如果加载很快,比如接口只用了100毫秒就返回了,这时气泡动画刚启动就结束,反而会造成闪动。通常会做一个最短展示时间,比如动画最短播放400毫秒,加载完成时如果动画时长没到,就先让气泡逐个破裂消失再关闭加载层,保证视觉过渡是平滑的,而不是"啪"一下没了。这个小处理,虽然加了几行代码,但能让整个加载体验的完整感提升一大截。
气泡Loading说到底是个微小的细节,但正是这些细节,构成用户对产品质感的直觉判断。我个人在这套方案上踩过最大的坑就是第一版把所有气泡都做成相同的半透明圆点,效果像一串漂浮的白色硬币,跟"气泡"完全不沾边。后来加入径向渐变、高光点和破裂动画之后,效果才真正对味。把这个过程分享出来,希望你能少走几步弯路,把注意力放在参数打磨和场景适配这些真正出效果的地方。