Canvas+SVG混合实现水泡分裂动画的性能优化实践
2026/9/14 19:03:33 网站建设 项目流程

简介:面向Web前端学习者的HTML5 Canvas+SVG彩色水泡分裂动画源码包,聚焦两种主流Web图形技术在动画特效中的融合,适合希望掌握Canvas绘图、SVG矢量特性并实现交互动效的中级前端开发者。压缩包为前端源码压缩包,体积仅3KB,代码量精简,便于直接阅读核心逻辑;目前已有174人浏览学习,可作为Canvas与SVG结合的入门参考。项目综合运用Canvas绘制水泡基础形状与运动路径,SVG处理高光、阴影等矢量细节,并通过requestAnimationFrame控制动画帧率,模拟水泡移动、变形与分裂场景,同时借助fillStyle、strokeStyle等属性调节颜色和透明度,增强视觉层次。解压后阅读源码,不仅能梳理Canvas API与SVG标签在实际动画中的分工,还可学习如何用JavaScript将大图形拆分为多个子图形并生成独立动画路径,是一份不错的Web交互图形编程实践素材。

1. 从一支蜡烛到一片气泡海:用 Canvas+SVG 拆一个会分裂的水泡动画

打开压缩包之前,我先说结论:这个动画的视觉浓度比普通粒子系统高一个量级。普通粒子系统是“点”的运动,而水泡分裂要处理“面”的形变、高光位移和父子气泡的层级关系——如果全部用 Canvas 硬画,光泽会失真;全部用 SVG 做,上百个气泡同时分裂时 DOM 节点会拖垮主线程。这个资源最好的地方在于它没有选边站,而是把 Canvas 当渲染主体、SVG 当装饰层,各干各的活。适合正在做数据可视化大屏、H5 营销页或者想搞懂浏览器双渲染管线怎么协作的人。我按它的代码结构重新梳理一遍,再补上参数调优和性能排查的方法。

2. 渲染轨道切分:Canvas 绘制水泡主体,SVG 只留高光与边框

2.1 为什么要两种技术混用,而不是只用 Canvas

Canvas 是位图渲染,绘制完就直接把像素交给屏幕,它的优势在于批量处理同类型图形时开销极低——画一百个圆和画一个圆,调用次数不同但状态切换成本几乎一样。SVG 是保留模式的矢量渲染,每个节点都活在 DOM 树里,可以被 CSS 动画、CSS 滤镜直接驱动,但节点数过千后布局和样式计算会逐渐吃掉帧预算。

水泡这个特效刚好卡在两种技术的甜区:气泡本体是大量同构的圆形,适合 Canvas 批量画;而高光、外边框、轻微的环境反射这类需要保持锐利边缘的细节,适合用 SVG 的radialGradientfilter来做,放大到 retina 屏也不会发虚。

这个资源里的典型做法是把 SVG 作为 Canvas 上的覆盖层,通过绝对定位叠加在同一个容器中。Canvas 负责每个气泡的“身体”和位置,SVG 只画那些 Canvas 里需要保存大量渐变状态才能模拟的高光点。核心代码结构大致是这样:

<div class="bubble-stage"> <canvas id="bubbleCanvas"></canvas> <svg id="highlightLayer" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"></svg> </div>

Canvas 在下,SVG 在上,两者通过 CSS 的position: absolute叠加。SVG 层的指针事件需要关闭,否则会挡住 Canvas 上的点击交互。

这种分层思路的价值在于:Canvas 每一帧都在清屏重绘,如果高光也放进去,就必须在同一帧里同时计算位置和渐变,代码耦合度高,而且每次重绘都要重建渐变对象,GC 压力大。SVG 层的高光节点是常驻的,重绘时只需要通过transform移动位置,浏览器对 SVG transform 的优化远快于重新绘制一段复杂的 Canvas 渐变。

2.1.1 分层绘制的职责边界

我拆了一份源码后,梳理出的职责分配如下:

职责技术选型原因
气泡主体填充Canvasarc() + fillStyle批量绘制同构图形,状态切换少
气泡径向渐变CanvascreateRadialGradient()每帧可动态调整渐变中心模拟光照角度
高光点与反光SVGcircle + radialGradient矢量边缘清晰,缩放不模糊
气泡外圈描边SVGcircle + stroke避免 Canvas 描边在子像素位置产生锯齿
分裂动画的过渡Canvas 每帧重绘需要频繁修改半径和坐标,Canvas 无 DOM 开销

