简介:一份基于HTML5 Canvas与SVG的彩色水泡分裂动画特效源码,代码量虽小却完整实现了从绘制到交互的动画链路,非常适合前端学习者、动画爱好者及Web交互视觉工程师用来研究两种图形技术的配合方式。压缩包本身只有3KB,没有多余文件,下载后即可运行调试,目前已有174人学习浏览。源码中 Canvas 负责绘制可动态变化的水泡主体,SVG 负责补充高清边缘与高光细节,而 JavaScript 则控制水泡的移动、碰撞和分裂逻辑,并通过样式属性让水泡呈现丰富的色彩层次。此外,使用 requestAnimationFrame 保证动画帧率稳定,利用离屏Canvas 分担主线程渲染压力,这些优化思路都值得在真实项目中借鉴。由于代码精简且无复杂依赖,适合逐行阅读,作为快速上手 Canvas+SVG 混合编程的示例再合适不过。无论是做网页动态背景,还是学习图形交互,都能从中获得直接启发。
1. 为什么「彩色水泡分裂动画」值得把 Canvas 和 SVG 放在同一张画布上
处理过 html5 动画特效源码的人大概都有这种经历:点开一个漂亮的水泡交互页面,以为全是 Canvas 画的,打开 DevTools 一看,DOM 树里躺着一堆<path>和<circle>。这类「Canvas+SVG 混排」的项目在源码包里非常常见,而彩色水泡分裂恰好是解剖这种协作方式的最佳样本。水泡本体需要高频重绘,适合 Canvas;分裂瞬间的膜层碎片、高光轮廓和装饰贝塞尔路径,用 SVG 定义一次、反复引用,成本远低于逐帧画像素。这篇文章要讲的是水泡动画里 Canvas 和 SVG 各自管什么、分裂逻辑怎么用状态机收敛,以及把这份源码改造进自己项目时的参数边界。适合想读源码但不想被 demo 带偏的进阶前端,也适合要自己从零写一发气泡交互动效的工程师。
2. 用 Canvas 绘制水泡主体:从「一个圆」到「会呼吸的球」
2.1 水泡的最小绘制单元:径向渐变与高光偏移
水泡和普通圆形按钮的区别在于:它需要透光、有厚度、高光位置固定,而且颜色是彩色半透明的。常见实现是ctx.createRadialGradient制造内外两层折射,再叠加一个偏移的椭圆高光:
function drawBubble(ctx, bubble) { const { x, y, r, hue, alpha } = bubble; // 内层光晕:颜色随 hue 变化,半透明,模拟薄膜折射 const glow = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r); glow.addColorStop(0, `hsla(${hue}, 85%, 92%, ${alpha})`); glow.addColorStop(0.55, `hsla(${hue}, 80%, 75%, ${alpha * 0.55})`); glow.addColorStop(1, `hsla(${hue}, 70%, 55%, ${alpha * 0.18})`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); // 左上高光:一个更小、更实的椭圆,方向固定模拟顶部光源 ctx.save(); ctx.beginPath(); ctx.translate(x - r * 0.3, y - r * 0.4); ctx.scale(1, 0.62); ctx.arc(0, 0, r * 0.32, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 100%, 98%, ${alpha * 0.85})`; ctx.fill(); ctx.restore(); // 底部一圈反光,避免水泡看起来像贴纸 ctx.beginPath(); ctx.arc(x, y, r * 0.92, Math.PI * 0.15, Math.PI * 0.85, false); ctx.strokeStyle = `hsla(${hue}, 90%, 95%, ${alpha * 0.35})`; ctx.lineWidth = Math.max(1, r * 0.045); ctx.stroke(); }绘制顺序是有讲究的:先铺渐变底,再画左上高光,最后补底部反光。高光要用save/restore包住,因为translate和scale会影响后续绘制。参数里hue用色相角控制「彩色」,alpha随水泡生命周期衰减;r * 0.35和r * 0.4控制高光相对圆心偏移,这两个值在源码包里经常被改成随机量,让同一批水泡高光位置略有差异,视觉上更接近自然气泡而非统一模板。
2.2 分裂触发判定:半径阈值、生命周期与随机抖动
水泡不会自己一直运动,需要一套更新循环决定「什么时候炸开」。常见的触发条件有三种:被鼠标点中、碰到画布边缘、生命周期到达随机上限。三种条件最终都走到同一个入口——splitBubble:
function updateBubble(bubble, dt) { bubble.age += dt; bubble.phase += dt * bubble.vibration; // 1. 半径随机脉动,让水泡"呼吸" bubble.drawRadius = bubble.r * (1 + 0.035 * Math.sin(bubble.phase)); // 2. 受浮力和阻尼影响,方向朝上但速度不断衰减 bubble.vy -= 12 * dt; // 模拟浮力 bubble.vx *= (1 - 0.6 * dt); bubble.vy *= (1 - 0.6 * dt); bubble.x += bubble.vx * dt; bubble.y += bubble.vy * dt; // 3. 生命周期到期、缩小到临界半径或碰到边界时才分裂 const shrinkLimit = bubble.r < 6; const ageLimit = bubble.age > bubble.maxAge; if (shrinkLimit) { bubble.state = 'dying'; } else if (ageLimit || isOutOfBounds(bubble)) { bubble.state = 'split'; } }vibration是呼吸频率,0.035是半径摆幅系数;把摆幅改成0.06以上水泡会明显抖动,改小则接近静止。vy -= 12 * dt是简化的浮力模型,真实水泡浮力会随深度变化,这里只保证「往上飘」的观感。需要留意的坑是:r < 6的判定放在最前面,因为分裂出的子水泡半径很小,如果继续走ageLimit分支会陷入「无限分裂」,这是此类源码最常见的递归失控点。
2.3 为什么水泡主体用 Canvas 而不是纯 SVG
选型理由可以从一个实际数字出发:动画特效要维持 60fps,而一次分裂会从 1 个水泡变成 3~6 个,数量是倍增的。SVG 每个水泡至少要 2~3 个节点(圆、高光、膜层),200 个水泡就意味着几百个 DOM 节点参与每一帧的属性更新。下表是常见结论:
| 对比项 | Canvas 负责主体 | SVG 只做装饰层 |
|---|---|---|
| 每帧更新方式 | 像素重绘,无节点成本 | 只有少量节点改 attribute,其他静态 |
| 水泡数量上限 | 几百个仍能维持流畅帧率 | 超过一定节点数明显掉帧 |
| 事件命中检测 | 需要手写distance < r判断 | 自带 pointer-events |
| 分辨率适配 | canvas 尺寸变化需要重绘 | 矢量缩放无压力 |
所以要被点击的水泡用 Canvas 加数学判定,装饰性高光、膜层碎片才用 SVG 挂上去。这也是这个源码叫「Canvas+SVG 双实现」而不是「二选一」的原因——两个引擎各自承担自己擅长的更新频率。
3. SVG 层负责「看得见的细节」:高光、膜层与装饰 path
3.1 先搭出「Canvas 主层 + SVG 装饰层」的页面结构
打开 zip 源码包,常见结构是index.html里放一个绝对定位的容器,Canvas 和 SVG 互相重叠、坐标系对齐:
<div class="bubble-stage" style="position: relative; width: 100%; height: 100vh; overflow: hidden;"> <canvas id="bubbleCanvas" style="position: absolute; inset: 0; width: 100%; height: 100%; display: block;"></canvas> <svg id="decorLayer" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice" style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;"></svg> </div>关键参数有三个:viewBox="0 0 800 600"建立与 Canvas 坐标系对应的逻辑尺寸;preserveAspectRatio="xMidYMid slice"保证 SVG 层在窗口宽高比变化时跟随 Canvas 一起裁切;pointer-events: none让 SVG 装饰层不拦截鼠标事件,点击事件仍然落在 Canvas 上。如果漏掉第三个,点击水泡时会莫名失效,这是读这类源码最容易卡住的点。
3.2 用 SVG path 在不重绘 Canvas 的前提下补膜层纹理
水泡主体是半透明的,但「裂开」的瞬间需要一些非常细的、随机的膜层碎片,如果让 Canvas 每帧重绘这些碎片,代码量会成倍增加。常见做法是把碎片预先做成<path>放进<defs>,分裂时只改变transform:
<defs> <path id="membranePatch" d="M0,0 C4,-6 10,-5 12,-1 C15,4 9,9 3,7 C-1,5 -3,3 0,0 Z" fill="rgba(255,255,255,0.4)" stroke="rgba(255,255,255,0.7)" stroke-width="0.6"/> </defs>这条 path 的 d 写得刻意不规则:两个C曲线段分别控制上缘外凸和下缘回收,首尾闭合形成一片「被撕裂的薄膜」。实际使用时把它复制成 4~6 个<use>节点,各自设置不同的旋转角度和缩放:
<use href="#membranePatch" x="0" y="0" transform="translate(398,296) rotate(-24 0 0) scale(0.6)"/> <use href="#membranePatch" x="0" y="0" transform="translate(421,305) rotate(77 0 0) scale(0.82)"/>transform里的顺序有讲究:translate决定碎片出现在哪个分裂点,rotate后面的0 0是旋转中心的局部坐标,先平移后旋转才能让碎片围绕自身中心旋转。很多人把旋转放在 translate 前面,碎片会沿画布原点公转,瞬间飞出屏幕。
3.3 坐标同步:把 Canvas 里算出的水泡位置喂给 SVG 节点
SVG 层不是独立运动的,它要跟随 Canvas 里的水泡坐标。实现上不要每帧查 DOM,而是维护一个「水泡 id → SVG 节点」的映射表:
const decorMap = new Map(); function syncDecorToBubble(bubble) { let node = decorMap.get(bubble.id); if (!node) { node = createDecorNode(bubble); decorMap.set(bubble.id, node); } // 只改 transform,不改 x/y 属性,减少样式重算 node.setAttribute('transform', `translate(${bubble.x}, ${bubble.y})`); node.setAttribute('opacity', String(bubble.alpha)); } function createDecorNode(bubble) { const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); use.setAttribute('href', '#membranePatch'); use.setAttribute('width', '16'); use.setAttribute('height', '16'); document.getElementById('decorLayer').appendChild(use); return use; }两个细节值得注意:一是用createElementNS而不是createElement,SVG 节点必须显式声明命名空间,否则会被浏览器当成 HTML 元素,表现异常;二是只更新transform和opacity这两个属性,浏览器对这两个属性的变更可以走合成器进程,不会触发完整的布局和绘制。同步函数应放在 Canvas 绘制完成之后调用,避免装饰比主体早出现造成撕裂感。
4. 分裂算法与状态管理:让水泡「裂得自然」
4.1 状态机:NORMAL、SPLIT、DYING 之间的流转
水泡的行为不是一个布尔开关能描述的。一次分裂要经历「等待 → 生成子泡 → 父泡淡出」三个阶段,如果父泡直接消失再凭空出现子泡,动画会显得生硬。源码包里的常见实现是显式维护一个状态字段:
const BubbleState = { NORMAL: 'normal', SPLIT: 'split', DYING: 'dying' }; function tick(bubble, dt) { if (bubble.state === BubbleState.SPLIT) { bubble.splitTimer += dt; bubble.alpha *= 0.985; // 父泡快速淡出 if (bubble.splitTimer > 0.2) { spawnChildren(bubble); // 只有一次,真正生成子泡 bubble.state = BubbleState.DYING; } } if (bubble.state === BubbleState.DYING) { bubble.alpha -= dt * 2.2; if (bubble.alpha <= 0) removeBubble(bubble); } }SPLIT状态里刻意停留 0.2 秒而不是立刻生成子泡,这段时间屏幕上会先看到父泡「鼓起」的征兆。spawnChildren必须保证只执行一次,常见的 bug 是在循环里反复调用,导致子泡数量指数级爆炸。DYING阶段不绘制高光、不再参与碰撞,只保留一个收缩的圆,让删除动作从视觉上不那么突兀。
4.2 子水泡生成规则:面积守恒才有真实感
水泡分裂看起来很物理,但大多数 html5 动画特效源码并没有真的做流体力学,而是用「面积守恒」来模拟体积守恒。父泡半径是 R,生成 n 个子泡,每个子泡半径由下式给出:
function spawnChildren(bubble) { const n = 3 + Math.floor(Math.random() * 4); // 3~6 个子泡 const area = Math.PI * bubble.r * bubble.r; const perArea = area / n; for (let i = 0; i < n; i++) { const childR = Math.sqrt(perArea / Math.PI) * (0.85 + Math.random() * 0.3); const angle = (Math.PI * 2 / n) * i + (Math.random() - 0.5) * 0.6; const dist = bubble.r * (0.95 + Math.random() * 0.25); bubbles.push({ id: nextId++, x: bubble.x + Math.cos(angle) * dist, y: bubble.y + Math.sin(angle) * dist, r: childR, hue: (bubble.hue + 18 + Math.floor(Math.random() * 24)) % 360, vx: bubble.vx + Math.cos(angle) * (30 + Math.random() * 40), vy: bubble.vy + Math.sin(angle) * (30 + Math.random() * 40), alpha: 0.9, state: BubbleState.NORMAL // 其他字段省略 }); } }子泡半径加了个0.85 + rand * 0.3的随机系数,所以 n 个子泡的面积之和不严格等于父泡,而是略有波动,这反而是真实感来源——完全等分的分裂看起来像程序生成的六边形网格。angle的基础分布是均匀的,再叠加随机抖动,避免子泡排成正多边形。hue在父泡色相附近偏移 18~42 度,是「彩色」两个字的重点:如果子泡完全继承父泡颜色,画面会越分越单调,偏移后则能在保持色调统一的同时持续产生新鲜感。
4.3 五个必调参数与调参方向
这类源码最有价值的部分不是绘制函数,而是那组控制随机分布的常数。下表列出手动调参时最常用的五个位置及其效果方向:
| 参数位置 | 取值范围 | 效果方向 |
|---|---|---|
浮力加速度vy -= | 6 ~ 20 | 越小水泡飘得越慢,适合做页面背景;越大越有喷涌感 |
分裂子泡数3 + rand*4 | 2 ~ 8 | 数量越多画面越碎,注意面积守恒计算会随 n 改变 |
高光偏移r * 0.35 | 0.2 ~ 0.5 | 调小高光居中像实心球;调大高光跑出边缘像玻璃反光 |
色相偏移18 + rand*24 | 0 ~ 60 | 偏移大则分裂后颜色差异明显,适合彩色主题 |
splitTimer停留时间 | 0.1 ~ 0.4 s | 太短看不到征兆;太长子泡出生有延迟感 |
调参时的判断依据不是数值本身,而是三类视觉反馈:水泡是否像「被戳破」而不是被替换、子泡出生时是否沿父泡边缘散布、整体颜色是否在分裂后保持原有色调又互相区分。优先动分离度最大的参数——先改分裂数量,再改色相偏移,最后微调浮力。
5. 验证与改造:一小时上手这份「水泡分裂动画源码包」
5.1 用帧率统计验证 Canvas 与 SVG 的分工边界
拿到源码包先别急着改参数,先加一段 20 行的帧率统计,确认瓶颈在 Canvas 绘制还是 SVG 节点更新:
let frames = 0, lastTime = performance.now(); function frameLoop(t) { frames++; const elapsed = t - lastTime; if (elapsed >= 1000) { const fps = frames * 1000 / elapsed; console.log('fps:', fps.toFixed(1), 'bubbles:', bubbles.length, 'svgNodes:', decorMap.size); frames = 0; lastTime = t; } // 原有 requestAnimationFrame 内容,最后递归调用 frameLoop }观察一分钟的日志,如果水泡数量增加时 fps 先掉,说明 Canvas 绘制是瓶颈,优先减少每帧渐变运算量,比如把不参与交互的远处水泡改用纯色填充;如果svgNodes数增长时 fps 掉,说明要限制装饰<use>节点总量,比如超过 120 个时复用最早的节点而不是继续新建。
5.2 把坐标同步从 attribute 迁移到 CSS transform
第 3.3 节的同步函数直接改setAttribute,在装饰节点较多时可以进一步优化:改用style.transform,并把节点放进合成层:
node.style.transform = `translate(${bubble.x}px, ${bubble.y}px)`; node.style.willChange = 'transform';willChange: transform会提示浏览器提前为该节点建立合成层,代价是每个节点消耗更多显存。经验值是:装饰节点少于 100 个收益明显,超过 200 个时显存开销反而可能拖慢帧率。这个技巧在把动画从全屏改成「页面局部区域」时尤其好用,因为局部区域节点少,合成层的收益更高。
5.3 交互触发方式改造:从鼠标点击到自动分裂
源码包里通常用鼠标事件触发分裂,做页面背景时更常见的是「每 2~3 秒随机选一个水泡触发分裂」。改造代价很小——在update循环外层加一个累积定时器,每次到点从存活水泡里随机挑一个调用splitBubble:
let splitAccumulator = 0; const SPLIT_INTERVAL = 2.4; // 秒 function autoSplit(dt) { splitAccumulator += dt; if (splitAccumulator >= SPLIT_INTERVAL) { splitAccumulator = 0; const alive = bubbles.filter(b => b.state === BubbleState.NORMAL); if (alive.length > 0) { const chosen = alive[Math.floor(Math.random() * alive.length)]; chosen.state = BubbleState.SPLIT; } } }建议同时把浮力加速度vy调小到 6~8、把初始水泡控制在 12~18 个,这样无需任何交互就能得到一张持续扩张又不断自我分裂的彩色背景。配合viewBox的 800×600 逻辑坐标系,只需调整缩放比例就能适配不同分辨率页面的水泡密度。
提示:如果页面还有其他高频交互元素,让水泡动画占据满屏可能导致事件区域重叠。更稳的做法是给最外层容器设置
pointer-events: none,只有明确需要点击的水泡才恢复auto,避免误触影响现有业务。
这套实现真正可迁移的部分是「状态驱动分裂 + 双图层按更新频率分工」这两个思路。把 Canvas 绘制、SVG 装饰、状态转移三块拆开读,水泡分裂源码就能变成你自己动画库里的一个基础特效模板,而不是只能原样跑通的 demo。
本文还有配套的精品资源,点击获取