气泡Loading动效从零实现:原理、代码与性能优化全解析
发布时间:2026/10/8 9:48:43 作者:尧图编辑部 阅读量:1,286

说实话第一次看到气泡 Loading 的时候我的第一反应不是“这个效果能用在哪”而是“这东西怎么做的”。气泡一个接一个冒出来又在水面炸开加载过程不再是一根干巴巴的转圈反而像是有个透明瓶子在眼前倒水——等多久都不觉得焦躁这大概就是微交互动效的魔力。今天就把我从零实现这个气泡 Loading 的完整思路、代码细节和踩坑记录整理成文从动画原理讲到性能优化一条龙聊透。适合已经写了三到六个月前端、想给项目加一点“小设计感”的同行也适合那些只想要现成代码直接抄的读者这两种需求我都会照顾到。1. 气泡 Loading 是什么为什么值得亲手做一版气泡 Loading 本质上是一组模拟液体冒泡过程的加载动效。页面请求数据时屏幕上出现若干大小不一的气泡沿着垂直方向缓缓上升到达顶部后破裂消失同时新的气泡从底部不断生成形成一个循环往复的“冒泡—破裂—再冒泡”过程。和传统 spinner 转圈、进度条填充相比气泡效果给人的心理暗示更轻盈——加载状态不再是一根冰冷的等待条而是一个有节奏感的小场景。这种效果最常见的落点在 H5 支付跳转、移动端下拉刷新、数据看板的初始加载以及小程序里登录态校验那几秒钟的等待。设计语言偏清新、偏自然的团队尤其喜欢用它因为气泡不需要太多修饰本身就能传递“轻量、流动、持续进行中”的语义。还有一个现实原因品牌方对加载页面越来越敏感同样的等待时间换成气泡动效后跳出率明显下降这在电商活动页上被反复验证过。那为什么不直接去 CodePen 复制一份两个原因。第一现成作品绝大多数是 Demo 级别没有考虑真实项目里的适配问题比如深浅色模式、容器尺寸变化、低端机性能第二气泡动效的“灵魂”在于随机性而随机参数需要跟自己的设计体系对齐——气泡大小范围、上升速度、透明度衰减这些都必须能调、能控。自己写一版后面加需求也好接比如加载结束时让气泡快速上浮消散这种收尾细节直接抄来的代码通常改不动。从实现路径上看气泡动效并不复杂核心无非三件事生成气泡节点、驱动它做位移动画、到顶后销毁并重新生成。但要把这十几行逻辑写出“有质感”里面涉及的细节比想象中多得多。接下来我把方案选型和动效拆解分开讲。1.1 方案选型CSS、SVG、Canvas 到底选哪个做气泡 Loading 有三条主流路线很多人上来就纠结。我直接给结论常规业务项目选 CSS 少量 JavaScript追求复杂流体模拟才上 Canvas。纯 CSS 方案可以做出气泡的上浮和缩放用keyframesanimation-delay就能让多个气泡错峰出现。但纯 CSS 的随机性很弱气泡大小、位置、速度全都得静态写死在样式里想做成“每次刷新都不一样”就得维护一长串规则代码量反而失控。而且气泡到顶后的消失动画、循环周期控制纯 CSS 写起来像在做谜题对后续维护不友好。SVG 方案的优势是气泡可以做得很精致比如用渐变给气泡加高光、加镜面反光质感比纯 CSS 强。劣势同样明显几十个气泡就是几十个 SVG 节点DOM 开销大而且 SVG 动画的驱动通常还需要 JS 配合等于既要维护图形又要维护逻辑复杂度两头占。Canvas 适合另一种场景——不需要 DOM 节点所有气泡都在一张画布上绘制性能上限最高。如果你要做的是“液面波动气泡上浮水花飞溅”这种全套流体模拟Canvas 是唯一选择。但普通加载动效用 Canvas 属于大炮打蚊子没法直接让设计师用 CSS 调样式调试成本高在低端安卓机上还容易遇到 canvas 上下文创建失败的老问题。所以最终方案定为“CSS 定义视觉样式 JS 生成随机参数并驱动生命周期”。CSS 负责气泡长得像气泡圆角、阴影、高光JS 负责决定每个气泡的尺寸、速度、延迟和销毁时机。这样视觉和逻辑解耦参数全部集中在 JS 里改起来一目了然。1.2 动效核心逻辑拆解一条循环生产线的三个工位为了把气泡 Loading 讲清楚我习惯把它拆成一条“生产线”三个工位各司其职生成、驱动、回收。生成工位负责创建气泡 DOM 节点并给它赋予一组随机参数。包括气泡直径、水平位置、上升时长、开始延迟、初始透明度、水平摆动幅度。这里的关键不是“能生成”而是“生成的参数要在一个合理区间里随机”例如直径范围、时长范围都必须有边界否则会出现几个大得离谱的气泡把容器撑破的现象。驱动工位是动效的核心用的是 CSStransition或requestAnimationFrame驱动transform: translateY让气泡从容器底部移动到顶部。我说一句实操中最重要的体会位移一定要走transform不要碰top和left。top变化触发的是布局重排气泡数量一多低端设备直接卡成幻灯片transform走的是合成器线程GPU 加速几十个气泡同时动毫无压力。回收工位最有意思也是多数教程不讲的点。气泡到达顶部不能直接移出 DOM否则下一轮循环还得重新创建节点造成内存抖动。我的做法是给气泡加一个“上岸”前的过渡先让气泡透明度降到 0、略微放大模拟破裂的瞬间然后用animationend或transitionend监听到动画结束再把节点从 DOM 中移除并新生成一个补位。这样从用户视角看气泡是“碎掉”的不是“消失”的体验感完全不同。2. 核心细节解析从气泡长相到动画节奏控制2.1 气泡的“长相”设计圆角、高光与阴影缺一不可气泡在视觉上要成立三个要素缺一不可圆角、高光、柔和阴影。很多人写气泡就写一个border-radius: 50%加上半透明背景色结果看起来像彩色圆片不像气泡。原因在于真实气泡的透光性和表面反光没有被模拟出来。我的做法是给气泡节点加一层::before伪元素模拟高光。高光不是正中一个圆而是在气泡左上部分画一个扁平的椭圆白色半透明模拟光从左上角打入时的弧面反射。核心样式大致是这样.bubble { position: absolute; bottom: -80px; border-radius: 50%; background: radial-gradient( circle at 32% 28%, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 45% ); box-shadow: inset 0 0 6px rgba(255, 255, 255, 0.12); will-change: transform, opacity; } .bubble::before { content: ; position: absolute; left: 18%; top: 12%; width: 36%; height: 22%; border-radius: 50%; background: rgba(255, 255, 255, 0.5); filter: blur(2px); }高光用radial-gradient做基础再用伪元素叠加一个模糊小椭圆两层一叠气泡的玻璃质感立刻就出来了。阴影部分我用box-shadow的inset内阴影而不是外阴影因为气泡是半透明的内部边缘稍微压暗一点会更接近真实液泡。背景色我习惯用全局 CSS 变量控制方便做深浅色主题适配这一点后面单独说。2.2 关键帧设计上升、摆动与破裂三个阶段气泡动画分成上升、摆动、破裂三个阶段三个阶段的节奏参数决定了最终观感。上升阶段是主体气泡从底部到容器顶部耗时通过 JS 随机生成通常在 3 到 6 秒之间。这个范围是我试出来的太快会有“冒烟”的急躁感太慢用户会觉得卡死了。阶段二是水平摆动。真实气泡在水中上升不是直线而是受到液体扰动会产生小幅度的侧向偏移。我给气泡加了一个可选的translateX偏移动画偏移量控制在水平位置正负 10 像素以内再配上ease-in-out的时间函数气泡就会有一种“被水流带着走”的摇曳感。阶段三是破裂。气泡到达顶部后我不会让它直接消失而是先触发一个 200 到 300 毫秒的“破碎”动画透明度从当前值降到 0缩放从 1 放大到 1.15同时高光部分快速变亮再熄灭模拟气泡被撑破的感觉。这个细节需要 JS 配合在气泡上升动画结束前 300 毫秒给节点加一个.burst类。类样式的切换时间点如果控制不好会出现气泡还没到顶就“碎”了或者到顶后半天下不去这里需要多调试几轮。2.3 随机参数工程化限幅、分布与防重叠随机不是乱来参数必须要做边界约束。我为每个气泡定义了一个配置对象所有可调参数集中管理const bubbleConfig { count: 14, size: { min: 18, max: 54 }, duration: { min: 3200, max: 5800 }, delay: { min: 0, max: 2500 }, drift: { min: -10, max: 10 }, opacity: { min: 0.35, max: 0.85 }, };count是同时存在的最大气泡数size是直径范围duration是上升周期毫秒数delay是首次出现的错峰延迟drift是水平摆动幅度opacity是透明度区间。所有随机值都落在这些区间内不会出现超出设计语义的极端情况。防重叠是另一个容易被忽略的问题。气泡是半透明的完全重叠其实视觉上不算糟但如果两个气泡位置完全一样会出现“两个气泡叠成一个却不破碎”的诡异效果。我在生成水平位置时做了简单互斥新气泡的水平位置如果与现存气泡距离小于直径平均值的 60%就重新随机一次最多重试三次。这个逻辑只花了几行代码但能明显减少重叠概率。3. 实操过程从零实现一个完整的气泡 Loading3.1 基础结构与样式先搭一个能跑的框架开始之前先明确目标实现一个 200 乘 200 的加载容器容器内部持续冒出气泡加载结束后气泡停止生成、已存在的自然消失。我建议先在本地搭一个最简页面连样式带逻辑都跑通再考虑封装成组件。HTML 结构非常简洁一个容器接一段文案div classloading-wrap div classbubble-stage idbubbleStage/div p classloading-text正在加载请稍候.../p /div.bubble-stage是气泡的活动区域需要设置position: relative、overflow: hidden并且给它一个明确的宽高。overflow: hidden这里非常关键——气泡在生成时会位于容器底部下方 80 像素处也就是bottom: -80px如果容器不裁切气泡会在容器外露出来破坏整体布局。.bubble-stage { position: relative; width: 200px; height: 200px; margin: 0 auto; overflow: hidden; border-radius: 16px; background: linear-gradient(180deg, #e8f4fc 0%, #c8e8f8 100%); }背景我用了一个从浅蓝到稍深蓝的垂直渐变模拟水面下的光线衰减。这个渐变个人建议别用纯白气泡浅色在纯白背景上反差太小视觉重心会漂移。主色调用变量维护方便后续换肤。3.2 生成气泡的 JavaScript 逻辑随机参数与节点生命周期气泡的生成逻辑我写成了一个简单的控制器包含init和spawn两个公开方法。init负责按配置数量生成第一批气泡并为每个气泡分配延迟spawn负责创建单个气泡节点、设置随机参数然后监听它的动画结束事件准备回收。class BubbleLoader { constructor(stage, config) { this.stage stage; this.config Object.assign({}, bubbleConfig, config); this.alive false; // 控制加载状态 } start() { this.alive true; for (let i 0; i this.config.count; i) { this.spawnWithDelay(i); } } stop() { this.alive false; } spawnWithDelay(index) { const { delay } this.config; const randomDelay rand(delay.min, delay.max) index * 80; setTimeout(() this.spawn(), randomDelay); } spawn() { if (!this.alive this.stage.childElementCount 0) return; const bubble document.createElement(div); bubble.className bubble; const size rand(this.config.size.min, this.config.size.max); const left rand(-10, 90); // 百分比表示水平位置 const duration rand(this.config.duration.min, this.config.duration.max); const opacity rand(this.config.opacity.min, this.config.opacity.max); const drift rand(this.config.drift.min, this.config.drift.max); bubble.style.width size px; bubble.style.height size px; bubble.style.left left %; bubble.style.opacity opacity; bubble.style.setProperty(--duration, duration ms); bubble.style.setProperty(--drift, drift px); bubble.addEventListener(animationend, () { bubble.remove(); if (this.alive) this.spawn(); }); this.stage.appendChild(bubble); } } function rand(min, max) { return Math.round(min Math.random() * (max - min)); }我把动画时长和摆动幅度放到了 CSS 自定义属性--duration和--drift里这样 CSS 的 keyframes 可以直接引用不需要通过 JS 频繁改 style。这是目前比较推荐的写法随机参数进了 CSS 变量样式在 CSS 里统一维护JS 只负责数值。3.3 上升与摆动动画的 CSS 实现关键帧与变量联动气泡的上升和摆动我拆成两套动画rise负责垂直位移drift负责水平摆动。两套动画同时作用于同一个节点通过animation简写属性组合在一起。.bubble { animation: rise var(--duration, 4s) ease-in forwards, drift 2.2s ease-in-out infinite alternate; } keyframes rise { 0% { transform: translateY(0) scale(0.6); opacity: 0; } 12% { transform: translateY(-24px) scale(1); opacity: var(--bubble-opacity, 0.7); } 85% { transform: translateY(-180px) scale(1.02); opacity: var(--bubble-opacity, 0.7); } 100% { transform: translateY(-240px) scale(1.12); opacity: 0; } }注意rise动画我用了forwards填充模式让气泡在动画结束时保持最终状态避免最后突然跳回底部造成闪烁。另外translateY的终点值是负的因为气泡本身定位在容器底部之外bottom: -80px要让它完全走出容器可视区位移值必须比容器高度更大具体数值要根据容器尺寸动态算。我这里是 200 高的容器配-240px你可以等比换算。drift动画用alternate来回摆动气泡会有一种被水流轻轻推着走的感觉。摆动幅度通过--drift变量控制但要注意rise动画也在操作transform两者会冲突。解决办法是用一个嵌套结构给气泡外层套一个.bubble做垂直位移内层新增一个.bubble-core做水平摆动这样两层各管一个维度永远不会互相覆盖。3.4 加载结束时的优雅收尾让气泡自然消散而不是瞬间消失加载结束后直接把气泡全部移除会显得很突兀。我的做法是把stop()方法设计成“停止生成但已存在的气泡自然走完剩余生命周期”。也就是说stop()只把alive置为false让animationend回调里不再生成新气泡已经升到一半的气泡继续上升到顶再消失。这样整个 Loading 区域会像一壶水慢慢停止沸腾气泡逐渐变少最终清空体验比直接清空 DOM 平滑得多。有些场景还需要一个更快的收尾比如点击“跳过加载”按钮时最好 200 毫秒内全部消失。这时候我给容器加一个.loading-done类批量把气泡的animation-duration改成当前剩余时间的一半气泡就会加速上浮破裂实现“快速放气”的视觉效果。这个细节挺有意思建议你在项目里试试。4. 进阶优化把气泡动效做细腻性能调优与踩坑记录4.1 性能优化的核心思路图层、合成器与帧预算气泡动效的瓶颈通常不在 JS 计算量而在浏览器渲染流程。任何动画都是每一帧做一次“布局—绘制—合成”的管线处理管线的不同阶段开销差异巨大。top/left的变化会触发布局计算因为浏览器需要重新计算元素的位置和大小transform和opacity的变化则可以在合成器层面完成不需要走布局环节。同样的动效优化前后在低端安卓机上的流畅度差距是“卡顿到飞起”和“全速丝滑”的差别。我在代码里做了三件事来压榨性能。第一气泡节点全程只改transform和opacity不碰top/left水平位置用left只在创建时设置一次之后不修改。第二给每个气泡加上will-change: transform, opacity告诉浏览器提前为这些属性准备合成层减少运行中反复创建图层的开销。第三气泡数量控制在最小值我测过 200 乘 200 容器里 14 个气泡和 28 个气泡的观感区别28 个确实更密实但在某些老处理器的手机上帧率会掉到 40 帧以下最后我定在 18 个左右视觉和性能的平衡点。4.2 深浅色模式适配与容器尺寸自适应气泡效果有个容易翻车的点加载容器背景和页面底色不协调。在浅色模式下气泡用蓝色渐变、白色高光没有问题切到深色模式后气泡若是半透明蓝色深色背景下的通透感会完全丢失。我的适配方案是定义两组 CSS 变量浅色模式一组深色模式一组通过媒体查询切换:root { --bubble-bg: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.35), rgba(64, 156, 255, 0.06) 45%); --stage-bg: linear-gradient(180deg, #e8f4fc 0%, #c8e8f8 100%); } media (prefers-color-scheme: dark) { :root { --bubble-bg: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.22), rgba(96, 120, 255, 0.12) 45%); --stage-bg: linear-gradient(180deg, #1c2333 0%, #121826 100%); } }气泡的background直接引用var(--bubble-bg)容器背景引用var(--stage-bg)。这样系统切换深浅模式时整个动效的明暗关系会跟着调整不会出现深色页面里有一个浅蓝色玻璃瓶的割裂感。容器尺寸自适应用百分比加动态计算解决。气泡的left用百分比定位尺寸上限和容器的min(width, height)绑定比如配置里size.max由 JS 在初始化时根据容器元素的实际宽高计算规则是容器边长的一半以内。这样做的好处是同一套气泡代码放在移动端 200 像素的方形容器里和桌面端 400 像素的圆形容器里气泡都不会撑破边界比例始终协调。4.3 运行中动画的生命周期管理页面隐藏与组件销毁单页应用里最常见的问题切走了路由气泡还在后台跑。气泡消耗的资源不大但加载监听器、定时器不清理页面来回切换几次会积累出好几个并行运行的 Loading 循环内存和 CPU 双双浪费。我在控制器里加了一个destroy()方法负责三件事停止生成、移除所有剩余气泡节点、清空所有挂起的setTimeout定时器。组件卸载时调用它确保不会有游离的定时器和 DOM 节点残留。另外页面切到后台时浏览器的requestAnimationFrame自动暂停但用setTimeout驱动的生成逻辑不受影响后台跑回来时可能一瞬间冒出多个气泡。处理办法是监听visibilitychange事件页面重新可见时先清掉一部分现有气泡再继续生成把积压的一次性释放掉。注意animationend事件在页面切后台时也可能被浏览器挂起等回到前台时才统一触发这会导致气泡瞬间全部消失再瞬间全部出现。如果你遇到这个现象多半是动画事件积压而不是逻辑写错。5. 常见问题与排查技巧实录5.1 高频问题速查表现象、原因与解决办法做气泡 Loading 过程中最容易碰到的问题我整理成了下面这张表大多是我自己踩过之后排查出来的按出现频率从高到低排现象直接原因解决办法气泡卡顿掉帧明显动画驱动用了top/left而不是transform全局搜索替换成transform: translateX/Y加上will-change气泡到顶后闪烁一下又回到底部animation-fill-mode没设forwards设置animation-fill-mode: forwards保持结束状态气泡重叠位置一模一样水平位置随机范围过窄且无互斥增加左边界随机范围加入最小间距互斥逻辑气泡在容器外露出半个容器没设overflow: hidden给.bubble-stage加overflow: hidden加载完成后气泡残留不消失没有监听animationend移除节点在动画结束回调里remove()节点页面切后台再回来气泡全部刷新animationend积压触发监听visibilitychange重置动画状态深色模式下气泡几乎看不见背景色与容器背景没有做适配用 CSS 变量 prefers-color-scheme切换配色气泡上升快慢参差不齐duration范围设置太窄把duration.min与duration.max的差距拉大到 2 秒以上最后一行值得多说一句随机时长的范围如果太窄比如都集中在 4 到 4.5 秒之间气泡会像阅兵方阵一样整整齐齐往上走显得非常机械。把范围拉宽到 3.2 到 5.8 秒气泡的节奏感立刻就有了这是参数调节里投入产出比最高的一步。5.2 一次印象深刻的排查气泡为何突然加速上浮最让我头疼的一次问题是气泡在运行到大约 20 秒后会突然全部加速上浮视觉效果像“一群气泡同时逃命”。排查了很久最终定位到问题出在requestAnimationFrame和setTimeout混合使用上。气泡的上升动画由 CSS 驱动但生成新气泡的时间轴由setTimeout驱动两者没有同步。当页面帧率发生波动时CSS 动画的实际播放速度和setTimeout的时间轴会产生偏移偏移积累到一定程度所有的气泡几乎同一时刻触发animationend然后统一的生成回调又让下一批气泡同一时刻出现。解决思路是同步生成节奏。我把生成气泡的操作也改成了由 CSS 动画事件驱动每次animationend触发后延迟随机 0 到 800 毫秒再生成下一个气泡。这样新气泡的出现周期与现有气泡的消亡节奏绑定不会出现长时间运行后周期漂移的问题。5.3 其他值得注意的细节点击穿透与低功耗模式气泡节点虽然是半透明视觉但它们是真实 DOM 元素会拦截点击事件。加载容器如果出现在弹窗或遮罩层上气泡区域将无法点击穿透用户快速点击“取消”按钮时容易误触。解决办法是在气泡节点上设置pointer-events: none让它不参与事件响应。这是一个很不起眼但实际影响很大的细节尤其是移动端 H5 页面。另一个细节是低功耗模式。部分手机系统打开省电模式后会限制浏览器的动画渲染频率CSS 动画的帧率会下降气泡可能会出现“一顿一顿”的感觉。这不是代码问题是系统策略。我的做法是检测动画帧率如果连续几秒低于 30 帧就自动减少气泡数量至原来的一半同时缩短动画持续时间让气泡以“简洁模式”继续运行。代码本身不复杂核心是评估用户体验的底线与其让用户看到卡顿的完整效果不如让用户看到流畅的简化效果。6. 玩法扩展方向气泡 Loading 还能变成什么气泡 Loading 只是一个起点把它的设计语言延伸出去可以做出很多变体。最常见的是把气泡换成其他上升物体比如音符、星星、金币在游戏结算、会员权益展示等场景里非常契合。换汤不换药生成、驱动、回收的循环结构完全可以复用只需要把 CSS 里的圆角高光换成对应图形。更高级的变体是给气泡增加交互响应。我试过在气泡上绑定click事件点击后气泡立刻破碎并飞溅出几个更小的微粒这个效果在活动页的“互动按钮”场景里很出彩。实现上只需要在click回调里给气泡加一个.pop类并在 CSS 里定义破碎动画同时暂停该气泡原有的上升动画避免两个动画争夺transform属性。还有一种方向是让气泡携带信息。把气泡做成半透明的胶囊形状内部用文字标签展示当前加载进度比如“正在上传 36%”气泡上浮的同时数字跟随变化。技术上需要在生成时动态写入内容但要注意气泡尺寸与文字的适配以及气泡破裂瞬间文字是否要保留的决策。如果你的产品有较强的动效设计欲这几个方向都是值得投入时间去做的。我在实际项目里遇到的另一个高频诉求是把气泡 Loading 做成一个 Web Component 或者框架无关的组件。气泡控制器本身不依赖任何框架直接操作 DOM所以封装起来很自由。React 里用一个useEffect包裹控制器生命周期Vue 里用onMounted和onUnmounted触发start和destroy结构都很干净。最后再分享一个我在调参过程中摸索出来的小技巧测试气泡节奏时别只看最终效果试试把页面缩到 50% 再观察一次再开着开发者工具的性能面板看一遍。缩小窗口会让气泡的视觉密度变大密度失衡、动画速度过快等问题这时候会暴露得特别明显。我很多次“感觉不错”的版本都是在这两步检查里被打回去重调的。气泡动效讲究的就是一个恰到好处多一点嫌挤少一点嫌空节奏快一点嫌急慢一点嫌拖。找到一个舒服的手感比堆砌功能更能体现一个前端做交互动效的基本功。希望这篇拆解能让你少走一些弯路直接做出让自己满意、用户也喜欢的那版气泡 Loading。