最近好几个朋友问我,网页背景上那种鼠标一动就跟着飘散、靠近还会连成线的光点效果到底怎么做。这玩意儿确实挺出圈,很多个人主页、产品落地页都靠它撑氛围。其实核心就是一个Canvas粒子动画,本质上也不算多高深,但想把效果做流畅、做“有质感”,还是有不少细节值得掰扯。这篇就完整拆一遍我自己的实现过程,从原理到代码到踩坑,一次讲清楚。
这个效果本身能做的事不少:给官网加一层动态背景、做404页面的交互点缀、甚至当成一个独立的小组件嵌入到页头。适合谁看?如果你刚接触Canvas想找个小项目练手,或者你已经在写业务代码但一直没搞懂粒子系统的组织方式,这篇文章都能帮上忙。我会把每个设计决策背后的原因也讲明白,不只是甩一段能跑的代码。
1. 项目概述与整体设计思路
1.1 核心需求拆解:这个效果到底在做什么
抛开“炫酷”这个表象,这个粒子动画的本质需求其实就两点:一是页面上有一群持续运动的小粒子,二是鼠标移动到附近时,粒子与人之间产生一种“被吸引”或“被感知”的交互反馈。最常见的做法就是粒子靠近鼠标后,在鼠标位置和粒子之间画一条半透明的线段,同时粒子本身稍微改变运动方向或速度。
第三个需求通常是“粒子之间距离足够近时也互相连线”,这也是整个效果视觉上最有“网感”的来源。所以一个标准实现里至少要包含三套计算逻辑:粒子自身的运动、粒子与鼠标的距离判断、粒子与粒子之间的距离判断。听起来简单,但把这三件事放到每一帧里去算,如果粒子数量一多,性能立刻就会成为瓶颈。
这里有一个很容易被新手忽略的坑:粒子的“个体”其实只是一个包含位置、速度、半径等属性的对象,真正的视觉效果是每一帧把所有对象重新画一遍产生的。所以动画的流畅度不取决于粒子对象有多复杂,而取决于每一帧的绘制和计算开销有多大。理解了这一点,后面做性能优化时思路就会清晰很多。
1.2 技术选型:为什么选Canvas而不是DOM或SVG
同样是做粒子动画,DOM、SVG、Canvas三种方案我都试过,结论很明确:粒子数量超过几十个、且有大量实时交互计算时,Canvas是唯一靠谱的选择。
DOM方案的思路是给每个粒子创建一个div,再用transform或left/top去移动位置。粒子少的时候还行,一旦数量上到一两百,浏览器布局计算和样式重排就会明显拖慢帧率。SVG情况类似,虽然它有自己的图形渲染机制,但几百个circle节点同时更新属性,对DOM树操作的开销也非常大,而且SVG的绘制性能在复杂路径下并不占优。
Canvas是一块位图画布,它不关心页面上有哪些元素,只负责把像素画出来。每一帧你先把画布清空,再重新画所有粒子,整个过程在底层就是一个光栅化操作,效率高得多。再加上Canvas的2D上下文提供了arc、lineTo等基础绘制API,画一个圆形或一条线段就是一次函数调用,很适合高频绘制场景。
不过Canvas也有个缺点:它是“一次性”的,画完就不记得了,所以你必须自己维护粒子的状态数据。这正是粒子系统里“数据与绘制分离”的核心原因——一个数组保存所有粒子的位置和速度,每帧更新数组,再根据数组去画图。这个思路一旦建立,后续扩展任何效果都只是往数组里加字段的问题。
1.3 动画循环机制:requestAnimationFrame为什么是首选
动画循环是实现粒子系统最重要的骨架。很多人一开始会用setInterval或setTimeout来驱动绘制,比如每16毫秒执行一次update和draw。表面上也能跑,但有几个硬伤:首先setInterval的触发频率并不精准,浏览器标签页切到后台时它依然尝试执行(只是被限流),白白消耗资源;其次它无法和屏幕刷新率对齐,容易出现卡顿或撕裂。
正确的做法是用requestAnimationFrame。这个API的触发频率会和显示器的刷新率同步,通常是60Hz,也就是每秒调用60次,每次调用之间的距离足够稳定。而且当标签页切换到后台时,浏览器会自动暂停调用,不消耗CPU。
我在项目里通常不会直接裸用requestAnimationFrame,而是封装一个简单的循环函数,把更新逻辑和绘制逻辑分开调用。这样做的好处是后面如果要暂停动画、调整速度、或者做帧率统计,只需要在这个函数里加逻辑就行,不用改业务代码。
javascript复制function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
updateParticles();
drawParticles();
drawLines();
drawMouseLink();
requestAnimationFrame(animate);
}
animate();
这里要注意,requestAnimationFrame的第一次调用最好放在事件绑定之后,避免首帧动画还没准备好就开始绘制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理深度解析
2.1 粒子对象的数据结构与状态设计
粒子的数据结构是整个系统的地基。我见过不少新手把粒子的属性分散在多个数组里,比如一个数组存x坐标、一个数组存y坐标,最后索引对不上,排查起来非常痛苦。正确的做法是把每个粒子封装成一个独立对象,统一放在一个数组里。
一个基础粒子对象至少包含以下字段:x坐标、y坐标、x方向速度、y方向速度、半径。如果你想让粒子有视觉层次,可以再加透明度、颜色。如果你想让粒子在某些条件下改变行为,可以加一个“状态”字段,比如0代表正常漂浮、1代表被鼠标吸引。
javascript复制class Particle {
constructor(canvasWidth, canvasHeight) {
this.x = Math.random() * canvasWidth;
this.y = Math.random() * canvasHeight;
this.vx = (Math.random() - 0.5) * 0.6;
this.vy = (Math.random() - 0.5) * 0.6;
this.radius = Math.random() * 2 + 0.6;
this.alpha = Math.random() * 0.4 + 0.4;
}
}
速度的随机范围很讲究。太大会让粒子飞得飞快,还没来得及形成连线就散出屏幕了;太小粒子几乎不动,连线关系会很固定,动画缺乏灵气。我调试下来,x和y方向速度在-0.3到0.3之间是一个比较舒服的范围,既能看到明显的漂移感,又不至于破坏画面稳定。
粒子初始位置是随机平铺在整个画布上的。这里有一个细节:如果你把粒子全部生成在左上角,最后一瞬间所有粒子会像爆炸一样扩散,体验很突兀。所以初始化时用Math.random()乘上画布宽高,让粒子均匀分布。如果你想让粒子在某块区域更密集,可以对这个随机过程做加权处理,但基础版用均匀随机就够了。
2.2 粒子运动与边界处理策略
粒子的运动本身非常简单,每帧把速度累加到位置上就行:x += vx,y += vy。难点在于边界处理。常见的策略有三种:反弹、回流、消失重建。
反弹策略类似弹球,粒子碰到边界时反转对应的速度方向,代码实现是if (this.x < 0 || this.x > canvasWidth) this.vx = -this.vx。这种策略的优点是粒子数量恒定,视觉效果稳定,但如果你把反弹写成硬编码,粒子会一直贴着边缘来回弹,看着很机械。更好的做法是给边界加一个padding区域,粒子进入边缘缓冲带后才反弹,这样不会出现粒子刚到边缘就被弹走的生硬感。
回流策略是当粒子超出边界后,从对侧重新出现,类似贪吃蛇的穿越。这种策略适合表现“无限空间”的感觉,粒子总数也恒定,但突然从右边飞到左边会有一个视觉跳跃。
消失重建策略是我最推荐的一种:粒子超出边界一定范围后,重新生成一个位于画布内部的粒子。这样做虽然粒子总数在某一瞬间不完全一致(因为新粒子还没补上),但从整体看不会有明显个数差异,而且视觉上非常自然,粒子从边缘消失后由新的粒子补充,不会出现任何机械感。
javascript复制update(canvasWidth, canvasHeight) {
this.x += this.vx;
this.y += this.vy;
const margin = 50;
if (this.x < -margin || this.x > canvasWidth + margin ||
this.y < -margin || this.y > canvasHeight + margin) {
this.respawn(canvasWidth, canvasHeight);
}
}
2.3 鼠标追踪交互:坐标映射与事件监听
鼠标追踪的核心是监听mousemove事件,实时获取鼠标在页面上的坐标,然后换算成画布坐标。这个换算很关键,因为如果你的画布不是从页面原点开始,直接使用event.clientX和event.clientY会把坐标弄偏。
正确的做法是使用canvas.getBoundingClientRect()获取画布相对于视口的位置,然后用clientX减去rect.left、clientY减去rect.top,得到鼠标画布坐标。如果你对canvas做了CSS缩放(比如把画布宽度设成100%),那还需要把坐标乘以canvas.width和rect.width的比值,否则你会发现鼠标和粒子之间的连线位置完全对不上。
javascript复制canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouse.x = (e.clientX - rect.left) * (c
