简介html5与canvas结合实现的仿抖音直播爱心飘动点赞动画特效是一份面向Web前端初学者的完整源码案例重点展示如何用Canvas实时渲染动态图形并还原直播场景中的点赞交互体验。压缩包共10个文件以HTML页面、JavaScript脚本和PNG图片素材为主另含一张预览图整体仅100KB轻量易读便于直接运行与拆解学习。目前已有899人学习了该资源。代码中覆盖了Canvas绘图API调用、requestAnimationFrame驱动动画循环、点击事件监听与爱心生成逻辑同时兼顾了性能优化与移动端响应式适配适合希望系统掌握HTML5动画实现思路的开发者参考。通过阅读源码可以快速理解从绘制、更新到销毁一整套飘动动画流程并学习到如何将第三方库与原生Canvas结合使用为后续自制定制化交互动效打下基础。1. 为什么直播点赞动效绕不开 html5 canvasCSS 动画也能做爱心但做不了 40 颗爱心同时在屏幕里飘还保持 60fps。这套 html5canvas 动画特效源码把直播点赞的爱心飘动拆成了一个完整可复用的实现index.html 只有少量结构动画核心收敛在 flutter-hearts-zmt.js换 img 目录下的贴图就能换整套皮肤。值得拆开看的原因不是它画了一张图而是把 Canvas 动画的硬骨头——贴图预热、对象池复用、时间步进、触摸节流——按生产代码的方式组织在一起。直接跑起来很省事拿来改会遇到我这篇里写的那些调参细节。适合学完 Canvas 基础还在纠结性能的新手也适合要把直播或活动页点赞动效快速接进业务的前端。2. Canvas 渲染管线爱心贴图预热与对象池生命周期看 flutter-hearts-zmt.js 的开头和多数 Canvas 动画的启动顺序一致先准备贴图再初始化对象池最后才进入动画循环。新手最容易踩的坑是把img元素直接交给drawImage()。每次绘制时浏览器都要重新做图片解码点赞峰值时每秒几十次解码掉帧几乎是必然。合理做法是先把爱心贴图预热成位图纹理动画期间只做纹理拷贝不走解码路径。2.1 离屏 Canvas 预热把爱心贴图变成可复用纹理// 在动画启动前把 img/ 下的爱心贴图解码并绘制到离屏画布 const offscreen document.createElement(canvas); const offCtx offscreen.getContext(2d); const heartImage new Image(); heartImage.onload () { const scale 2; // 按 2 倍尺寸预渲染兼顾 Retina 屏清晰度 offscreen.width heartImage.naturalWidth * scale; offscreen.height heartImage.naturalHeight * scale; offCtx.drawImage(heartImage, 0, 0, offscreen.width, offscreen.height); startAnimation(offscreen); // 用这张位图启动主循环 }; heartImage.src img/heart.png; // 替换这里的路径即可换皮肤这里的关键是drawImage(image, x, y, width, height)的完整签名前两个参数是目标画布坐标后两个是绘制宽高。scale 2不是拍脑袋定的直播场景里爱心最终绘制宽度通常在 24~48px 之间预渲染到 48~96px在 iPhone 的高分屏上依然锐利再往上提升观感有限反而增加显存占用和纹理上传带宽。离屏 Canvas 与主 Canvas 共享 GPU 纹理通道首次drawImage后位图就驻留在显存里后续每帧只是纹理采样CPU 和内存开销远小于重复解码原图。这个预热步骤是后面对象池方案的前提——没有它池化只省了对象分配没省图片解码。2.2 爱心对象字段设计从 new 到对象池复用很多初学者习惯每次点击new Heart()动画结束就交给 GC 回收。演示页里没问题直播互动场景下用户连点时一秒钟创建几十个对象GC 停顿会以卡顿形式暴露出来。常见做法是把爱心设计成可复用对象事先分配好一池子点击只是取用动画结束就归还。const pool []; const activeHearts []; const POOL_SIZE 40; // 同时活跃的爱心上限超过后新点击会被丢弃 function createPool() { for (let i 0; i POOL_SIZE; i) { pool.push({ x: 0, y: 0, vx: 0, vy: 0, // 位置与速度 rotation: 0, rotateSpeed: 0, // 旋转角度与角速度 scale: 1, alpha: 1, // 缩放与透明度 life: 0, ttl: 1200, // 已存活时间与总寿命毫秒 active: false, // 是否在飞 }); } } function acquireHeart() { const h pool.find(item !item.active); if (!h) return null; // 池已满丢弃本次请求 h.active true; return h; }下列字段是这个场景里最容易调出差异的部分字段说明典型值x / y爱心中心坐标以点击点为准点击点本身vx / vy横向、纵向初速度决定第一帧飞行动量vx ±0.25px/msvy -0.5px/msrotation / rotateSpeed当前旋转角与角速度制造不规则翻滚rotateSpeed ±0.006 rad/msscale / alpha初始缩放与透明度scale 0.8~1.2alpha 1life / ttl存活时间 / 总寿命life 超过 ttl 即回收ttl 800~1400ms用pool.find而不是splice的原因splice会改变数组长度并触发内存移动高频点击下反而制造新的 GC 压力find加active标志是原地复用。池满时我一般直接丢弃新点击而不是抢占最老的爱心因为用户感知不到多出来的那颗爱心抢占反而会让飞了一半的动画突然消失看起来像 bug。2.3 绘制与回收drawImage 顺序与中心点对齐绘制阶段遍历 activeHearts先画后点的爱心在上层这个顺序不要反过来否则新爱心会一直被旧爱心盖住观感上有“往下压”的错觉。function render(ctx, texture) { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const h of activeHearts) { if (!h.active) continue; ctx.save(); ctx.translate(h.x, h.y); ctx.rotate(h.rotation); ctx.scale(h.scale, h.scale); ctx.globalAlpha h.alpha; ctx.drawImage(texture, -texture.width / 2, -texture.height / 2); ctx.restore(); } }drawImage的起始坐标取了负的贴图半宽半高这是为了让旋转和缩放围绕爱心中心进行如果从 0,0 开始画爱心会绕左上角旋转轨迹明显偏斜。clearRect清空整个画布在 375×667 的屏幕尺寸下成本可以忽略后面 4.2 再谈什么时候值得做局部清屏。3. requestAnimationFrame 时间步进与点击节流把爱心飘起来只需要两件事一个稳定的主循环一个合理的点击输入。主循环用requestAnimationFrame而不是setInterval是因为前者跟着显示器刷新率走60Hz 屏和 120Hz 屏上行为一致页面切后台会自动暂停更省电。但 rAF 回调拿到的是时间戳直接拿时间戳当增量用是第一个坑。3.1 用 delta 处理不同刷新率而不是每帧固定速度新手常见的写法是x 1一帧移动 1px。在 60Hz 上每秒移动 60px换到 120Hz 的旗舰机上每秒就是 120px同一套效果在不同设备上速度差了一倍视觉上高刷设备反而“飘得更快”。正确做法是位移和速度全部换成 ms 为单位由时间戳差值驱动。let lastTime 0; const MAX_DELTA 35; // 单位 ms超过说明发生过页面切换或卡顿 function tick(timestamp) { const delta Math.min(timestamp - lastTime, MAX_DELTA); lastTime timestamp; update(delta); render(ctx, heartTexture); window.requestAnimationFrame(tick); } window.requestAnimationFrame(tick);timestamp是 rAF 每次回调由浏览器注入的高精度时间戳。第一次进入时lastTime为 0delta可能是一个巨大的数字Math.min的截断不是可有可无的防御。MAX_DELTA 35的含义是页面切后台再切回来时rAF 会连续回调几帧第一帧 delta 可能达到几万毫秒不截断的话爱心会瞬间飞出几千像素。这个值也决定了动画对卡顿的容忍度调到 50 会让卡顿后的追赶更激进视觉上更容易出现跳变。3.2 spawnHeart 随机参数与 update 轨迹叠加飘动轨迹要接近直播那种“轻盈上浮、左右微摆”的手感通常用初速度加正弦摆动叠加实现。单独用线性速度太生硬单独加摆动又缺少上升感两者叠加才有悬浮效果。function spawnHeart(h, x, y) { h.x x; h.y y; const angle Math.random() * Math.PI * 2; // 随机初速度方向 h.vx Math.cos(angle) * 0.25; h.vy -0.5 - Math.random() * 0.2; // 向上为主 h.rotation Math.random() * Math.PI * 2; h.rotateSpeed (Math.random() * 2 - 1) * 0.006; h.scale 0.8 Math.random() * 0.4; h.ttl 900 Math.random() * 600; } const SWING_FREQ 0.004; // 摆动角频率 const SWING_AMP 0.28; // 摆动幅度 px/ms const DAMP 0.0016; // 速度阻尼控制上升减速 function update(delta) { for (const h of activeHearts) { if (!h.active) continue; h.life delta; h.y h.vy * delta; h.vy * Math.max(0, 1 - DAMP * delta); // 越飘越慢 h.x h.vx * delta Math.sin(h.life * SWING_FREQ) * SWING_AMP * delta; h.rotation h.rotateSpeed * delta; const progress h.life / h.ttl; h.alpha progress 0.3 ? 1 : 1 - (progress - 0.3) / 0.7; if (h.life h.ttl) { h.active false; } } }参数怎么调想让爱心飘得更高调大ttl或减小DAMP摆动幅度嫌小把SWING_AMP从 0.28 抬到 0.4注意幅度过大会出现“左右拉扯”的机械感SWING_FREQ建议保持在 0.003~0.006太低是直线上升太高是抽搐。透明度曲线这里只做了两段线性前 30% 保持不透明后 70% 淡出。想更自然的话换成缓动h.alpha Math.pow(1 - progress, 2)曲线更圆润多一次Math.pow调用对 40 个对象完全无感知。参数作用参考范围SWING_FREQ横向摆动的角频率0.003~0.006 rad/msSWING_AMP摆动幅度0.2~0.4 px/msDAMP上升速度阻尼0.001~0.003 /msMAX_DELTA单帧时间步长上限30~50 msBURST_LIMIT时间窗内点击批次数6~10HEARTS_PER_TAP单次点击生成爱心数2~43.3 pointerdown 连击节流时间窗与上限事件监听用pointerdown而不是touchstart和click的组合Pointer Events 已经把鼠标、触摸和笔输入统一了一套监听全覆盖。但pointerdown触发频率极高快速连点一秒钟能产生十几二十次事件每次都生成爱心的话对象池瞬间打满后续点击全部被丢弃用户看到的就是“点着点着没反应”。let burstCount 0; let lastBurstTime 0; const BURST_LIMIT 8; // 一个时间窗内最多处理的点击批次 const BURST_INTERVAL 300; // 单位 ms const HEARTS_PER_TAP 3; // 每次点击生成 3 颗 canvas.addEventListener(pointerdown, (e) { const now performance.now(); if (now - lastBurstTime BURST_INTERVAL) { burstCount; } else { burstCount 1; // 新时间窗开始 } lastBurstTime now; if (burstCount BURST_LIMIT) return; // 超限直接丢弃 const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; for (let i 0; i HEARTS_PER_TAP; i) { const h acquireHeart(); if (!h) break; spawnHeart(h, x, y); } });BURST_LIMIT 8的含义是一个 300ms 时间窗内最多处理 8 次点击对应大约每秒 26 次已经超过绝大多数人的手速上限再高用户感知不到新爱心只会白白耗尽对象池。HEARTS_PER_TAP 3是视觉密度和设备负载之间的折中一颗太寡淡五颗以上容易把屏幕糊住。getBoundingClientRect的坐标转换这步不能省画布经过 DPR 缩放后事件坐标和画布坐标已经不在同一个坐标系里直接用e.offsetX在部分浏览器上会出现点击位置偏移。4. 移动端适配与降级DPR、池上限与帧率监控这个 demo 在 PC 浏览器上跑起来没什么压力拿到真机上会暴露两类问题高分屏模糊和对象池配置失当。前者让爱心边缘发虚观感一下子就露怯后者的表现是动画偶发卡顿最可靠的判断依据是帧率曲线而不是肉眼感觉。这一章按我调优的顺序解决尺寸、容量、观测三个问题。4.1 devicePixelRatio 换算与画布尺寸不设置 DPR 的画布在高分屏上是模糊的浏览器会把物理像素和 CSS 像素之间做一次插值放大。常见做法是以 CSS 尺寸为基准把画布像素数乘上devicePixelRatio再用setTransform把坐标系缩放回来让后续所有drawImage仍然以 CSS 像素为单位书写。function resizeCanvas(canvas, container) { const dpr Math.min(window.devicePixelRatio || 1, 2); canvas.width Math.floor(container.clientWidth * dpr); canvas.height Math.floor(container.clientHeight * dpr); canvas.style.width container.clientWidth px; canvas.style.height container.clientHeight px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }为什么把dpr上限设为 2Android 旗舰的 3x 屏上画布像素数是 2x 时的 2.25 倍每帧clearRect和全屏纹理上屏的开销同步上涨而肉眼几乎分辨不出 2x 和 3x 在动态小尺寸贴图上的差别。Math.floor是为了避免部分浏览器在高 DPR 下出现半像素偏移导致爱心边缘出现细线的瑕疵。setTransform(dpr, 0, 0, dpr, 0, 0)用完以后所有绘制代码都不需要再关心 DPR 的存在改坐标时心智负担小很多。4.2 对象池上限30 还是 80看帧率说话不同设备能承载的活跃爱心数量差距很大。iPhone 13 上跑到 60 颗仍能稳定 60fps换成两年前的千元机40 颗就开始掉到 40fps。与其猜一个万能值不如把池子上限做成可调参数配合 4.3 的帧率观测在发布前用低端机实测一轮。设备档位POOL_SIZE 建议HEARTS_PER_TAP中端 Android / 旧 iPhone242近两年旗舰手机403桌面 Chrome / 高性能笔记本643~4POOL_SIZE 24 的含义是峰值时有 24 颗爱心同时在飞配合 BURST_LIMIT 节流交互上依然有“瀑布感”但 GPU 填充率压力只有 40 颗的六成。这个值不是固定的你可以加一行启动日志输出pool.length和activeHearts.length的曲线如果长期跑到上限且帧率低于 45fps就往下调一档。降级策略我在实际项目里通常做成两档检测到掉帧超过阈值后优先降HEARTS_PER_TAP再降POOL_SIZE这个顺序能最大程度保住视觉密度。4.3 visibilitychange 暂停与帧率观测requestAnimationFrame在页面切后台后会自动停止但恢复回来时第一次回调的时间戳距离上一帧可能隔了几秒上一章的MAX_DELTA会兜住瞬移更彻底的做法是监听visibilitychange恢复时重置时间基准。部分安卓 WebView 在切后台再恢复时会把 canvas 内容清空恢复后需要触发一次全量重绘。let running true; document.addEventListener(visibilitychange, () { if (document.hidden) { running false; } else { running true; lastTime performance.now(); // 重置时间基准防止恢复后首帧 delta 爆炸 requestAnimationFrame(tick); } }); let frames 0; let lastFpsAt performance.now(); function measureFps(timestamp) { frames; if (timestamp - lastFpsAt 1000) { console.log(fps${frames}); frames 0; lastFpsAt timestamp; } }lastTime performance.now()这行是恢复时最容易漏掉的不重置的话恢复后的第一帧 delta 是几秒钟就算有 MAX_DELTA 截断也会丢弃一次正常的更新。帧率观测代码只用于开发调试上线前删掉避免每帧多一次条件判断和 console 输出。调试时建议把measureFps挂在真实设备和开发者工具的 CPU 降频模拟上分别跑一轮你会看到数字差得非常明显。5. 进阶从单次点击到连击组件的扩展技巧5.1 双击后自动连击用时间窗接管点击直播场景里最常被要求补的一个功能是“双击连发”用户双击后自动连续往外冒爱心不用一直按着屏幕。原理不复杂600ms 内的第二次点击进入连击模式用一个setInterval周期性地从同一个点击点生成爱心运行一段时间后自动停下。let autoTimer null; let lastTapAt 0; const DOUBLE_TAP_MS 600; canvas.addEventListener(pointerup, (e) { const now performance.now(); if (now - lastTapAt DOUBLE_TAP_MS) { startAutoBurst(e.offsetX, e.offsetY); } else { lastTapAt now; } }); function startAutoBurst(x, y) { if (autoTimer) return; // 连击期间不响应新的双击 autoTimer setInterval(() { const h acquireHeart(); if (h) spawnHeart(h, x, y); }, 120); setTimeout(() { clearInterval(autoTimer); autoTimer null; }, 1600); }120ms 的间隔在多数机型上是安全的低于 100 会让同一时刻活跃爱心数暴涨直接冲高 GPU 负载。连击期间不再响应新双击是为了避免多个 setInterval 叠加。5.2 文本渲染路径把贴图换成 Unicode 字符不想依赖图片文件的话可以在绘制阶段按 type 分派image 类型走 drawImagetext 类型走 fillText。这样换皮肤时只需要改一个字符串。function drawTextHeart(ctx, h) { ctx.save(); ctx.translate(h.x, h.y); ctx.rotate(h.rotation); ctx.globalAlpha h.alpha; ctx.font ${Math.round(24 * h.scale)}px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(♥, 0, 0); // 换成其他单字符即可换样式 ctx.restore(); }textBaseline middle配合textAlign center才能让字符中心对齐到爱心的坐标点上否则字符会按基线偏移轨迹看起来是歪的。与 drawImage 相比fillText 每次调用都走文本栅格化性能比贴图绘制差只适合爱心数量不超过三五十颗的轻量场景。5.3 限定点击区域把弹幕区留给用户直播页面的顶部通常是弹幕区只有下半部分才应该触发点赞。实现上是在 pointerdown 里加一个比例判断低于阈值直接 return。const TAP_ZONE_RATIO 0.6; // 只允许屏幕下方 60% 区域触发 function inTapZone(y, height) { return y height * (1 - TAP_ZONE_RATIO); }调参顺序我一般是这样先在真机上打开 fps 观测连点 10 秒记录降帧幅度再依次调小 BURST_INTERVAL、调大 HEARTS_PER_TAP直到 fps 曲线跌破 45然后回调一档。这套源码的核心逻辑都集中在 flutter-hearts-zmt.js调参不需要动 index.html 的任何结构。本文还有配套的精品资源点击获取