十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

后端开发学前端:Canvas黑洞光标特效实战解析

后端开发学前端:Canvas黑洞光标特效实战解析 前端圈子有个说法后端开发看页面就像出租车司机看地图能看懂大方向但真让自己开进小巷子就抓瞎。这话我原先是不服的直到自己动手写了一个“黑洞光标特效”才发现自己连Canvas的坐标系都没完全吃透。这篇日志就是我从Java后端视角硬啃前端交互特效的记录——不聊“转行”只聊“打通”。如果你也是后端也想搞懂前端某个效果到底是怎么动起来的这篇文章应该能对你有用。黑洞光标特效说白了就是页面上一堆粒子平时安静地飘着鼠标一过来粒子就像被吸进无底洞一样汇聚到光标附近形成黑洞般的引力效果。它好看、直接、反馈感强而且实现它能覆盖前端里很多核心知识Canvas绘制、requestAnimationFrame动画循环、事件机制、性能优化、自适应屏幕。对一个后端来说这是性价比极高的一次动手实践。1. 拆需求黑洞光标到底在“动”什么1.1 一个特效背后的三个子问题拿到一个前端特效别急着查代码先把问题拆开。黑洞光标这个效果拆开其实就三件事。第一页面上那些微小的粒子从哪来、怎么画出来。第二鼠标的光标位置怎么被页面感知到。第三鼠标附近的粒子怎么被“吸”过去并且产生那种被黑洞吞噬的视觉感受。把这三个问题想清楚整个特效的地基就打好了。这就是我作为后端最熟悉的套路拿到需求先拆模块、定接口、理清数据流只不过这次的数据流改成了“鼠标移动 - 更新粒子状态 - 重新绘制画面”。第二件事里有个细节容易一眼带过实际却很关键鼠标在页面上移动的频率可以很高一秒触发几十次是很正常的。如果每次移动都直接重新画一整幅画面性能立刻崩。所以鼠标事件和画面绘制之间必须解耦鼠标事件只负责更新坐标状态真正的绘制交给专门的动画循环。这种“事件只改状态、循环负责输出”的思路其实就是后端常见的生产者和消费者模型。鼠标是生产者动画循环是消费者中间共享一个坐标状态。把这个关系想明白后面写代码基本不会乱。1.2 为什么选了Canvas而不是CSS动画刚开始我也犹豫这效果能不能用CSS做毕竟后端一直对CSS有种谜之敬畏觉得它无所不能。事实上CSS可以做粒子动画也可以做鼠标跟随但黑洞这种“粒子数量多、运动轨迹连续变化、需要基于距离实时计算”的效果如果用CSS给每个粒子单独设置transform粒子一多就直接卡成PPT。后来我排了个对比选型思路一下子清晰了。方案实现方式粒子数量上限复杂度适用场景CSS DOM每个粒子一个div用transform移动100个左右就明显卡低少量元素、简单位移Canvas 2D一个画布JS绘制所有粒子几千个也能流畅中粒子系统、实时交互WebGLGPU加速渲染数万个以上高大型3D、复杂粒子海洋黑洞特效的粒子数量通常在几百个到一千个之间Canvas 2D刚好是又稳又省事的区间。WebGL其实完全够格性能上限更高但对当时的我来说学习成本太高了属于杀鸡用牛刀还会把后端同学劝退。选Canvas还有一个很重要的原因它的心智模型和后端更接近。Canvas就是一块画布JS在每一帧里把所有粒子的位置算好然后批量绘制出来。这跟后端做报表批处理很像从数据源粒子数组里取数据做计算然后一次性输出。CSS动画则相反它把每一帧的中间状态交给浏览器引擎去算开发者能控制的东西少排错也更难。提示如果只是想给页面加个鼠标跟随的小圆点CSS就够了。但从“学习前端原理”的角度Canvas路径的价值远高于CSS。2. 核心机制粒子、引力与事件2.1 粒子的数据结构就像一张表后端写代码习惯先定义实体类前端做粒子也是同一个道理。如果你用Java一个粒子就是一张表的一条记录字段大概这样public class Particle { private double x; private double y; private double vx; private double vy; private double radius; private boolean active; }放到JavaScript里连类都省了直接塞进一个对象数组let particles []; for (let i 0; i 600; i) { particles.push({ x: Math.random() * canvasWidth, y: Math.random() * canvasHeight, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, r: Math.random() * 2 0.5, active: true }); }x和y是粒子的当前坐标vx和vy是它在x轴和y轴方向的速度r是粒子半径active标记它在不在黑洞的“吞噬范围”内。后端的表结构讲究字段精简这个结构已经算是最小可用集了。这里我想强调一个从后端迁移过来的习惯给对象加一个active字段而不是直接删除它。这就像数据库里做逻辑删除而不是物理删除。因为在动画循环里删除一个数组元素会改变数组索引后续遍历容易出现跳跃或者越界。更关键的是粒子被“吞噬”之后不是永远消失它还会在页面另一个位置重新生成用active标记逻辑状态实现起来干净多了。2.2 引力模型把数学公式变成代码黑洞特效最核心的就是“吸”这个动作。后端写算法讲究可用性和边界条件前端做视觉特效更讲究“看起来像不像那么回事”。所以这里不需要严格遵守物理学的万有引力公式只需要近似出一个“越靠近鼠标速度越快”的效果就行。我的实现思路分三步。第一步计算粒子和鼠标之间的距离。设鼠标位置为 mouseX 和 mouseY粒子位置是 p.x 和 p.y那么let dx mouseX - p.x; let dy mouseY - p.y; let dist Math.sqrt(dx * dx dy * dy);第二步判断距离是否落在黑洞影响半径内。如果 dist 小于某个阈值比如150像素就按照“距离越近受力越大”的方式给粒子叠加一个朝向鼠标的速度增量if (dist RADIUS dist 0.5) { let force (RADIUS - dist) / RADIUS * STRENGTH; p.vx (dx / dist) * force; p.vy (dy / dist) * force; }dx / dist 是方向上的单位向量保证粒子不管从哪个方位靠近鼠标受力方向都是正确的。force 则像后端的权重系数距离越近权重越大粒子被吸得越狠。第三步给粒子施加速度的时候不能让它无限加速否则视觉上会变成一团乱飞的光点。这里需要给速度设一个上限再加一点阻尼let speed Math.sqrt(p.vx * p.vx p.vy * p.vy); if (speed MAX_SPEED) { p.vx (p.vx / speed) * MAX_SPEED; p.vy (p.vy / speed) * MAX_SPEED; } p.vx * DAMPING; p.vy * DAMPING;阻尼系数通常取0.9到0.95之间。它就像一个减速带保证粒子被吸到鼠标附近时会慢下来而不是在目标点附近来回振荡。加完力之后更新粒子的坐标p.x p.vx; p.y p.vy;当距离小于一个很小的值比如 dist 10就认为粒子已经被黑洞吞掉了。这时候把 active 设为 false然后在屏幕随机位置重新生成一个粒子让它继续参与运动。2.3 鼠标事件与requestAnimationFrame的分工后端开发对事件应该不陌生前端的事件类似消息队列的发布订阅鼠标移动就是一个高频消息。但高频消息不能直接处理和它相关的所有工作否则消息一多就堆积这就是我在前面提到的“生产者和消费者”模型。前端解决这个问题的标准方案是requestAnimationFrame。它的特点是浏览器每次刷新画面之前只会执行一次回调。通常显示器刷新率是60Hz也就是说每秒最多执行60次动画更新。你完全可以把它理解成一个固定频率的调度器类似后端的定时任务框架只是它的频率由浏览器的渲染节奏决定。代码上的分工是这样// 鼠标事件只更新坐标 canvas.addEventListener(mousemove, (e) { mouseX e.clientX - rect.left; mouseY e.clientY - rect.top; }); // 动画循环负责计算和绘制 function animate() { updateParticles(); drawParticles(); requestAnimationFrame(animate); } animate();这里有一个后端很容易踩的坑如果把绘制的代码直接写进mousemove回调鼠标每动一次就重画一次全部粒子。当粒子数量多时性能会急剧下降。正确做法是让mousemove只更新鼠标坐标真正的绘制统一由animate的requestAnimationFrame来驱动。这个思想跟后端的削峰填谷本质一模一样。3. 动手实现一版能跑的代码3.1 初始化画布与高分屏适配拿到Canvas第一件事不是画图而是把画布尺寸设置正确。这里有一个巨大的坑Canvas的width和height属性是“画布内部像素尺寸”而CSS里的width和height是“画布显示尺寸”。如果两者不一致尤其当屏幕是高DPI的Retina屏时画出来的内容会发糊。标准的做法是拿到设备像素比devicePixelRatio把画布的实际像素尺寸扩大到显示尺寸的dpr倍再用CSS把它压缩回显示的尺寸。代码是这样const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.scale(dpr, dpr);注意最后一行ctx.scale(dpr, dpr)它让后续所有绘制的坐标都使用逻辑像素不需要在画图时手动乘dpr。这个细节很多教程没有写清楚不设置的话画出来的圆在Retina屏上就是模糊的。3.2 粒子生成与启动初始化粒子的时候我建议把数量设成一个可调节的常量。屏幕越小粒子越少屏幕越大粒子越多。简单粗暴的方式是根据屏幕面积估算一个值const PARTICLE_COUNT Math.floor((window.innerWidth * window.innerHeight) / 3000);这个3000是经验值表示平均每3000平方像素放一个粒子。1920x1080的屏幕大约需要691个粒子1366x768大约需要350个肉眼看起来密度比较均匀。注意这个值要和你后续的性能测试结果配合调整如果你是4K屏建议直接把除数调到5000否则粒子数量太多一样会卡。粒子初始化后还要给页面加上一个纯黑或深色背景。黑洞特效的关键是视觉对比度深色背景下白色粒子被吸入的感觉非常强浅色背景下效果会大打折扣。用CSS给body设置背景色就可以了。如果想让黑洞更炫背景可以用渐变或者星空图这个属于锦上添花。3.3 鼠标监听与黑洞参数黑洞光标特效必须有鼠标参与所以mousemove事件是刚需。我建议先封装一个获取鼠标在canvas内坐标的工具函数function getMousePos(canvas, evt) { const rect canvas.getBoundingClientRect(); return { x: evt.clientX - rect.left, y: evt.clientY - rect.top }; }clientX和clientY是鼠标相对于浏览器视口的坐标rect.left和rect.top是canvas相对于视口的偏移。两者相减才能得到鼠标在canvas坐标系内的实际坐标。如果不相减鼠标在页面中部时黑洞口会偏到右下角去这是新手最容易踩的偏移问题。黑洞参数我一开始先用三组影响半径RADIUS、吸引强度STRENGTH、最大速度MAX_SPEED。用常量定义好调试的时候改数字就行const RADIUS 150; // 引力影响半径单位像素 const STRENGTH 1.2; // 引力强度 const MAX_SPEED 12; // 粒子最大速度 const DAMPING 0.92; // 阻尼系数这几个参数对视觉效果的影响我简单说一下。RADIUS太小黑洞感不明显太大则满屏粒子乱飞STRENGTH太小粒子靠近得很慢太大则粒子会被“弹”开MAX_SPEED是防止粒子变成瞬移的保险丝。调参的时候一次只改一个配合控制台实时看效果会比盲调快很多。3.4 更新与绘制循环更新逻辑按我前面说的顺序来遍历粒子数组计算距离受力限速更新坐标判断吞噬。绘制则分四步清屏、画背景、画粒子、画黑洞圈。function update() { for (let p of particles) { let dx mouseX - p.x; let dy mouseY - p.y; let dist Math.sqrt(dx * dx dy * dy); if (dist RADIUS dist 0.5) { let force (RADIUS - dist) / RADIUS * STRENGTH; p.vx (dx / dist) * force; p.vy (dy / dist) * force; } let speed Math.sqrt(p.vx * p.vx p.vy * p.vy); if (speed MAX_SPEED) { p.vx (p.vx / speed) * MAX_SPEED; p.vy (p.vy / speed) * MAX_SPEED; } p.vx * DAMPING; p.vy * DAMPING; p.x p.vx; p.y p.vy; if (dist 10) { resetParticle(p); } } } function draw() { ctx.clearRect(0, 0, width, height); drawBackground(); drawParticles(); drawBlackHole(); }resetParticle就是把粒子重新放到随机位置同时把速度清零避免它一出生就以高速冲向鼠标。黑洞圈我建议画一个半透明的圆环让用户能直观看到影响范围。鼠标位置作为圆心半径为黑洞的半径用ctx.arc画出来配色上稍微特殊一点透明度低一点不会抢占粒子效果。这一层纯粹是视觉引导不影响物理计算。3.5 完整代码贴出到这里核心部分已经全部完成。整合起来的完整代码我贴在下面方便你直接复制运行。我故意没有做任何框架封装一个HTML文件从头到尾方便排错和理解原理。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title黑洞光标特效/title style body { margin: 0; overflow: hidden; background: #0a0a0f; } canvas { display: block; } /style /head body canvas idcanvas/canvas script const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; let width window.innerWidth; let height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.scale(dpr, dpr); const PARTICLE_COUNT Math.floor((width * height) / 3000); const RADIUS 150; const STRENGTH 1.2; const MAX_SPEED 12; const DAMPING 0.92; let particles []; let mouseX -1000; let mouseY -1000; function initParticles() { for (let i 0; i PARTICLE_COUNT; i) { particles.push({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, r: Math.random() * 2 0.5 }); } } function resetParticle(p) { p.x Math.random() * width; p.y Math.random() * height; p.vx (Math.random() - 0.5) * 0.5; p.vy (Math.random() - 0.5) * 0.5; } canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); mouseX e.clientX - rect.left; mouseY e.clientY - rect.top; }); canvas.addEventListener(mouseleave, () { mouseX -1000; mouseY -1000; }); function update() { for (let p of particles) { let dx mouseX - p.x; let dy mouseY - p.y; let dist Math.sqrt(dx * dx dy * dy); if (dist RADIUS dist 0.5) { let force (RADIUS - dist) / RADIUS * STRENGTH; p.vx (dx / dist) * force; p.vy (dy / dist) * force; } let speed Math.sqrt(p.vx * p.vx p.vy * p.vy); if (speed MAX_SPEED) { p.vx (p.vx / speed) * MAX_SPEED; p.vy (p.vy / speed) * MAX_SPEED; } p.vx * DAMPING; p.vy * DAMPING; p.x p.vx; p.y p.vy; if (dist 10) { resetParticle(p); } } } function draw() { ctx.clearRect(0, 0, width, height); // 背景半透明叠加产生拖影效果 ctx.fillStyle rgba(10, 10, 15, 0.3); ctx.fillRect(0, 0, width, height); // 粒子 ctx.fillStyle #ffffff; for (let p of particles) { ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } // 黑洞圈 if (mouseX 0 mouseY 0) { ctx.beginPath(); ctx.arc(mouseX, mouseY, RADIUS, 0, Math.PI * 2); ctx.strokeStyle rgba(255, 255, 255, 0.15); ctx.lineWidth 2; ctx.stroke(); } } function animate() { update(); draw(); requestAnimationFrame(animate); } initParticles(); animate(); window.addEventListener(resize, () { width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.scale(dpr, dpr); particles []; initParticles(); }); /script /body /html这段代码我实测在1366x768分辨率、Chrome浏览器下FPS稳定在60左右粒子数量调高到900个也没有明显掉帧。如果你在低配电脑上跑可以先把STRENGTH降一点或者把DAMPING调高一点粒子运动不会那么剧烈视觉上更沉稳。4. 调优与坑实测中比教程多踩的几个雷4.1 FPS上不去的真凶如果我把粒子数量直接拉高到3000FPS会掉到40以下。一开始我以为是粒子太多Canvas扛不住。后来用Chrome的Performance面板一测发现罪魁祸首不是绘制而是我在mousemove回调里直接调用了draw函数。鼠标一移动就触发新的绘制同时requestAnimationFrame也在触发绘制两者叠加在一起实际上一个事件循环里可能绘制了两次甚至三次。对Canvas来说多次绘制都是全量工作浪费极大。解决办法就是我在2.3节里说的mousemove只更新坐标绘制全部交给animate。改完之后即使粒子数量到2000FPS也稳定在55以上。提示前端性能分析和后端一样先量化再优化。打开Chrome的Performance面板录一段交互过程看主线程的耗时分布比盲目改代码靠谱得多。4.2 鼠标位置对不上这是我遇到的第一个明显的视觉Bug鼠标在页面左上角黑洞却偏到右下角约30像素。排查后发现是DPR适配引起的。画布的逻辑宽度是width物理像素是widthdpr鼠标事件里拿到的clientX是CSS像素坐标。如果我创建canvas时用widthdpr设置了canvas.width却忘了ctx.scale(dpr, dpr)那么所有绘制坐标都会被放大dpr倍鼠标位置自然就对不上。解决方案就是在初始化时调用ctx.scale(dpr, dpr)后续所有绘图坐标都按逻辑像素走鼠标坐标不需要额外换算。这也是为什么我在3.1节反复强调那行scale不能漏。4.3 屏幕缩放后画布变糊或变形浏览器窗口大小改变之后如果不处理resize事件画布内容要么被拉伸变形要么边缘出现锯齿。我在代码里加了resize监听重置canvas的宽高并重新生成粒子。这里有个小坑canvas.width一改画布内容会被清空所以必须在resize后重新初始化粒子否则画布是白的。另一种情况是用户缩放浏览器页面Ctrl /-会导致devicePixelRatio变化。这个情况也可以通过监听resize或者matchMedia事件来处理但普通页面场景下发生率不高我这里就没有额外处理。4.4 视觉不好看的细节第一次跑通时效果只能说“能动”谈不上好看。后来我发现两个细节对质感影响巨大。第一粒子颜色和透明度。纯白粒子配合body的纯黑背景对比强烈但生硬。我把背景改成了深蓝灰色rgba(10, 10, 15, 1)粒子用略带一点透明度的小点同时给粒子画了几个动态渐变色整体立刻柔和很多。如果你想简洁直接调整粒子透明度也行。第二拖影问题。黑洞特效最常见的增强手法是给粒子运动加“拖影”方法是用半透明背景而不是完全不透明的背景覆盖画布。代码里我把背景填充改成rgba(10, 10, 15, 0.3)每次重绘时保留上一帧的部分像素粒子移动时会留下一道浅浅的残影。这个效果非常提升质感原理也很好理解每一帧不是完全清除上一帧而是盖上30%不透明度的深色旧像素慢慢变淡形成视觉残留。拖影长短由背景透明度的值控制值越小残影消失越快值越大拖影越长。建议在0.2到0.4之间调试太长会糊成一团。4.5 内存与性能粒子不要随意创建销毁最初实现吞噬逻辑时我用的是splice(index, 1)删除粒子再用push重新创建一个。看似没问题但粒子被吞噬的频率很高一秒钟可能要创建几百个对象Chrome一开任务管理器浏览器内存稳步上涨。后来改成resetParticle函数只修改已有粒子的字段不创建新对象内存稳定在20MB以下。这个优化思路跟后端避免频繁创建线程、使用线程池如出一辙。前端虽然没有线程池的概念但对象池的思想完全通用。前端开发中凡是高频更新的对象都应该优先考虑复用而不是反复创建销毁这个坑我先替各位踩了。5. 结合后端的感悟学前端不能只学API5.1 从“写接口”到“画页面”的思维转变后端写接口时头脑里天然有一个“请求-处理-响应”的模型。接口是不负责“持续刷新”的你调用它它返回数据一次调用结束。前端动画则不一样它跑在一个永不停歇的循环里每帧都在更新状态、重绘画面。这个思维转换其实很微妙。写后端接口时状态一般保存在数据库里请求来了才去查写前端特效时状态就活在JavaScript对象里每个requestAnimationFrame都在消费它。刚开始写的时候我总想把所有变量都放到“数据库”里后来才发现前端的“内存态”和“持久态”界限很模糊更多时候需要的就是内存态。5.2 前端性能优化里的“后端影子”说实话写这个特效的过程中我觉得最有价值的不是学会了Canvas API而是发现后端性能优化的思路在前端同样成立。粒子对象池对应后端线程池避免频繁创建销毁对象mouse事件只更新状态对应后端消息队列的异步处理requestAnimationFrame限制帧率对应后端限流Canvas批量绘制对应后端的批处理批量插库。这些概念换个场景说法变了底层逻辑完全一样。如果你后端玩得溜学前端是有优势的。不要被一堆新名词吓住什么虚拟DOM、响应式、合成层它们的底层逻辑基本都能在后端的世界里找到对应物。把熟悉的思维模型迁移过去比死记硬背几个API有价值得多。5.3 这个特效还能怎么玩这个黑洞特效本身只是个起点后面可以扩展的方向很多。比如做成多黑洞效果在屏幕上随机生成几个黑洞点粒子会被最近的黑洞吸引视觉上会出现多个漩涡适合做科技感页面的背景。再比如把粒子颜色改成与鼠标距离相关的渐变色距离越近颜色越暖距离越远颜色越冷这就是一个数据可视化的雏形。还可以结合Vue3或React封装成组件在页面加载后挂载配合暗色主题变成一套可复用的前端素材。等粒子系统的底子打牢了再往深处走可以看WebGL、shader、粒子物理碰撞这些硬核方向。到那个时候你就不再是“后端学一点前端”而是真正具备了独立完成全栈页面的能力。我个人在实际操作中的体会是后端学前端重点不在于记住多少API而在于理解前端是如何管理状态、如何驱动渲染、如何取舍性能的。黑洞光标这个特效虽然小但它把事件循环、Canvas绘制、对象池复用、性能优化这些前端核心概念全部串了一遍学透一个特效比刷十篇前端面经都管用。最后分享一个小技巧写完这个特效之后你可以顺手把它嵌进一个Spring Boot项目的静态资源目录通过Controller直接返回一个HTML页面。这样既能用后端的思路测试前端文件部署又能给自己做一个“可演示的全栈项目样板”后面学到Vue或者React的时候再把这个特效迁移过去学习曲线会平滑很多。
返回列表