☰
用Canvas手写雪花特效:从基础版到增强版的完整实践
2026/10/22 18:56:43 网站建设 项目流程

1. 先接住这个需求:雪花特效到底要做什么

年末给异地的好友准备点小惊喜,翻来覆去不知道送什么,灵机一动写了个网页:打开就是满屏飘雪,衬着深蓝夜色,几秒钟后浮现一句想说的话。对方第二天专门发消息来问代码是怎么写的,这就是我入坑雪花特效的起因。

说白了,这类项目的本质就是"用尽可能轻量的一页HTML,做出一个有氛围感、可交互的动态效果",核心载体就是Canvas画布加JavaScript动画循环。它不牵扯后端,不需要构建工具,不需要框架依赖,打开记事本就能写,双击HTML文件就能跑,发给任何人直接看。技术门槛大概就是知道一点JS语法、搞清楚Canvas的基本API,非常适合有一定前端基础或者正在学JS的人练手。

这个项目能做的事比表面看到的要多:它可以做成告白页、春节问候页、生日祝福页,也可以用来研究动画性能优化和随机数设计。麻雀虽小但五脏俱全,里面涉及Canvas绘制、requestAnimationFrame帧循环、多层渲染模拟景深、鼠标交互、设备像素比适配,每一条都是实际开发中每天都会碰到的知识点。把这几十行代码吃透了,再回头看很多动效框架的逻辑都会通透很多。

2. 方案选型:为什么用Canvas而不是堆DOM节点

2.1 两种实现路线的差别

做雪花特效,直觉上马上能想到两条路:一是生成几十个DOM元素,每个元素放一个雪花图标然后改CSS样式让它飘;二是开一块Canvas画布,用JS逐帧把雪花画上去。两条路都能出效果,但在处理"大量运动元素"这个场景时,差距非常明显。

假设你要200片雪花同时飘。如果用DOM方案,每一个雪花都是一个独立的div节点,浏览器要为每个节点维护布局和样式,每一帧都要触发一次重排或重绘。雪花越多,DOM节点的增删、位置修改造成的性能开销就越线性往上走,手机上很容易掉到30帧以下,肉眼可见地卡。而Canvas方案是在一块画布上统一绘制,200片雪花就是200次绘图指令,没有节点树和样式计算的开销,同样规模的动画能轻松跑到60帧。

另一个容易忽略的点是视觉一致性。DOM方案里每个雪花要么用CSS画圆、要么用图片或emoji,控制半径、透明度和旋转都绕不开CSS属性的组合,逻辑分散在样式表和JS里。Canvas里所有属性都在一个draw调用里完成,半径、透明度、颜色、坐标,一目了然。改起来方便,排查问题也方便。

2.2 帧循环的核心:requestAnimationFrame

动画背后还有一个关键选择:用setInterval还是requestAnimationFrame来做循环。早期的网页动画教程喜欢用setInterval(fn, 16),约等于60帧,但这里有个坑——setInterval完全不关心浏览器当前有没有能力执行、标签页是否可见、屏幕刷新率是多少,它只会死板地按固定间隔调用。

requestAnimationFrame就不一样,浏览器会在下一次重绘之前调用你传入的回调函数,天然跟屏幕刷新率同步。标签页切到后台时会自动暂停,不会白白消耗CPU和电量;屏幕刷新率高的设备(比如120Hz的旗舰手机)能自动获得更流畅的帧率。实测下来,同样的雪花动画,requestAnimationFrame的主线程耗时和功耗表现都比setInterval好不少。

还有个细节:递归调用requestAnimationFrame比setInterval更适合做逐帧动画,因为每次回调都带上当前时间戳,方便做基于时间的运动控制。比如后面要做的阵风效果,就是根据performance.now()动态计算风力系数,用setInterval就很难做到这种平滑变化。

3. 从零手写:带完整源码的基础版

3.1 页面骨架与样式

