简介:html5与canvas结合实现的仿抖音直播爱心飘动点赞动画特效,是一份面向Web前端初学者的完整源码案例,重点展示如何用Canvas实时渲染动态图形,并还原直播场景中的点赞交互体验。压缩包共10个文件,以HTML页面、JavaScript脚本和PNG图片素材为主,另含一张预览图,整体仅100KB,轻量易读,便于直接运行与拆解学习。目前已有899人学习了该资源。代码中覆盖了Canvas绘图API调用、requestAnimationFrame驱动动画循环、点击事件监听与爱心生成逻辑,同时兼顾了性能优化与移动端响应式适配,适合希望系统掌握HTML5动画实现思路的开发者参考。通过阅读源码,可以快速理解从绘制、更新到销毁一整套飘动动画流程,并学习到如何将第三方库与原生Canvas结合使用,为后续自制定制化交互动效打下基础。
1. 为什么直播点赞动效绕不开 html5 canvas
CSS 动画也能做爱心,但做不了 40 颗爱心同时在屏幕里飘还保持 60fps。这套 html5+canvas 动画特效源码把直播点赞的爱心飘动拆成了一个完整可复用的实现: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/ms,vy -0.5px/ms |
| rotation / rotateSpeed | 当前旋转角与角速度,制造不规则翻滚 | rotateSpeed ±0.006 rad/ms |
| scale / alpha | 初始缩放与透明度 | scale 0.8~1.2,alpha 1 |
| life / 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为 0,delta可能是一个巨大的数字,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/ms |
| SWING_AMP | 摆动幅度 | 0.2~0.4 px/ms |
| DAMP | 上升速度阻尼 | 0.001~0.003 /ms |
| MAX_DELTA | 单帧时间步长上限 | 30~50 ms |
| BURST_LIMIT | 时间窗内点击批次数 | 6~10 |
| HEARTS_PER_TAP | 单次点击生成爱心数 | 2~4 |
3.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上限设为 2:Android 旗舰的 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 / 旧 iPhone | 24 | 2 |
| 近两年旗舰手机 | 40 | 3 |
| 桌面 Chrome / 高性能笔记本 | 64 | 3~4 |
POOL_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 类型走 drawImage,text 类型走 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 的任何结构。
本文还有配套的精品资源,点击获取