这里有个容易踩的坑:SVG 图层不能直接放在 Canvas 的parentNode之外,否则两者的坐标系会因为滚动偏移对不上。实战中一般把两层包在同一个position: relative的容器里,并且让 SVG 的viewBox和 Canvas 的width/height保持一致。

2.2 气泡主体的 Canvas 绘制实现

气泡本质上不是一个纯圆,而是一个带厚度感的球体。纯色圆形看起来像饼,不像气泡。源码里通常会用径向渐变制造“背光暗、迎光亮”的球体效果:

function drawBubble(ctx, bubble) { // 渐变中心偏移到左上方,模拟顶光 const gradient = ctx.createRadialGradient( bubble.x - bubble.radius * 0.3, bubble.y - bubble.radius * 0.3, bubble.radius * 0.1, bubble.x, bubble.y, bubble.radius ); gradient.addColorStop(0, bubble.highlightColor || 'rgba(255, 255, 255, 0.9)'); gradient.addColorStop(0.4, bubble.fillColor || 'rgba(100, 180, 255, 0.35)'); gradient.addColorStop(0.8, bubble.edgeColor || 'rgba(40, 80, 160, 0.2)'); gradient.addColorStop(1, 'rgba(20, 40, 80, 0.05)'); ctx.beginPath(); ctx.arc(bubble.x, bubble.y, bubble.radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); // 底部轻微内阴影,模拟液体表面张力导致的暗区 ctx.globalCompositeOperation = 'source-atop'; ctx.beginPath(); ctx.arc(bubble.x, bubble.y, bubble.radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; ctx.fill(); ctx.globalCompositeOperation = 'source-over'; }

createRadialGradient的前四个参数是内圆圆心与半径,后三个是外圆圆心与半径。把内圆圆心往左上偏移,就能模拟顶光源;addColorStop的四个色标控制了从高光到边缘暗部的过渡节奏。特别注意globalCompositeOperation的切换——source-atop会把内阴影限制在已有像素范围内,避免阴影溢出到气泡外面。

SVG 层的高光则是另外一套逻辑,通常用radialGradient定义一个白色到透明的小圆,叠加在 Canvas 气泡的对应位置上。SVG 高光的坐标需要和 Canvas 气泡坐标保持同步,一般通过transform属性更新:

<defs> <radialGradient id="specular" cx="30%" cy="30%" r="50%"> <stop offset="0%" stop-color="rgba(255,255,255,0.95)" /> <stop offset="100%" stop-color="rgba(255,255,255,0)" /> </radialGradient> </defs>

SVG 的渐变定义在整个生命周期只需创建一次,运行期只更新<circle>cxcyr。这样每一帧的 DOM 操作从“改一堆属性”降级为“改三个数字”,浏览器能直接走快速路径。

3. 分裂算法与物理边界:把一个大泡变成二十个小泡的递归逻辑

3.1 分裂触发条件与子泡生成策略

水泡不会凭空分裂,源码里一般会设定两类触发:定时分裂和碰撞分裂。定时分裂是每个气泡每隔若干秒随机分裂一次;碰撞分裂则是两个气泡距离小于半径之和时,较大的一方分裂。无论哪种,分裂的核心是“父泡消失,生成 N 个子泡”。

子泡数量不是固定的,我见过最常见的策略是2 + Math.floor(Math.random() * 3),也就是 2 到 4 个,偶尔出现三等分。生成子泡时要保证基本物理规律不崩坏:面积守恒。若父泡半径为R,子泡半径r_i要满足:

π * R² ≈ ∑ π * r_i²

代码实现并不复杂,但很多初学者会漏掉分配策略:

function splitBubble(bubble, bubbles, maxBubbles) { // 超过全局上限时只缩小,不再分裂 if (bubbles.length >= maxBubbles) { bubble.radius *= 0.92; return; } const count = 2 + Math.floor(Math.random() * 3); const totalArea = Math.PI * bubble.radius * bubble.radius; // 随机权重,面积按权重分配,保证总和不突变 const weights = Array.from({ length: count }, () => 0.5 + Math.random()); const weightSum = weights.reduce((a, b) => a + b, 0); // 从当前气泡数组中移除父泡 const parentIndex = bubbles.indexOf(bubble); if (parentIndex !== -1) bubbles.splice(parentIndex, 1); for (let i = 0; i < count; i++) { const childRadius = Math.sqrt(totalArea * weights[i] / weightSum / Math.PI); const angle = (Math.PI * 2 * i / count) + Math.random() * 0.8; bubbles.push({ x: bubble.x + Math.cos(angle) * bubble.radius * 0.3, y: bubble.y + Math.sin(angle) * bubble.radius * 0.3, radius: Math.max(childRadius, 4), // 最小半径兜底,避免出现 0.1px 的幽灵泡 vx: bubble.vx * 0.6 + Math.cos(angle) * 1.5, vy: bubble.vy * 0.6 - Math.sin(angle) * 1.5, fillColor: shiftHue(bubble.fillColor, Math.random() * 30 - 15), }); } }

子泡初始位置放在父泡中心附近,沿圆周按角度均匀展开,这样视觉上是“炸开”,而不是弹射。childRadius的平方根计算不能省——如果直接按权重比例分配半径,面积和会小于父泡,动画会因为“凭空消失了一部分体积”而显得不自然。shiftHue是色相偏移函数,让子泡颜色继承自父泡但略有变化,这比每次随机颜色更耐看。

提示:bubbles.indexOf(bubble)的时间复杂度是 O(n),分裂频繁时会有可感知的卡顿。如果气泡数量超过 300,建议给每个气泡挂id,用 Map 索引。

3.2 边界反弹与气泡之间的排斥

光有分裂还不够,水泡要浮起来才像真的。一般通过给气泡一个向上的加速度vy -= gravity(注意 Canvas 坐标系 y 轴向下),模拟水中浮力。边界处理上,墙壁反弹通常会保留法向速度的 0.8 倍:

function updatePhysics(bubble, width, height) { // 浮力与阻力 bubble.vy -= 0.02; // 模拟浮力,方向朝屏幕上方 bubble.vx *= 0.995; // 水平方向空气阻力 bubble.vy *= 0.998; bubble.x += bubble.vx; bubble.y += bubble.vy; // 左右边界:反转水平速度并衰减 if (bubble.x < bubble.radius) { bubble.x = bubble.radius; bubble.vx = Math.abs(bubble.vx) * 0.8; } if (bubble.x > width - bubble.radius) { bubble.x = width - bubble.radius; bubble.vx = -Math.abs(bubble.vx) * 0.8; } // 上边界:速度衰减并反弹,下边界:越过底部则重置回顶部 if (bubble.y < bubble.radius) { bubble.y = bubble.radius; bubble.vy = Math.abs(bubble.vy) * 0.75; } if (bubble.y > height + bubble.radius) { bubble.y = height + bubble.radius; bubble.vy = -Math.abs(bubble.vy) * 0.5; } }

气泡间碰撞这里有一个性能陷阱:双重循环判断两两碰撞是 O(n²),100 个气泡就是 4950 次距离计算,可能还带着Math.sqrt,在低端移动设备上会消耗大量帧时间。常见的优化是先用距离平方比较,只有在必要时才开平方:

function checkCollision(a, b) { const dx = a.x - b.x; const dy = a.y - b.y; const minDist = a.radius + b.radius; // 先比较平方距离,避免每帧都做开方运算 if (dx * dx + dy * dy < minDist * minDist) { return true; } return false; }

更进一步的做法是使用空间哈希网格,把画布划分为多个格子,每个格子维护气泡列表,只检查同一格子及相邻格子的气泡对。对于本项目的规模(同时存活 100~200 个气泡),平方距离优化已经够用,不需要上网格。

3.3 分裂层级限制:防止指数爆炸

分裂动画最常见的问题是小泡继续分裂成更小的泡,数量指数增长,最终整个页面卡死。源码里一般有两个保险:全局数量上限maxBubbles和最小分裂半径minSplitRadius

const MAX_BUBBLES = 160; const MIN_SPLIT_RADIUS = 8; function shouldSplit(bubble) { return bubble.radius > MIN_SPLIT_RADIUS && bubbleCount < MAX_BUBBLES; }

这两个参数直接决定了动画的口味:MIN_SPLIT_RADIUS调大,画面会更稀疏,适合做背景装饰;调小则更密集,适合做主视觉但 CPU 占用升高。MAX_BUBBLES设太高会让低端机掉帧,设太低又显得不够热闹。建议移动端设 80,桌面端设 160 起步。

4. 性能兜底:离屏 Canvas、内存池与 requestAnimationFrame 裁剪

4.1 离屏 Canvas 预渲染气泡纹理

每个气泡都要调用createRadialGradient并填充,如果每帧全部重建,Canvas 的渐变计算开销是很大的。更合理的方式是预渲染:把不同色系、不同大小的气泡绘制到离屏 Canvas 上,运行期直接drawImage贴图。

const offscreenCache = {}; function createBubbleSprite(radius, color) { const key = `${radius.toFixed(1)}-${color}`; if (offscreenCache[key]) return offscreenCache[key]; // 离屏 canvas 的尺寸略大于半径,避免边缘裁剪 const canvas = document.createElement('canvas'); canvas.width = radius * 2 + 4; canvas.height = radius * 2 + 4; const ctx = canvas.getContext('2d'); const centerX = canvas.width / 2; const centerY = canvas.height / 2; const gradient = ctx.createRadialGradient( centerX - radius * 0.3, centerY - radius * 0.3, radius * 0.1, centerX, centerY, radius ); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.85)'); gradient.addColorStop(0.5, color); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.05)'); ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill(); offscreenCache[key] = canvas; return canvas; }

运行期绘制时,只需要一次drawImage

const sprite = createBubbleSprite(bubble.radius, bubble.fillColor); ctx.drawImage(sprite, bubble.x - sprite.width / 2, bubble.y - sprite.height / 2);

drawImage的性能远高于重新走一遍渐变填充。代价是气泡数量极多时缓存会占用内存,所以缓存 key 要带半径精度,同尺寸同色系共享同一张 sprite。源码中如果看到document.createElement('canvas')被循环调用,八成就是这个套路。

提示:drawImage是 GPU 加速友好的操作,但在放大或缩小时会触发重新采样。如果气泡在动画中有明显的缩放变化,建议按最大尺寸生成 sprite,之后只做平移,不做 scale。

4.2 对象池避免 GC 毛刺

JavaScript 引擎的垃圾回收(GC)是不可控的,频繁创建和销毁对象会在 200ms 级别触发暂停,表现为动画的周期性卡顿。对象池的思路是提前创建一批不活跃的对象,用active标志区分存活状态:

const bubblePool = []; const poolSize = 120; // 初始化时全部置为 inactive for (let i = 0; i < poolSize; i++) { bubblePool.push({ active: false, x: 0, y: 0, vx: 0, vy: 0, radius: 10, fillColor: '#cb6ce6', }); } function acquireBubble(init) { for (let i = 0; i < bubblePool.length; i++) { if (!bubblePool[i].active) { Object.assign(bubblePool[i], init, { active: true }); return bubblePool[i]; } } return null; // 池满,忽略新建请求 } function releaseBubble(bubble) { bubble.active = false; }

分裂产生子泡时,从池中acquireBubble,不新建对象;父泡销毁时releaseBubble,对象回池。这能让堆内存保持稳定,帧率曲线更平滑。具体到代码里,就是把 GitHub 上最常见的数组splice删除换成标记位删除,渲染时过滤active节点,避免数组移动带来的内存搬移。

4.3 按需裁剪与 DPR 适配

很多人忽略的一个优化点是:气泡完全在屏幕外时,绘制它的所有逻辑都是白费的。在drawBubble之前加一个简单的边界判断:

if (bubble.x + bubble.radius < 0 || bubble.x - bubble.radius > canvas.width || bubble.y + bubble.radius < 0 || bubble.y - bubble.radius > canvas.height) { return; // 跳过屏幕外的气泡 }

另一个关键参数是devicePixelRatio。高分屏上 Canvas 的width/height如果不乘以 DPR,画面会发糊;乘了之后,绘制面积变大四倍,性能下降。主流做法是设定 DPR 上限:

const DPR = Math.min(window.devicePixelRatio || 1, 2); canvas.width = container.clientWidth * DPR; canvas.height = container.clientHeight * DPR; canvas.getContext('2d').scale(DPR, DPR);

DPR 上限设 2 是性能和清晰度的平衡点,3 倍屏(部分安卓旗舰)强行跑 3 倍画布收益有限但开销翻倍。

4.4 requestAnimationFrame 的正确打开方式

动画主循环用requestAnimationFrame是常识,但我发现很多项目的代码会把整个绘制逻辑直接塞进回调里,不做任何增量判断。合理的结构是一个死循环里先更新物理状态,再判断是否需要重绘,把重绘频率降到每秒 30 帧也能保持视觉流畅:

let lastTime = 0; const MIN_FRAME_INTERVAL = 1000 / 30; // 30fps 兜底 function animate(timestamp) { requestAnimationFrame(animate); const delta = timestamp - lastTime; if (delta < MIN_FRAME_INTERVAL) return; // 跳过这一帧,节能 lastTime = timestamp; updatePhysics(); // 更新所有气泡的位置、速度 renderFrame(); // 绘制 Canvas 并同步 SVG 高光位置 } requestAnimationFrame(animate);

通过delta控制帧率策略,可以在多开标签页时自动降低刷新率,避免后台标签页消耗 GPU。源码演示中可能没有写这一段,但投入运行时非常值得加上。

5. 解压即跑的验证方法与调参技巧

5.1 从零到看到动画的最小操作路径

拿到压缩包后,常见的错误是直接在文件管理器里双击index.html,然后用file://协议打开。如果 Canvas 动画里加载了本地图片或 JSON 数据,file://会被浏览器跨域策略拦截,画面空白。推荐的做法是在项目根目录启动一个静态文件服务器:

cd html5-canvas-svg-bubble python3 -m http.server 8080

然后在浏览器访问http://localhost:8080。如果本机没有 Python 3,npx serve .也可以,前者更常见。

打开页面后,如果看不见水泡,优先打开 DevTools 的 Console 面板,看有无红色报错。最常见的三个问题:

  • canvas.getContext('2d')返回null:说明 Canvas 元素被重复初始化或 id 冲突
  • SVG 高光层没有对齐 Canvas:检查父容器是否设置了position: relative,SVG 是否设置了left: 0; top: 0
  • 水泡只出现在左上角:Canvas 的width/height和 CSS 尺寸不一致,通常是忘了乘以devicePixelRatio

5.2 用 Performance 面板定位卡顿瓶颈

动画能跑了,接下来就是验证性能。打开 DevTools 的 Performance 面板,点击 Record,操作几轮分裂,然后停止录制。重点看两条:绿色竖条是否每一帧都出现在预期位置,以及Main区域里ScriptingRendering的占比。

如果Scripting占比超过 20%,问题大概率在物理计算或 Canvas 绘制代码;如果Rendering占比过高,则是 SVG 节点重排或 Canvas 尺寸过大。此时可以打开Rendering面板的Frame Rendering Stats,观察 GPU 占用和帧率曲线。

还有一个更高阶的验证工具:Chrome 的Paint flashing功能。如果它把 SVG 层的局部更新也标记为闪烁,说明浏览器每次都在重绘高亮层的大块区域,这时可以给 SVG 层设置will-change: transform提示浏览器把该层提升为独立合成层:

#highlightLayer { will-change: transform; }

但注意不要滥用will-change,每个合成层都占用 GPU 内存,层数过多移动端反而更卡。

5.3 把源码改成你自己的产品

拿到演示源码后,不要满足于跑通流程。我建议你按顺序做三件事:

第一,把颜色方案改成自定义配置。将气泡的高光色、填充色、边缘色抽成一个THEME常量对象,以便后续切换不同风格。

const THEME = { primary: { hue: 210, saturation: 80, lightness: 60 }, specular: 'rgba(255, 255, 255, 0.8)', environment: 'rgba(80, 140, 200, 0.1)', };

色相用hsl而不是rgb的好处是水泡颜色的调整上限更高,后续代码可以随时做渐变调色。

第二,给动画加交互:点击事件触发一次“强制分裂”或“生成新气泡”。在 Canvas 上监听这个需要做坐标换算,因为 Canvas 的clientWidth和内部width可能不同:

canvas.addEventListener('pointerdown', (e) => { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width / DPR; const scaleY = canvas.height / rect.height / DPR; const mouseX = (e.clientX - rect.left) * scaleX; const mouseY = (e.clientY - rect.top) * scaleY; // 在鼠标位置添加一个半径 30 的气泡 });

注意缩放因子要除以 DPR 一次,鼠标坐标对齐分毫不差。第三,把 Canvas 尺寸变化监听起来,当容器 resize 时同时调整画布大小和 SVG 层尺寸,并重新计算已有气泡的边界,避免 resize 后气泡飞出可视区域再也回不来。这一步是 HTML5 动画承接真实业务需求时最容易出错的,而它在这个演示项目里往往是被忽略的。

本文还有配套的精品资源,点击获取

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

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

立即咨询