用 Canvas 实现一个轻量粒子背景动画:从踩坑到丝滑

举报
yd_263393080 发表于 2026/10/28 11:40:01 2026/10/28
【摘要】 不依赖任何动画库,用原生 Canvas API 实现一个性能友好的粒子背景动画:粒子运动模型、鼠标交互吸附、设备像素比适配,以及几个容易掉进去的性能坑。

不依赖任何动画库,用原生 Canvas API 实现一个性能友好的粒子背景动画:粒子运动模型、鼠标交互吸附、设备像素比适配,以及几个容易掉进去的性能坑。
粒子背景动画在落地页和个人主页里很常见,通常直接上 particles.js 之类的库。但库的体积和配置成本有时候没必要,尤其当你只需要一个简单的飘浮效果时。最近用纯 Canvas 写了一遍,把值得记的东西整理出来。

一、基本结构

整个动画就一个 canvas 元素加一段 JS,核心循环是 requestAnimationFrame 驱动的清屏-更新-绘制三步:

function loop() {
  ctx.clearRect(0, 0, width, height);
  particles.forEach(p => { p.update(); p.draw(ctx); });
  requestAnimationFrame(loop);
}

粒子对象持有位置、速度、半径三个核心属性,update 做边界反弹,draw 画一个圆。就这么简单,几十行就能跑起来。

二、连线效果

粒子之间距离小于阈值时画一条线,透明度随距离衰减。这是粒子动画视觉上最关键的部分,也是性能问题的第一个坑。
直觉写法是双重循环遍历所有粒子对,O(n²)。100 个粒子还好,300 个就开始掉帧。优化思路有两个:
1. 减少绘制频率。 不是每帧都重新计算所有连线,隔一帧算一次,视觉上几乎无差别。
2. 限制连线数量。 每个粒子最多只和最近的 K 个粒子连线,而不是和所有满足距离条件的粒子都连。用距离排序取前 K 个,虽然排序本身也是 O(n²log n),但绘制调用大幅减少,瓶颈在绘制不在计算。

function drawConnections(ctx, particles, maxDist, maxLines) {
  for (let i = 0; i < particles.length; i++) {
    const dists = [];
    for (let j = 0; j < particles.length; j++) {
      if (i === j) continue;
      const d = distance(particles[i], particles[j]);
      if (d < maxDist) dists.push({ j, d });
    }
    dists.sort((a, b) => a.d - b.d);
    dists.slice(0, maxLines).forEach(({ j, d }) => {
      ctx.globalAlpha = 1 - d / maxDist;
      ctx.beginPath();
      ctx.moveTo(particles[i].x, particles[i].y);
      ctx.lineTo(particles[j].x, particles[j].y);
      ctx.stroke();
    });
  }
}

三、鼠标交互

粒子被鼠标吸引或排斥是常见的交互效果。实现上就是在 update 里加一个力:计算粒子到鼠标的距离,距离越近力越大。
坑在于 mousemove 事件的频率和 RAF 的频率不一致。mousemove 一秒可能触发上百次,但 RAF 只有 60 次。如果每次 mousemove 都去更新粒子状态,会产生抖动。正确做法是 mousemove 只记录鼠标位置,粒子状态的更新统一放在 RAF 循环里做:

canvas.addEventListener('mousemove', e => {
  mouse.x = e.clientX;
  mouse.y = e.clientY;
});
// 在粒子的 update 里读取 mouse.x / mouse.y

四、设备像素比适配

这是 Canvas 最经典的坑。在高 DPI 屏幕上不做 DPR 适配,画面会发虚。做法是:

const dpr = window.devicePixelRatio || 1;
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
ctx.scale(dpr, dpr);

注意 canvas.width 和 canvas.style.width 是两回事:前者是绘图缓冲区大小,后者是 CSS 显示大小。设错了要么模糊要么变形。另外 resize 时要重新设置一遍,不能只改 style 不改缓冲区。

五、性能细节

1. globalAlpha 的代价。 每条连线都改 globalAlpha 会触发大量状态切换。更好的做法是把透明度编码到 strokeStyle 的 rgba 里,一次性设置颜色,减少状态变更。
2. 离屏不可见时暂停。 用 IntersectionObserver 监听 canvas 是否在视口内,不可见时停掉 RAF 循环。标签页切走时浏览器会自动降频 RAF,但页面内滚动到不可见区域不会,白白耗电。
3. 粒子数量按屏幕宽度自适应。 手机上 30 个就够了,桌面端可以放到 100+。用 Math.min(120, Math.floor(width / 12)) 这种简单公式做自适应,避免在低端手机上卡死。

六、小结

粒子动画本身不复杂,但要做到不卡、不虚、不费电,细节比想象中多。核心经验就两条:性能瓶颈几乎都在绘制调用而非计算逻辑,能少画就少画;交互事件和渲染循环要解耦,事件只存状态,渲染只读状态。完整代码不到 200 行,没有外部依赖,双击 HTML 就能跑。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。