说句掏心窝的话,前端这行当有时候真的很玄学。你明明只是想简简单单在页面上整点下雨的效果,搜一下教程,满屏都是“三行CSS实现雨滴特效”“十行JS让你的页面下雨”。等你真上手想把它做得好看一点、在手机上也流畅、丢到线上不翻车,才发现这一整条路上全是坑。这篇就把我做这个“Canvas 雨滴特效”的全过程写下来,从方案选型、核心算法、性能调优到排错实录,尽量不留死角。
这篇文章适合谁看?刚接触 Canvas 想做点视觉效果练手的新人,以及在业务里被“加个下雨背景”这种需求折磨过的同学。我会把代码、参数、踩坑过程都展开讲,保证你照着能做出来,也能知道自己改的每一行到底在影响什么。
1. 先说结论:这个特效不难,难在“做完”
1.1 “下雨效果”到底在做什么
我见过很多人在需求评估阶段把下雨特效定性为一个“背景装饰”小任务,排期半天。实际上拆开了看,它至少包含四个独立模块:
- 雨滴的运动模型:速度、方向、长度、密度,这些参数互相耦合。
- 渲染方式:是用 CSS 动画做少量雨丝,还是用 Canvas 每一帧重绘整片雨幕。
- 残影与拖尾效果:这个细节决定了雨是“干净的线条”还是“有质感的雨丝”。
- 设备适配和性能兜底:低端机不能卡,高分屏不能糊,切后台回来不能失控。
这四个模块全打通之后,你得到的才算一个能交付的“效果”,而不是一段 Demo。网上那些“三行 CSS”实现的下雨,本质上是背景图滚动或者几条直线的位移动画,离“能上线”的质感差得很远。
1.2 三条技术路怎么选
我做之前先把方案在脑子里过了一遍,主流就三条路:纯 CSS、Canvas 2D、WebGL/Shader。把它们放在一起对比,结论会非常清晰:
| 方案 | 实现成本 | 性能上限 | 视觉效果 | 适合场景 |
|---|---|---|---|---|
| 纯 CSS 动画 + 渐变背景 | 低 | 中低,线条一多就卡 | 平面化,雨丝感弱 | 装饰性背景,对质感要求不高 |
| Canvas 2D 逐帧绘制 | 中 | 中高,支持数百粒子流畅 | 可控制拖尾、模糊、溅射 | 大部分落地需求,推荐 |
| WebGL 粒子 + Shader | 高 | 极高,上万粒子轻松 | 可做景深、体积光、物理溅射 | 高精度视觉页、移动端大型互动 |
我最后选了 Canvas 2D,原因很朴素:WebGL 的 Shader 调试成本高,需求方往往只是要“下雨”这个感觉,犯不着为它搭一套 WebGL 管线。而 Canvas 2D 只要控制好粒子数量和绘制开销,跑几百颗雨滴完全没问题,效果上限也远高于 CSS。这是投入产出比最高的一套。
2. 基础环境与画布初始化:跨端开局的三个细节
2.1 工程与依赖
这个项目不需要任何重量级依赖。我用的是 Vite 搭了一个原生 JS 模块,结构非常简单:
rain-eff/ ├── index.html ├── src/ │ ├── main.js │ ├── rain.js │ └── style.cssrain.js负责粒子的生成和绘制逻辑,main.js负责启动和 resize 监听,style.css只放页面布局。如果你用的是 Vue 或 React,把rain.js设计成类并导出,在mounted或useEffect里初始化就行,Canvas 操作和框架解耦。
2.2 画布尺寸与 devicePixelRatio:为什么必须处理
这个细节是“玄学”的重灾区。很多新手会让 Canvas 画布宽高等于 CSS 宽高,结果在高分屏上一看,雨丝全是糊的锯齿。
原因很简单:Canvas 的位图宽高默认是 CSS 像素值,而设备物理像素可能是它的两倍甚至三倍。比如一台devicePixelRatio(简称 DPR)为 3 的手机,逻辑宽度 375,物理宽度 1125。你不把 Canvas 内部尺寸放大到物理像素,系统就只能用 375 个位图像素去铺满 1125 个物理像素,每一根雨丝都被强制插值放大,自然糊。
正确的初始化逻辑是:
function setupCanvas(canvas, ctx) { const dpr = window.devicePixelRatio || 1; const { clientWidth, clientHeight } = canvas.parentElement; canvas.width = clientWidth * dpr; canvas.height = clientHeight * dpr; canvas.style.width = clientWidth + 'px'; canvas.style.height = clientHeight + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }setTransform这一步必须做,它会把坐标系缩放回逻辑像素,后续所有绘制代码都能直接按 CSS 像素来写,不用在每个坐标值上乘 DPR。这个写法我复用到好多项目里,至今没出过问题。
2.3 初始化时机:玄学白屏的源头
项目里如果 Canvas 放在一个初始隐藏的容器里,或者容器大小依赖图片加载完成,千万别在DOMContentLoaded时就量尺寸。我踩过一次:某模块的落地球在图片加载完才定位,Canvas 却在页面加载时就初始化了,量出来的高度是 0,画布尺寸变成 0×0,后面怎么画都是白屏。后来改成在容器可见后再调用setupCanvas,问题立刻消失。这也算是我对“前端玄学”的第一个理解:大部分诡异问题,归根结底是一个时序问题。
3. 核心渲染算法:从一颗雨滴到一片雨幕
3.1 雨滴的数学模型
一颗雨滴在屏幕上不是一个点,而是一条线段。它的状态由五个参数决定:起点坐标、长度、速度、斜率偏移、透明度。为了让雨看起来不呆板,所有初始值都要随机,而且要符合视觉直觉。
我用的初始分配大概是这样的:
class RainDrop { constructor(width, height) { this.x = Math.random() * width; this.y = Math.random() * height; this.length = 8 + Math.random() * 14; this.speed = 4 + Math.random() * 6; this.opacity = 0.2 + Math.random() * 0.6; // 雨丝并不是完全垂直,会受一点“风”的影响 this.slope = 0.1 + Math.random() * 0.1; } }这里有几个参数看着随意,其实是试出来的:
- 长度为什么是 8~22?太短了像虚线,太长了像光剑。这个区间在常规屏幕上像雨丝。
- 速度为什么要和长度大致正相关?现实里雨滴越大终端速度越快,视觉上长雨丝移动慢会有明显的“假”感。我在代码里让
length和speed用同一个随机区间,本质上保证长的跑得快,短一点的慢慢飘,这个细节直接决定真实感。
更新逻辑非常轻量:
update(delta) { this.y += this.speed * delta; this.x += this.speed * this.slope * delta; }delta是上一帧到当前帧的秒数,用来保证在不同刷新率屏幕上雨滴速度一致。这一点不接受反驳:你用固定的+= speed在 60Hz 和 144Hz 显示器上的速度观感差 2.4 倍,很多“玄学”的时快时慢就是这么来的。
3.2 残影与拖尾的实现原理
这一节是这个特效的灵魂。普通做法是每一帧先clearRect清屏再画线,画出来的雨是干净但单薄的线条。为了做出雨丝的“连成一线”的感觉,要用一个很经典的技巧:不清除画布,而是覆盖一层半透明黑色。
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight);你可能会问,这不就把雨滴位置对应的像素也一起糊掉了吗?对,但这就是残影出现的原理。上一帧画出的雨线还没有完全消失,新的半透明覆盖层叠加在上面,旧线的对比度被压低一点,逐渐淡出。每一帧都这样积累,就形成了雨丝后面拖着的“尾巴”。
这个 alpha 值的物理意义是“上一帧信息保留比例”。0.05 对应保留 5% 的旧痕迹,尾巴会比较长,有细雨朦胧的感觉。如果你把它调成 0.2,尾巴就短很多,雨变得干净干脆。我做了一个预设表:
| alpha 值 | 视觉效果 | 适用场景 |
|---|---|---|
| 0.02~0.04 | 长拖尾,梦幻朦胧 | 情感向页面、雨夜氛围 |
| 0.05~0.08 | 标准雨丝,有质感 | 默认推荐 |
| 0.1~0.2 | 短干净,雨滴感强 | 写实向、雨势较大 |
3.3 雨滴落地与重新开始
雨滴掉出屏幕之后不能一直白白循环下去,不然粒子总量会不断膨胀。我的处理是:掉出画布底部后直接重置到顶部,并随机横向位置。雨滴降落到地面时的“溅射”效果很有质感,但涉及粒子生命周期管理,这里不做展开,只提一个思路:落地时生成几个极短寿命的斜向小粒子,一两帧后消失。
if (this.y > canvasHeight) { this.y = -this.length - Math.random() * 20; this.x = Math.random() * canvasWidth; }注意这里重置的y是负值,不能是 0。如果从 0 开始,雨滴会先画出屏幕外,再逐渐掉进来,视觉效果就是雨从顶部突然“冒出来”而不是“落下来”,这个负偏移量是很多 Demo 不写但体验差距极大的细节。
完整绘制代码合在一起就是这个样子:
draw(ctx) { const endX = this.x - this.slope * this.length; const endY = this.y - this.length; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(endX, endY); ctx.strokeStyle = `rgba(180, 210, 255, ${this.opacity})`; ctx.lineWidth = 1; ctx.stroke(); }一条雨丝从(x, y)画到(endX, endY),后者是往左上角偏移的,配合斜向运动,雨就整体往右下倾。颜色用淡蓝偏白,是因为雨夜里人眼对冷色调更敏感,也更符合“下雨”的固有印象。
4. 性能与体验调优:把“能跑”变成“好用”
4.1 每帧工作量的控制
Canvas 2D 每帧的消耗主要在strokeStyle字符串解析和路径绘制上。字符串rgba(180, 210, 255, ${this.opacity})每次都要解析一遍,量一大就是不可忽略的开销。优化办法有两个:
第一个,把透明度分层缓存。雨滴透明度离散化为几个档位,比如 0.3、0.5、0.7,每档对应一个预先生成的strokeStyle,绘制时直接取用,不拼字符串。
第二个,控制粒子总量。我做过一个简单的性能曲线测试:
| 粒子数量 | 60Hz PC 帧耗时 | 中端手机帧耗时 | 体感 |
|---|---|---|---|
| 200 | 0.5ms | 2ms | 流畅 |
| 500 | 1.1ms | 4.5ms | 流畅 |
| 1000 | 2.3ms | 9ms | 轻微掉帧 |
| 2000 | 5.5ms | 22ms | 明显卡顿 |
我的建议是默认 300~500 颗,足够撑起一片密雨,性能也稳。如果想在移动端表现更好,可以用屏幕高度做归一化:particleCount = Math.min(500, Math.floor(height * 0.8)),屏幕越大粒子越多,但总量有上限。
4.2 对象池与位图缓存:别再每帧 new 对象
粒子系统最常见的性能杀手就是每帧生成新对象。如果每一帧遍历所有雨滴都new RainDrop(),旧对象又交给垃圾回收,帧率会被 GC 卡到一抽一抽。
我的做法是初始化时一次性生成固定数量的雨滴实例,每帧只更新它们的坐标,不做增删:
constructor(width, height, count) { this.drops = []; for (let i = 0; i < count; i++) { this.drops.push(new RainDrop(width, height)); } }循环里做的就是update和draw,不存在任何动态分配。这一点对移动端尤其重要。
还有一个进阶套路:把雨丝预渲染到一个小 Offscreen Canvas 上。因为雨滴的形状基本固定,只是位置和透明度不同,你可以画几种标准长度的雨丝,每帧用drawImage把它们贴到主画布上。实测能省下一半以上的绘制时间,代价是灵活性下降。这个看需求,我最后没用,因为想保留透明度渐变和长度随机,但如果你要跑上万颗雨滴,这几乎是必经之路。
4.3 后台标签页的调度问题
页面切到后台再回来的时候,requestAnimationFrame在后台会被暂停,回来后如果直接用delta累加坐标,雨滴会瞬间跳走一大截。这种“瞬移”也是最常见的玄学之一。
解决办法很简单:给delta设置一个上限值,比如单帧最多只让雨滴移动 0.05 秒的量。超过的部分直接丢弃,雨滴回到前台的瞬间只是正常延续,不会变成闪电。
const dt = Math.min((now - lastTime) / 1000, 0.05);另外,如果页面完全不可见,可以暂停整个渲染循环,等visibilitychange触发再重新开始,省电也省 CPU。这是我在做移动端项目时养成的好习惯,后台消耗为零,产品那边还很满意。
5. 常见问题与排错实录:那些“玄学”时刻
5.1 白屏与半透明覆盖的死循环
很多人做残影效果时会遇到一个诡异的现象:页面越来越“脏”,最后变成一团黑色。这不是算法错,而是覆盖层的叠加速度超过了雨滴的绘制速度。比如你的 alpha 是 0.5,每帧覆盖一次,帧率按 60fps 算,两秒内同一个像素位置被叠加 120 次,黑度早就累积到看不清雨了。
排查方法也简单:把覆盖层的 alpha 改成 0.05,看两秒内页面是否恢复清爽。如果还是黑,说明你的残影叠加没有经过“衰减”机制,旧画的线永不消失,问题一定出在一个地方——你在覆盖半透明层之前又调用了clearRect,或者残影生成和实际绘制在同一个循环里被重复执行了。对照代码里fillRect和stroke的调用顺序,一查一个准。
5.2 移动端为什么格外卡
移动端 Canvas 表现差,很多时候不是 CPU 不够,而是 GPU 参与度太低。Canvas 2D 默认走 CPU 渲染,碰到大面积渐变背景和透明叠加就会吃力。优化方向有三个:
- 能用整数坐标就不要用浮点。雨滴
x、y是浮点没关系,但绘制前可以Math.floor一下,减少像素插值计算。 - 把
lineWidth固定为 1,不要用太细或太宽的线,半透明细线是 Canvas 2D 相对高效的绘制单元。 - 避免在同一帧内连续切换
shadowBlur。阴影模糊是移动端 Canvas 性能杀手,雨丝不需要阴影,把阴影效果去掉。
按这三步优化之后,我在一个千元机上实测,500 粒子稳定在 50fps 上下,视觉上已经算流畅。
5.3 上线后效果“变了”
这个坑尤其让人崩溃:本地开发一切正常,打包上线后发现雨滴颜色不对,或者 Canvas 一片空白。我在某个项目里遇到过的是 Canvas 尺寸读取容器高度时,上线后容器高度依赖异步数据渲染,初始化时拿到的是 0。本地开发时数据加载快,肉眼感觉不到时序差,线上服务器慢了一拍就暴露了。
另一个高频问题是生产环境对应的静态资源路径。你如果用了纹理图片来渲染雨滴,图片路径必须处理成可访问的地址,否则drawImage画不出东西,而报错往往还不会打断主流程,最后表现就是天气晴朗。这个排查思路分享给遇到“上线就雨停”的朋友:先看 Console 有没有资源请求 404,再看容器尺寸是不是 0,最后再怀疑算法。
5.4 常见问题速查
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 雨丝模糊 | 未处理 DPR | 按 2.2 的初始化放大像素 |
| 雨丝带残影但页面变黑 | 覆盖层 alpha 过大 | alpha 下调到 0.05 左右 |
| 切后台回来雨滴瞬移 | delta 累计过大 | 给 delta 设置上限 |
| 移动端掉帧 | 每帧拼接字符串 / 阴影 | 缓存 strokeStyle,去掉 shadowBlur |
| 容器隐藏时初始化白屏 | 尺寸为 0 | 容器可见后再初始化 |
| 上线后雨停 | 资源 404 / 容器高度 0 | 检查网络请求与初始化时序 |
最后分享一点实际感受
把这一连串做完之后,我再听到“前端很玄学”这种话,其实不太认同了。大部分所谓玄学,背后都有明确的技术原因,只是它藏在 DPR、时序、渲染上下文这些不常被人提起的角落里。下雨效果算是我推荐给前端新手的第一个 Canvas 项目,因为它足够小,但又能逼你处理好移动端适配、性能优化和状态管理这几件大事。如果你做完后还想继续扩展,我建议往两个方向试:一个是让雨滴落地溅出水花,另一个是把颜色从冷色调换成暖色调做“黄昏雨”的效果。前者练粒子生命周期,后者练色彩审美,都是实打实涨经验的事。