一个能在浏览器里跑起来的雪花页面,HTML结构可以极其简单,一个canvas元素就是全部的舞台。画布设置为全屏,背景用渐变模拟冬夜天空,深蓝色到藏青色,视觉上先定下基调。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>冬日雪花</title> <style> * { margin: 0; padding: 0; } body { overflow: hidden; background: linear-gradient(to bottom, #0a1230, #1a2a4a); } canvas { display: block; } #message { position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%); color: #e8f0ff; font-size: 26px; letter-spacing: 4px; text-align: center; text-shadow: 0 0 12px rgba(255, 255, 255, 0.4); opacity: 0; transition: opacity 3s ease; z-index: 10; pointer-events: none; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; } </style> </head> <body> <div id="message">整个城市的雪,都在替我说想你</div> <canvas id="snowCanvas"></canvas> <script> // ... 下文的JS代码 </script> </body> </html>

注意几个细节。body设置overflow: hidden是为了防止页面出现滚动条,canvas本身是display: block,能避免行内元素默认带来的几像素空白。消息层用了position: fixed居中,加pointer-events: none让鼠标事件穿透,这样它不会挡到后面的Canvas交互。

3.2 雪花的生成逻辑

每一片雪花本质就是一个包含若干属性的小对象:横坐标、纵坐标、半径、下落速度、透明度、摇摆相位和摇摆幅度。生成新雪花时,用Math.random()在这些属性的取值范围内做随机,这一步决定了整体画面的多样性。

function random(min, max) { return Math.random() * (max - min) + min; } function createSnowflake() { return { x: random(0, width), y: random(-height, -20), radius: random(1, 4), speed: random(0.8, 2.5), opacity: random(0.4, 1), angle: random(0, Math.PI * 2), swaySpeed: random(0.02, 0.05), swayAmount: random(2, 6) }; }

这里有一个我踩过坑的地方:初始y坐标一定要有负值范围。如果你把所有雪花都初始化为y=0,打开页面的一瞬间会出现所有雪花从同一条水平线整齐落下的"集体入场"效果,非常出戏。让初始纵坐标均匀分布在屏幕顶部之外,页面一打开就已经有雪花散布在画面的各个高度,观感自然得多。

半径的随机范围也直接影响质感。雪花全部用同一大小会显得非常塑料,但半径差距拉得太大又会让画面显得杂乱。实测下来半径在1到4之间比较稳妥,细节丰富又不晃眼。透明度同理,不是所有雪花都要纯白,有的明亮、有的朦胧,层次感就出来了。

3.3 动画主循环

动画逻辑分三步:清空画布、更新每片雪花的坐标、绘制。用一个requestAnimationFrame递归调用自己,形成永不停止的帧循环。

const canvas = document.getElementById('snowCanvas'); const ctx = canvas.getContext('2d'); let width = window.innerWidth; let height = window.innerHeight; canvas.width = width; canvas.height = height; const snowflakes = []; const SNOW_COUNT = 180; for (let i = 0; i < SNOW_COUNT; i++) { snowflakes.push(createSnowflake()); } function draw() { ctx.clearRect(0, 0, width, height); for (let i = 0; i < snowflakes.length; i++) { const flake = snowflakes[i]; flake.y += flake.speed; flake.angle += flake.swaySpeed; flake.x += Math.sin(flake.angle) * flake.swayAmount * 0.4; if (flake.y > height + 20) { snowflakes[i] = createSnowflake(); continue; } ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, ' + flake.opacity + ')'; ctx.fill(); } requestAnimationFrame(draw); } draw();

这段代码里有三个核心概念值得展开说。

第一是摇摆运动。直接让雪花直线下落看起来像子弹,加一点水平抖动才像雪花。最常见的做法就是用Math.sin(),把每片雪花的angle做一个独立的递增相位,再乘上各自的摆动幅度,这样每片雪花都有微妙的差异,整体看起来就是自然的风中飘雪。用正弦函数的好处是运动轨迹平滑,不会出现随机数抖动那种"抽搐感"。

第二是边界回收。雪花落到屏幕底部以下20像素就重置,这个20像素的缓冲很重要。如果刚好在屏幕边缘就重置,你会看到雪花在底部被"瞬间凭空消失",有了这段缓冲,雪花是完整飞出屏幕后才被回收重生的,视觉上无缝衔接。

第三是clearRect的性能意义。有经验的开发都知道,频繁创建Canvas对象是大忌,但这里另一个值得注意的点是清屏的范围。如果屏幕是1920x1080,clearRect只清理雪花实际出现的区域,和全画布清理在性能上没有肉眼可见的差别。因为雪花分布在整个屏幕,所以直接清除整块画布就好,不需要做局部脏矩形这种过度优化。

4. 进阶玩法:给画面加上"灵魂"

4.1 远近分层:模拟景深

基础版能跑,但画面比较平。真实世界里的雪是有远近景深的:近处的雪花大、下落快、透明度高,远处的雪花小、下落慢、显得朦胧。这种深度感只要把雪花拆成三层就能模拟出来。

const layers = [ { count: 60, speed: [0.4, 0.9], radius: [0.8, 1.8], opacity: [0.3, 0.6] }, { count: 90, speed: [0.8, 1.6], radius: [1.5, 3.0], opacity: [0.5, 0.8] }, { count: 30, speed: [1.4, 2.6], radius: [3.0, 5.0], opacity: [0.8, 1.0] } ];

每一层独立生成雪花、独立维护属性范围,绘制时按从远到近的顺序画:远处的先画,近处的后画,近处的雪花就会遮挡远处的,形成自然的遮挡关系。这个效果对性能的影响可以忽略不计,因为每层只是参数不同的同一套逻辑,但在视觉上完全是两个档次。

我做过对比测试,同样180片雪花,单层均匀参数和三层分层参数,后者的画面立体感明显更好。你要送人的话,这个细节一定不要省。默认参数里引入一个小技巧:越靠前的层,雪花数量越少、个体越大但间距大,这样才有"近大远小"的透视感,而不是所有大雪花糊在一起。

4.2 鼠标交互:雪花也要怕你

互动性是"程序员的浪漫"里最容易加分的点。最简单的交互是让雪花避开鼠标,光标扫过的地方雪花被"推开",仿佛你在拨开雪幕。实现思路也不复杂,在帧循环里对鼠标附近的雪花施加一个排斥力。

let mouseX = null; let mouseY = null; canvas.addEventListener('mousemove', function(e) { mouseX = e.clientX; mouseY = e.clientY; }); // 在每片雪花的更新逻辑里 if (mouseX !== null) { const dx = flake.x - mouseX; const dy = flake.y - mouseY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 80) { const force = (80 - dist) / 80 * 2; flake.x += dx / dist * force; flake.y += dy / dist * force; } }

这个排斥力的计算逻辑值得记一下:先算鼠标到雪花的向量,距离小于80像素时触发排斥,靠得越近排斥越强。dx除以dist是做向量归一化,防止距离越远反而推力越大的反直觉效果。在手机上对应的事件是touchmove,把监听器换成指针事件Pointer Events可以一套代码兼容鼠标和触摸屏。

还有一个进阶变体:让雪花自动聚拢到鼠标位置,像是把雪花"吸"过来,效果也很惊艳。只需要把上面的dx、dy方向取反就行。两种交互可以做成配置项,甚至简单点做一个"吹动"效果——按住鼠标拖动,周围的雪花沿着你拖动的方向加速飞出去。这个适合做后续二创,代码思路完全一样,区别只在受力方向。

4.3 心动文案与渐显效果

浪漫页面的点睛之笔是文案。雪花飘一会儿后,一句想说的话缓缓浮现,时间节奏很重要。我建议在页面打开后3到5秒开始淡入,太早出现没有铺垫感,太晚会让人以为页面没做完整。

实现方式极度简单,CSS里设置透明度渐变,JS里加一个定时器给元素追加类名。

setTimeout(function() { document.getElementById('message').classList.add('show'); }, 4000);
#message { opacity: 0; transition: opacity 3s ease; } #message.show { opacity: 1; }

实测下来,4秒后开始渐显、花3秒完全浮现,这个节奏是观众反馈里最有氛围感的。文案本身不宜太长,一两句最好,字号可以稍微调大,配合亮色和微弱的光晕阴影,在深蓝背景上非常醒目。字体建议用系统自带的中文字体栈,你永远不知道对方手机上装了哪些字体,使用系统字体栈是最稳妥的。

如果你还有更大的野心,可以做"雪花落出图案"的效果:预先在离屏Canvas里画一段文字或一个爱心,逐帧读取像素数据,把有内容的区域当作"落点",雪花落到这些区域就停在原地。这个方案能做出雪花堆积成图案的效果,非常浪漫,但代码量会涨不少,需要提前把像素判断的算法写好,我后续在二创部分会细说思路。

5. 完整增强版源码:可直接复制运行

下面这个版本集成了前面提到的所有优化:设备像素比适配、三层景深、阵风效果、鼠标交互、文案渐显。复制保存成snow.html,双击打开就能用。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>冬日雪花</title> <style> * { margin: 0; padding: 0; } body { overflow: hidden; background: linear-gradient(to bottom, #0a1230, #1a2a4a); } canvas { display: block; } #message { position: fixed; top: 40%; left: 50%; transform: translate(-50%, -50%); color: #e8f0ff; font-size: 28px; letter-spacing: 4px; text-align: center; text-shadow: 0 0 16px rgba(255, 255, 255, 0.5); opacity: 0; transition: opacity 3s ease; z-index: 10; pointer-events: none; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; white-space: nowrap; } #message.show { opacity: 1; } </style> </head> <body> <div id="message">整个城市的雪,都在替我说想你</div> <canvas id="snowCanvas"></canvas> <script> (function() { const canvas = document.getElementById('snowCanvas'); 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 layers = [ { count: 60, speedRange: [0.4, 0.9], radiusRange: [0.8, 1.8], opacityRange: [0.3, 0.6] }, { count: 100, speedRange: [0.8, 1.6], radiusRange: [1.5, 3.0], opacityRange: [0.5, 0.8] }, { count: 40, speedRange: [1.4, 2.6], radiusRange: [3.0, 5.0], opacityRange: [0.8, 1.0] } ]; function random(min, max) { return Math.random() * (max - min) + min; } function createSnowflake(layer) { return { x: random(-20, width + 20), y: random(-height, -10), radius: random(layer.radiusRange[0], layer.radiusRange[1]), speed: random(layer.speedRange[0], layer.speedRange[1]), opacity: random(layer.opacityRange[0], layer.opacityRange[1]), angle: random(0, Math.PI * 2), swaySpeed: random(0.02, 0.05), swayAmount: random(2, 6) }; } // 为每一层生成雪花 const allSnowflakes = layers.map(function(layer) { const flakes = []; for (let i = 0; i < layer.count; i++) { flakes.push(createSnowflake(layer)); } return { layer: layer, flakes: flakes }; }); let mouseX = null; let mouseY = null; canvas.addEventListener('pointermove', function(e) { mouseX = e.clientX; mouseY = e.clientY; }); canvas.addEventListener('pointerleave', function() { mouseX = null; mouseY = null; }); function draw() { ctx.clearRect(0, 0, width, height); // 阵风效果:随时间平滑变化的风力 const gust = Math.sin(performance.now() / 4000) * 0.3 + 0.3; for (let i = 0; i < allSnowflakes.length; i++) { const layer = allSnowflakes[i]; for (let j = 0; j < layer.flakes.length; j++) { const flake = layer.flakes[j]; flake.y += flake.speed; flake.angle += flake.swaySpeed; flake.x += Math.sin(flake.angle) * flake.swayAmount * 0.4 + gust * 0.2; // 鼠标排斥 if (mouseX !== null) { const dx = flake.x - mouseX; const dy = flake.y - mouseY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 80) { const force = (80 - dist) / 80 * 2; flake.x += dx / dist * force; flake.y += dy / dist * force; } } // 边界回收 if (flake.y > height + 20 || flake.x < -40 || flake.x > width + 40) { layer.flakes[j] = createSnowflake(layer.layer); continue; } // 绘制 ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, ' + flake.opacity.toFixed(2) + ')'; ctx.fill(); } } requestAnimationFrame(draw); } draw(); // 文案渐显 setTimeout(function() { document.getElementById('message').classList.add('show'); }, 4000); // 窗口尺寸变化时重设画布 window.addEventListener('resize', function() { 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); }); })(); </script> </body> </html>

这个版本的核心参数都集中在前面的layers配置里,想调整视觉效果只改这一处就行。比如想要暴雪效果,把三个count分别上调到100、150、60;想要温柔的碎雪,把speedRange整体调低到0.2到1.2。具体数值没有绝对标准,我给的这套参数是多次测试后平衡了氛围和性能的选择。要特别注意resize处理:改了画布尺寸后,必须重新设置canvas.width和height,否则画布内容会被拉伸变形。ctx.scale(dpr, dpr)在resize后也要重新调用,因为设置canvas.width会重置整个画布的变换状态,我在实际使用中常常忘记这一步导致高分屏上突然变糊。

6. 常见问题与性能调优实录

6.1 为什么雪花边缘发虚

这个问题几乎每个用Canvas的人都会遇到一次,尤其在高分屏笔记本和手机上。原因在于CSS逻辑像素和物理像素的差异:一块苹果Retina屏幕的devicePixelRatio是2,意味着一个CSS像素要对应4个物理像素。如果你直接把Canvas的width设置成CSS宽度,Canvas的默认分辨率就只有物理分辨率的一半,浏览器放大显示后边缘自然发虚。

解决办法就是前面代码里那三行关键逻辑:把canvas.width设为CSS宽度乘以dpr,然后用ctx.scale(dpr, dpr)把后续所有绘图坐标恢复到CSS像素坐标系。这样画一个半径3的圆,实际渲染时会用6个物理像素宽度,边缘锐利。每个Canvas项目都应该养成这个习惯,而不是等发现问题再回头改。

6.2 雪花数量到底设多少合适

我见过有人把雪花数量直接拉到500片,结果中端手机上掉到30帧。这里有个非常基础的权衡:每片雪花每一帧至少要做一次坐标更新和一次arc绘制,500片就是每帧500次路径绘制,加上clearRect和后续DOM操作,很容易把主线程占满。

正常的分层配置里,总量控制在180到220片完全够用。桌面端可以再大胆一点放到300片,移动端建议压到150片以内。判断依据很简单:打开浏览器开发者工具的Performance面板,录制一段动画,看FPS能不能稳定在50以上,掉到45以下就减雪花。还有一种更精细的做法:根据屏幕面积动态调整数量,小屏少一点,大屏多一点,用面积比例换算出来的数值更合理。

const area = width * height; const targetCount = Math.floor(area / 8000);

但这个公式的系数需要自己根据实际效果调整,面积法的优点是实现在resize后自动适配不同屏幕,缺点是系数没有万能值,必须在目标设备上实测。

6.3 标签页切走再切回来,动画卡一下

这不是Bug,而是requestAnimationFrame的机制。浏览器为了省电,标签页在后台时不会调用rAF回调,切回来时动画本身还在继续,但很多人的代码会把雪花的位置更新逻辑和帧率耦合在一起,比如每帧让雪花下落speed像素,如果中断了3秒,回来时所有雪花都还停在原地,看着像卡顿。

严谨的做法是把运动改成基于时间的:每次回调时计算距上一帧的时间差deltaTime,然后y增量等于speed乘deltaTime。这样做动画速度跟帧率无关,不管中间断了多久,回来都能平滑衔接。

let lastTime = performance.now(); function draw() { const now = performance.now(); const delta = (now - lastTime) / 16.67; // 归一化到60帧 lastTime = now; flake.y += flake.speed * delta; requestAnimationFrame(draw); }

归一化到16.67毫秒之后,无论实际帧率是30帧还是120帧,雪花的下落速度都保持一致。这是一个非常通用的小技巧,任何基于帧循环的动画都会用到。我在做基础版的时候偷懒没写deltaTime,结果在高刷新率显示器上雪花飘得特别快,改完之后速度就正常了。

6.4 手机端发文件打不开怎么处理

做好的HTML文件要发给对方,最直接的方式就是把这个文件通过聊天工具传过去,对方下载后用浏览器打开。但部分手机自带的文件管理器会把HTML文件默认用系统浏览器打开,如果弹出的是"不支持打开此类文件",多半是被手机自带的应用拦截了。

更稳妥的做法是丢到免费静态托管上,生成一个链接,对方点开就是网页。这类服务很多,选一个支持自定义首页路径的就行,把HTML文件命名为index.html传上去,链接就是页面地址。这样做还有一个好处:页面里想加背景音乐、弹窗或者更多花活时,不受本地文件协议的限制。

如果只是想快速给手机上看,还有一个不那么正规但很实用的办法:直接在微信或聊天工具的浏览器里打开在线页面,再做成长图截屏发过去。虽然少了互动性,但胜在简单,适合应急场景。

7. 个人经验与后续二创方向

这个项目我前前后后做了好几个版本,最开始就是自己玩,后来帮朋友做过告白页,也把它改造成过团队年会大屏幕上的开场效果。踩过的坑集中在这几个地方:一是忘了处理devicePixelRatio导致画面发虚,被朋友一针见血指出来;二是把雪花数量设到400片之后在旧笔记本上卡成幻灯片;三是文案配色太暗,在深蓝背景上根本看不清。这些都不是高深问题,但每一个都直接影响最终观感,写出来希望你能少走弯路。

如果你想让这个效果更进一步,可以试试这几个方向。把雪花落到底部后不再回收,而是换成堆积层,配合前面说的离屏Canvas像素判断,可以实现"雪越积越厚"的实时积累效果。或者加入背景音乐,用audio标签放一首安静的纯音乐,点击页面后播放,氛围感直接翻倍。还有一个我很喜欢的设计:页面顶部放一排小字"点击屏幕听雪落下的声音",点击后画面上的雪花短暂停顿,同时播放一段风雪的合成音效,有种电影开场的感觉。

做这种礼物性质的小项目,最重要的不是代码写得多么优雅,而是对方打开页面那一刻的反应。你可能会花一个小时调参数、换文案,但看到对方发来一句"哇,这个雪好好看"的时候,什么都值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询