三伏天里坐在电脑前,风扇吹出来的都是热风,屏幕亮着,人却是木的。这种时候我一般不会硬写业务代码,而是给自己找点小活干——做一段“代码雨”。我说的不是经典那种满屏黑绿、压迫感极强的数字雨,而是一段看起来就凉快的版本:冷青色的字符往下淌,底部有淡淡的水汽,偶尔落下一两滴水,屏幕像是蒙了一层刚从冰箱里拿出来的玻璃。这篇文章就把我这几轮折腾的完整过程摊开讲:核心循环怎么搭、颜色和透明度怎么调到“清凉”而不是“阴森”、性能边界在哪里、移动端和高分屏怎么不糊,以及怎么把它封装成一个能随手丢到页面里的背景组件。适合有一点前端基础、想练手 Canvas 动画,或者单纯想在夏天给自己做个降温小玩具的读者。全程只用原生 Canvas 2D,不引第三方库,代码可以直接复制跑。
1. 先想清楚“清凉”这两个字要落在哪几个视觉层
很多人一听代码雨,脑子里第一反应就是黑底绿字、字符刷刷往下掉,做完之后发现气氛完全不对——那是赛博朋克式的压迫感,和“清凉”是两码事。我在第一版就栽在这上面:把经典的实现原封不动跑起来,冷却效果为零,甚至因为黑色太重,看着更闷。所以动手之前必须先把“清凉”这个形容词拆成可执行的视觉参数,否则后面全靠感觉改,改到天亮也改不出来。
1.1 从经典数字雨到水幕感,三个必须改掉的默认设定
第一个要改的是底色。经典版本用的是接近纯黑的背景,这会把整个画面的温度感拉到最低。我换成深蓝到墨青的渐变,顶部偏蓝黑、底部偏青绿,模拟的是深水区的光衰减。这个改动看起来只是换了个色值,实际观感差异极大——同样是字符下落,纯黑底像代码洪水,青蓝渐变底像隔着水面看东西。
第二个要改的是字符的对比度。原版字符是高饱和的荧光绿,亮度拉满,视觉上非常“刺”。清凉感需要的是低对比、柔和高光,所以我把字符主色调压到薄荷青和浅湖蓝之间,头部最亮的字符也只是接近白色的淡青,而不是纯白。这里有个反直觉的经验:字符越亮,画面越热。我一开始觉得亮一点更醒目,结果整屏像 LED 灯牌,后来把亮度降了三成,凉意反而出来了。
第三个要改的是拖尾的长度。经典数字雨的拖尾极短,字符像一颗颗独立下坠的点,节奏很急。水幕感需要更长的尾巴,让字符之间有连绵的过渡,看起来像水流而不是雨点。拖尾长度由每帧覆盖的透明度决定,这个参数后面会详细调。
1.2 把需求翻译成一张可执行的参数表
我习惯在写代码前先把参数列成表,避免一边写一边拍脑袋。下面这张表是我最终稳定下来的一组值,不同屏幕尺寸下会微调,但量级基本在这个范围:
| 参数 | 取值 | 作用与调整方向 |
|---|---|---|
| 字号 | 16 到 20 px | 越大列数越少,越省性能,字符也更“重” |
| 行高比 | 1.1 到 1.25 | 控制字符纵向疏密,太密会有压迫感 |
| 拖尾透明度 | 0.06 到 0.12 | 数值越小尾巴越长,低于 0.05 会糊成一片 |
| 下落速度 | 0.3 到 1.2 行/帧 | 差异要大,速度一致会显得机械 |
| 列数上限 | 40 到 64 | 移动端必须限制,否则直接掉帧 |
| 字符集 | 15 到 25 个 | 太杂会乱,太少会重复感强 |
这张表里最容易被忽略的是速度的离散度。如果所有列的下落速度都在 0.8 左右,画面上会形成整齐的横带,看起来像纸糊的帘子。我的做法是给每一列分配一个在区间内均匀分布的随机速度,并且保留少量“慢列”作为视觉锚点,让画面有前后层次。实测下来,速度区间跨度保持在 3 倍左右最舒服,比如 0.3 到 0.9。
还有一点,字符集的选择直接影响主题感。我没有用常见的片假名,而是挑了一组和夏日降温有关的汉字加数字符号,比如“夏、雨、清、风、冰、泉、凉、露、荷”这些,再混入几个运算符。这样每一列落下来的字符在语义上也是凉快的,虽然观众不会逐个去读,但整体气质会不一样。这是个小细节,做的时候花不了几分钟,效果比单纯调色要明显。
2. Canvas 2D 打底:字符雨主循环怎么写才不浪费性能
技术选型这块我几乎没犹豫。CSS 动画做不出字符随机变化的效果,WebGL 对这个量级的绘制属于杀鸡用牛刀,还要处理着色器编译和上下文丢失,维护成本高。Canvas 2D 的fillText每帧调用几十到一百多次,在主流设备上完全扛得住,代码也短,后期想改成组件或者嵌进任何页面都方便。真正需要花心思的不是“用什么画”,而是“怎么少画”。
2.1 列模型:每一列只存三个数就够了
很多人写代码雨会维护一个二维数组,每个字符一个对象,存字符内容、位置、透明度、速度。这么写看起来很规整,但每帧要遍历几百上千个对象,还要频繁创建和回收,GC 压力很大。我的做法是只维护列,不维护字符——每一列只记录当前下落到第几行、这一列的速度、以及这一列下一次要不要重置。字符内容是每帧现随机取的,不存储。
这样做的代价是同一个字符不会在同一个位置停留,看起来会有轻微的“闪烁感”。但因为有拖尾覆盖,上一帧的字符残影还在,视觉上反而是流动的水纹效果,比固定字符更自然。如果你确实需要字符稳定,可以给每一列存一个字符数组循环取用,内存占用也不大,这是可选的取舍。
列状态的数据结构大概是这样:
// 每一列:y 是当前行号(浮点),speed 是每帧下落行数 columns.push({ y: Math.random() * -20, // 负值让初始位置错开,避免同时从顶部冒出 speed: 0.3 + Math.random() * 0.9 });初始 y 给负值是关键细节。如果所有列都从 0 开始,第一秒会看到整齐的一条横线往下推,非常出戏。让每列的初始位置随机散布在屏幕上方负二十行到正若干行之间,第一帧就是满屏的,观感立刻正常。
2.2 拖尾不是擦除:半透明覆盖的两种写法
拖尾效果的本质是“不把上一帧擦干净”。有两种常见写法,我在项目里都试过:
第一种是每帧用半透明的背景色铺满整个画布,fillStyle = 'rgba(4, 18, 27, 0.09)',然后fillRect(0, 0, w, h)。旧内容被一层薄薄的新颜色盖住,亮度逐渐衰减,形成尾巴。写法最简单,性能最好,缺点是背景必须是纯色,用渐变色会露馅——因为每帧盖的都是同一个颜色,渐变会被逐渐抹平。
第二种是先把画布内容整体降低透明度,再画新字符。用globalCompositeOperation = 'destination-out'配一个低透明度的填充,可以把旧内容“擦淡”而不改变色相。这种写法能保留背景渐变,但合成模式在某些低端设备上开销更高,而且要额外处理背景层的重绘。
我最终选的是混合方案:把渐变背景画在一个独立的底层 canvas 上(或者直接用 CSS 背景图),上层 canvas 用透明背景加半透明黑色覆盖做拖尾。这样既保住了渐变,又只有一层绘制开销。实现时给上层 canvas 的 context 开alpha: true,每帧先fillStyle = 'rgba(3, 14, 20, 0.09)'全屏填充,再画字符。
这里有个坑必须提醒:拖尾透明度不能一上来就设得很低。我第一次直接用了 0.03,想着尾巴越长越飘逸,结果跑起来整屏是一团青灰色的糊,字符完全看不清,像屏幕进了水。原因是衰减太慢,几十帧的残影叠在一起,亮度累积到饱和。后来调到 0.08 到 0.10 之间,尾巴长度刚好在 15 到 25 帧左右,既有流动感又不糊。
2.3 主循环的完整骨架与时间步长处理
主循环用requestAnimationFrame驱动,这一点没什么争议。但直接把每帧当成固定步长是会出问题的——设备刷新率不一样,120Hz 屏上字符速度会翻倍;切到后台再切回来,浏览器会补偿时间戳,字符可能瞬间跳一大截。稳妥的做法是记录上一帧时间,算出 delta,再归一化到一个基准值。
let lastTime = 0; const BASE_FRAME = 1000 / 60; // 以 60fps 为基准 function loop(now) { if (!lastTime) lastTime = now; let delta = now - lastTime; lastTime = now; // 限制单帧最大值,防止切后台回来时跳帧 if (delta > 100) delta = BASE_FRAME; const step = delta / BASE_FRAME; draw(step); requestAnimationFrame(loop); }delta上限这一步看着不起眼,但非常必要。我测试的时候切到别的标签页待了两分钟再回来,没有这行限制的话,字符会像被弹射一样直接冲出屏幕,然后所有列同时重置,画面空白一两秒才恢复。加上限之后,回来只是正常继续。
完整的绘制函数大概长这样:
function draw(step) { // 1. 拖尾覆盖 ctx.fillStyle = 'rgba(3, 14, 20, 0.09)'; ctx.fillRect(0, 0, width, height); // 2. 设置字体,只设一次 ctx.font = `${CONFIG.fontSize}px "PingFang SC", monospace`; ctx.textBaseline = 'top'; // 3. 逐列绘制 for (let i = 0; i < columns.length; i++) { const col = columns[i]; const x = i * CONFIG.fontSize; const y = Math.floor(col.y) * CONFIG.lineHeight; // 头部字符用亮色 const ch = CONFIG.chars[(Math.random() * CONFIG.chars.length) | 0]; ctx.fillStyle = CONFIG.headColor; ctx.fillText(ch, x, y); // 推进位置 col.y += col.speed * step; // 出屏后重置,概率控制避免同时清空 if (y > height && Math.random() > 0.975) { col.y = -Math.random() * 10; col.speed = 0.3 + Math.random() * 0.9; } } }ctx.font这一行放在循环外面是有意为之。字体字符串每次赋值都会触发解析,放在循环里一百多次赋值,实测在低端机上能吃掉两三帧。同样,textBaseline这种不会变的属性也只在初始化时设一次。
重置条件里的Math.random() > 0.975是个小技巧。如果字符一到底部就立刻重置,所有列会在某个时刻集中回到顶部,画面会出现周期性的“空窗”。用概率控制,让列在底部多待几帧再走,重置时间就分散开了,画面始终是满的。
3. 把颜色、透明度和水汽感调出来
代码能跑只是及格线,真正决定这段动画好不好看的是色彩和层次的调配。我在这一块来回改了七八版,中间推翻过两次方向,最后总结出一些比较确定的规律,可以直接照着抄。
3.1 冷色梯队怎么排,才不会变成电子屏
清凉感的色彩核心是低饱和的冷色加高明度差。具体来说,背景用深蓝到墨青的纵向渐变,顶部接近#04121b,底部到#062b33,模拟水越深越暗、越浅越透的感觉。字符主色选薄荷青系,比如rgba(126, 231, 212, 0.85),头部字符用更亮的rgba(220, 255, 250, 0.95)。
这里有个容易翻车的地方:很多人为了“清爽”会直接用高饱和的青蓝色,比如纯#00FFFF。这种颜色在深色背景上非常刺眼,看三十秒眼睛就累,而且会有很强的电子屏感,像老式显示器的测试画面。正确做法是把饱和度压下来,让颜色偏灰一点,同时靠明度差异来制造层次,而不是靠饱和度。
我试过的一个对照实验挺说明问题:同一套字符和速度,只把主色从高饱和青换成加了灰的薄荷青,观感从“霓虹灯”变成了“水下光影”。这个改动只涉及三个色值,效果却比调速度、调密度都明显。
另外,字符的透明度不要全局统一。我的做法是给每一列一个基础透明度,在 0.55 到 0.9 之间随机,画的时候乘上去。这样画面上会有明暗不同的列,像水层深浅不一,立体感立刻就出来了。如果所有列透明度一样,画面会非常平,像贴了张壁纸。
3.2 涟漪和偶发水滴:低频事件要有成本意识
只靠字符下落,画面看久了还是会单调。我加了两个低频元素:水面涟漪和偶尔坠落的水滴。
涟漪的实现是在随机位置画一个扩散的圆环,半径随时间增大,透明度随时间衰减。关键是控制频率——我设定每 1.5 到 3 秒随机触发一次,最多同时存在三个。每次触发时记录起始时间和坐标,每帧遍历更新。
const ripples = []; function spawnRipple() { if (ripples.length >= 3) return; ripples.push({ x: Math.random() * width, y: height * (0.75 + Math.random() * 0.2), r: 2, life: 1 }); }绘制时用arc描边,lineWidth设成 1.5 左右,颜色用极淡的青白,透明度跟着life走。这里的坑在于不能用shadowBlur来做发光。我一开始为了效果,给涟漪和字符都加了模糊阴影,桌面端看着很美,一到手机上帧率直接从 60 掉到 20 出头。shadowBlur的开销和模糊半径强相关,是非常昂贵的操作。后来全部去掉,改用“多画一圈浅色描边”模拟柔边,性能立刻恢复。
水滴的做法更简单,就是一条短的竖直线段,快速下落,落到底部时触发一个涟漪。它不需要物理模拟,只是视觉点缀,数量控制在同屏两三个。这类低频元素的价值在于打破节奏——满屏字符匀速下落时,突然一个小涟漪出现,视线会被吸引一下,画面就有了呼吸感。但频率一定要低,我试过每 0.3 秒触发,结果满屏圈圈,像下雨天的池塘,反而把字符雨盖掉了。
4. 掉帧、模糊、错位:联调阶段最容易撞上的四类问题
代码跑通不代表能交付。真正的麻烦都在不同设备、不同窗口尺寸、不同使用场景下冒出来。这一节我按自己踩坑的顺序,把排查过程完整写一遍,遇到类似问题可以照着这个思路走。
4.1 从现象反推到具体那一行的排查链路
第一个现象是画面发灰、字符看不清。第一反应是颜色不对,换了三套配色都没用。后来把拖尾覆盖的透明度打印出来才发现,问题出在背景层:我把渐变背景画在了同一个 canvas 上,又每帧用半透明色覆盖,渐变被反复叠加后越来越亮,把字符的对比度吃掉了。解决办法是把背景拆到 CSS 层,canvas 只负责字符和拖尾。诊断这类问题的通用方法是逐层关闭绘制——先把字符绘制注释掉,只看背景,再把拖尾注释掉,看是不是覆盖层的问题,两层排查下来原因通常很明确。
第二个现象是整屏闪烁。这个比较隐蔽,原因是每帧调用了clearRect之后又用fillRect铺底,两次操作之间有一帧的空档,在部分浏览器上会被合成器捕捉到。修复方法很简单:去掉clearRect,直接用半透明fillRect覆盖,因为半透明覆盖本身就承担了擦除和拖尾两个功能。
第三个现象是列错位,画面撕裂。这个是我在改窗口大小的时候发现的。resize事件触发时我只更新了 canvas 的宽高,但没有重建列数组,导致原来的列数和新宽度对不上,多出来的区域空白,少的部分溢出。正确做法是 resize 时防抖 200 毫秒,然后重新计算列数、重建列数组、重新生成背景渐变。
第四个现象是切换标签页回来速度异常。前面提到的 delta 上限就是解决这个的,但在加上限之前我还试过另一个方案:监听visibilitychange,页面隐藏时直接取消动画帧,可见时重置lastTime。这个方案更彻底,也顺带省了后台的 CPU 占用,我最后两个都保留着,双保险。
4.2 高分屏和移动端的适配取舍
高分屏的问题很典型:直接在 CSS 尺寸下绘制,字符边缘会明显发虚。原因是 canvas 的物理像素和 CSS 像素是一比一,而屏幕实际是两倍甚至三倍密度。标准解法是按devicePixelRatio放大 canvas 的宽高,再用ctx.scale把坐标系缩回逻辑尺寸。
function resize() { const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(rect.width * dpr); canvas.height = Math.floor(rect.height * dpr); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); width = rect.width; height = rect.height; // ...重新计算列数和渐变 }注意这里对 dpr 做了Math.min(..., 2)的限制。三倍屏的设备上如果按 3 倍绘制,像素量是 1 倍的九倍,填充率压力非常大。我实测某款高分屏手机上,dpr 不限制时帧率只有 30 出头,限制到 2 之后回到 55 以上,而肉眼几乎看不出清晰度差异。这是一个典型的“用一点点画质换大量性能”的取舍,值得做。
移动端还有两个必须处理的问题。第一是列数。手机竖屏宽度大概 375 到 430 逻辑像素,按 18px 字号算只有二十来列,看起来会很稀疏。我的处理是把移动端字号降到 14,列数控制在 28 到 36 之间,密度就够看了。第二是方向切换。横竖屏切换会触发 resize,如果防抖处理不好,会出现画面卡住或者列错乱。我加了 250 毫秒防抖,并且在重建时保留原有列的速度分布,避免切换后节奏突变。
5. 做成可复用组件:参数外置与降级方案
一个人玩和给别人用是两回事。前者怎么方便怎么来,后者要考虑别人在什么环境下用、设备性能如何、会不会干扰页面其他内容。我把它整理成了一个可配置的工厂函数,参数外置,默认值就是前面调好的那套。
5.1 配置对象的设计与默认值
配置项我尽量控制在必要范围内,太多了调用方也记不住。核心是这几组:尺寸相关的字号、行高比、列数上限;视觉相关的背景渐变两端色、字符主色、头部色、拖尾透明度;行为相关的速度区间、字符集、是否启用涟漪。
const defaults = { fontSize: 18, lineHeight: 20, trailAlpha: 0.09, speedRange: [0.3, 1.2], chars: '夏雨清风冰泉凉露荷蝉0123456789', headColor: 'rgba(220, 255, 250, 0.95)', bodyColor: 'rgba(126, 231, 212, 0.8)', maxColumns: 64, enableRipple: true };有两个设计决定值得说明。一是颜色不用数组而是单个字符串,因为我不做每列多色渐变,那样代码复杂度上升不少,收益不明显。二是列数上限放在配置里,让调用方能根据容器宽度自行压缩,移动端传 36,桌面端传 64,组件内部再和实际算出来的列数取最小值。
封装的时候还要处理销毁。单页应用里组件会被反复挂载卸载,如果只启动了requestAnimationFrame却不管,切换页面后循环还在跑,内存和 CPU 都白烧。我的做法是暴露一个destroy方法,内部取消动画帧、移除 resize 和 visibilitychange 监听、清空列数组,简单几行但必须写。
5.2 降级策略:性能不足和用户偏好都要照顾
有两类用户需要特殊对待。第一类是低性能设备。判断方式可以看帧率——启动后采样前 60 帧的平均间隔,如果连续多次超过 25 毫秒,就自动降级:字号加大、列数减半、关掉涟漪、去掉水滴。这个策略比按设备型号判断更可靠,因为它测量的是真实表现。
第二类是开启了“减少动态效果”偏好的用户。这个系统级设置在很多环境里都会被忽略,但对容易晕动症的人群很重要。检测方式是一行媒体查询:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;命中时不启动动画,改为绘制一帧静态的字符雨画面。静态版本也很好看,字符错落分布、颜色层次都在,只是不动。这个处理花不了多少工夫,但体现了对使用者的基本尊重,我在几个项目里加上之后,收到的反馈都是正面的。
还有一个细节是可访问性。canvas 对屏幕阅读器是不透明的,如果这段动画是页面的主要背景,最好给它加aria-hidden="true",避免辅助技术读到无意义的图形。如果页面上还有其他文字内容,把 canvas 设为纯装饰层,pointer-events: none,不要拦截点击。
最后再分享一个我在实际调试中养成的小习惯:给动画加一个隐藏的帧率显示,用performance.now算最近三十帧的平均值,只在开发时打开。很多时候“感觉卡”和“真的卡”是两回事,有了数字才能判断到底是渲染问题还是心理作用。我在一次改色之后觉得画面变卡了,打开帧率一看稳定在 59,其实只是深色让视觉节奏变慢产生的错觉,差点白改一轮代码。