☰
用Canvas打造网页雪花特效:代码与浪漫的结合
2026/10/22 23:26:55 网站建设 项目流程

你有没有试过,在某个冬夜打开一个网页,代码里慢悠悠飘起雪来,明明只是白色的小点,却让人盯着看了好几分钟?我第一次做这个HTML+JS雪花特效,其实不是为了什么项目需求,只是想在一个很重要的日子里,让网页替我下一场雪。后来发现这是一件特别"程序员的浪漫"的事——不送花不送礼,而是把一个自定义的冬日氛围页面丢给朋友,打开就是漫天雪花。这篇文章把整套思路、源码和踩坑经历都整理出来了,适合前端入门到进阶的开发者,也适合任何想在网页里加一点"仪式感"的人。

1. 从"一场代码雪"说起:为什么用canvas做雪花特效

1.1 浪漫场景里的真实需求

我做这个特效的最初动机很简单。有位平时老加班的朋友过生日,我想做一个生日祝福页面,但光有祝福文字总觉得干巴巴的。后来灵机一动:页面背景里加一层飘落的雪花,配上深蓝色夜空渐变,氛围感一下就拉满了。对方打开页面不说惊喜,至少能愣一下——"这玩意儿还能下雪?"

这类需求其实很常见。圣诞节的电商活动页需要下雪氛围,个人博客到了冬季想换皮肤,还有人做表白页面想让对方点开就是一屏雪花。雪花特效几乎成了"网页氛围组"的标配。相比去找现成的雪花动图或者背景视频,用HTML+JS写一个canvas雪花层,好处非常明显:体积小(一个几百行的JS文件搞定)、颜色和透明度可调、可以响应鼠标触摸事件、能和页面其他元素自然融合。

1.2 技术选型:canvas并非唯一方案,但最均衡

做雪花特效有几条路:CSS动画、canvas、SVG,甚至WebGL。我的选择是canvas,理由也很简单:

  • CSS动画适合纯装饰性的、数量少的雪花,比如二三十朵,用一堆div配合transform和animation就能做。但数量一多,DOM节点的开销就会拖垮页面,尤其是手机上。
  • WebGL性能最强,上万粒子都不卡,但要写着色器、初始化上下文,对大多数人来说学习成本高,用在这里属于"杀鸡用牛刀"。
  • canvas 2D居中,性能足够承载几百个粒子,API直观,兼容性好,代码写起来也不绕。

一句话总结:如果只是点缀,CSS够用;如果想让几百片雪花铺满屏,canvas是性价比最高的答案。本文的主线也围绕canvas展开,代码可以直接复制,跑起来就是一套完整特效。

2. 雪花的三样基础件:画布、粒子、动画循环

2.1 先搭一张高清屏不模糊的画布

不管你准备让雪下得多浪漫,第一步永远是把canvas画布铺好。很多人一上来就设置canvas.width = window.innerWidth,结果在Retina屏上画出来的雪花边缘发虚,像蒙了一层雾。原因是设备像素比(devicePixelRatio,简称dpr)在不同屏幕上不一样,常见的Windows缩放比例是125%或150%,手机屏幕更是动不动就dpr3。如果你不做处理,浏览器会用CSS像素去渲染物理像素,导致模糊。

正确做法是画布的实际像素尺寸乘以dpr,再用ctx.scale(dpr, dpr)把坐标系统一回来。这样你在代码里写的坐标和CSS坐标一致,但底层渲染的分辨率是高清的。代码如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Canvas 雪花特效</title> <style> * { margin: 0; padding: 0; } body { width: 100vw; height: 100vh; overflow: hidden; background: linear-gradient(180deg, #0a0e1f 0%, #1d2d5c 100%); } canvas { position: fixed; inset: 0; } </style> </head> <body> <canvas id="snowCanvas"></canvas> <script> const canvas = document.getElementById('snowCanvas'); const ctx = canvas.getContext('2d'); let width = window.innerWidth; let height = window.innerHeight; function resizeCanvas() { width = window.innerWidth; height = window.innerHeight; const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener('resize', resizeCanvas); resizeCanvas(); </script> </body> </html>

注意这里用了Math.floor而不是直接赋值,我遇到过有些浏览器在极端缩放下,乘完dpr之后出现半像素值,导致画布边缘有一条细线。取整能规避这个问题。ctx.setTransform其实比ctx.scale更推荐,因为它在窗口反复缩放后能把之前的变换清干净,避免变换累积。

2.2 雪花粒子的数据结构:一个对象管住一片雪

接下来是核心数据结构。每片雪花其实就是一个包含坐标、半径、速度和透明度的普通对象。我习惯用一个构造函数(或者class)来定义雪花,因为这样方便批量生成和管理。

class Snowflake { constructor(x, y) { this.reset(x, y); } reset(x, y) { this.x = x !== undefined ? x : Math.random() * width; this.y = y !== undefined ? y : Math.random() * height; this.radius = randomRange(0.5, 3.5); this.speedY = randomRange(0.5, 2); this.speedX = randomRange(-0.5, 0.5); this.opacity = randomRange(0.3, 0.9); this.wobble = Math.random() * Math.PI * 2; this.wobbleSpeed = randomRange(0.01, 0.03); this.wobbleRange = randomRange(0.5, 2); } } function randomRange(min, max) { return Math.random() * (max - min) + min; }

字段含义如下:

字段作用取值参考
x / y雪花当前坐标画布范围内初始化
radius雪花半径,决定大小0.5 ~ 3.5像素
speedY垂直下落速度0.5 ~ 2像素/帧
speedX水平漂移速度-0.5 ~ 0.5像素/帧
opacity透明度0.3 ~ 0.9
wobble摆动相位0 ~ 2π
wobbleSpeed摆动角速度0.01 ~ 0.03
wobbleRange摆动幅度0.5 ~ 2像素

这些数值不是随便定的,它们决定了雪花的"性格"。radius太小则看不见,太大会像纸团;speedY太慢像悬浮,太快像下雨。我调了很多轮,最终上面这组范围在大多数屏幕上都自然。

2.3 每一帧都做三件事:清屏、画雪、移动

动画循环只需要一个requestAnimationFrame,每一帧里的逻辑就三件事:清空画布、更新所有雪花坐标、把雪花画出来。

const flakes = []; const MAX_COUNT = 160; function initFlakes() { flakes.length = 0; const count = Math.min(MAX_COUNT, Math.floor((width * height) / 12000)); for (let i = 0; i < count; i++) { flakes.push(new Snowflake()); } } function animate() { ctx.clearRect(0, 0, width, height); for (const flake of flakes) { flake.x += flake.speedX + Math.sin(flake.wobble) * 0.3; flake.y += flake.speedY; flake.wobble += flake.wobbleSpeed; // 飘出画布后回到顶部 if (flake.y > height + 10) { flake.reset(Math.random() * width, -10); } if (flake.x > width + 20) flake.x = -20; if (flake.x < -20) flake.x = width + 20; // 画雪花 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(animate); } initFlakes(); requestAnimationFrame(animate);

这里有一个非常重要的细节:为什么随风摆动要用Math.sin(flake.wobble)而不是直接给一个随机的水平速度?如果你只给speedX一个常数,雪花会朝着一个方向匀速漂移,看起来不像雪花,倒像一群小虫子齐步走。而正弦函数让雪花在同一帧内水平方向上做往复运动,配合wobbleSpeed的随机相位,每片雪的飘落轨迹都不一样,肉眼就觉得"活了"。

另一个细节是清屏这一步。clearRect如果不做,上一帧的画面会残留,形成拖尾。有些人觉得拖尾有朦胧美,故意不清,但那就不再是雪花,而是光流的霓虹效果了。这里老老实实清空,雪的轮廓才会干净利落。

3. 让雪花"活"起来:飘落轨迹与随机感设计

3.1 正弦波摆动:从"直线坠落"到"随风飘摇"

如果你按上面第2章的代码跑起来,会发现雪已经在下了。但细心观察,还是有点机械感——每片雪花的摆动幅度都一样。要做到真正的"活",需要让wobbleRange也随机,并且让每个粒子的摆动速度各不相同。我在Snowflake构造器里已经随机了这些值,但这里单独拎出来讲,因为它值得再深一层。

真实雪花的飘落轨迹不是简单的正弦波,而是"涡旋式"的:它在水平方向上来回搓动,偶尔还会因为微小的气流突然加速一下。如果想模拟这种手感,可以在正弦波基础上叠加一层随机扰动:

flake.x += flake.speedX + Math.sin(flake.wobble) * flake.wobbleRange; // 模拟气流突变的扰动 if (Math.random() < 0.005) { flake.wobbleRange = randomRange(1, 3); flake.speedY = randomRange(0.4, 1.8); }

这个Math.random() < 0.005的判定代表:每帧只有0.5%的概率触发一次参数突变。从视觉效果上说,就是每隔一会儿,某片雪花会突然横着飘一下或者落得快一点,但又不会太频繁,整体依然稳定。这个技巧同样可以应用到粒子系统以外的很多场景——比如飘落的树叶、气泡、星星,原理是通用的。

3.2 透明度与大中小三层配比

雪花看起来"假",最常见的罪魁祸首是透明度统一、大小统一。一片白纸上所有白点大小一模一样,谁会信那是雪?所以我的做法是把雪花分成三个"梯队":

梯队半径范围透明度范围垂直速度占比建议
远景雪0.5 ~ 1.50.3 ~ 0.50.3 ~ 0.840%
中景雪1.5 ~ 2.50.5 ~ 0.70.8 ~ 1.540%
近景雪2.5 ~ 3.50.7 ~ 0.91.2 ~ 2.020%

远景雪又小又慢又淡,近景雪又大又快又亮。这套参数基本模拟了摄像机景深——远处的景模糊、运动慢,近处的清晰、运动快。如果你家里有窗户,隔着窗户看外面的雪,感受一下,就是这个逻辑。实现上我直接把三组参数写成数组,初始化时根据百分比随机分配:

const layers = [ { r: [0.5, 1.5], a: [0.3, 0.5], v: [0.3, 0.8], weight: 0.4 }, { r: [1.5, 2.5], a: [0.5, 0.7], v: [0.8, 1.5], weight: 0.4 }, { r: [2.5, 3.5], a: [0.7, 0.9], v: [1.2, 2.0], weight: 0.2 } ]; function pickLayer() { let r = Math.random(); let acc = 0; for (const layer of layers) { acc += layer.weight; if (r < acc) return layer; } return layers[layers.length - 1]; }

然后用pickLayer()返回的参数范围去生成雪花的radius、opacity、speedY。经常有人问我为什么雪花数量不多却显得很茂密,秘密就在这里——三层叠加的层次感会让大脑自动脑补出"很多雪"。

3.3 像雪而不像雨的检验标准

我调试时有个简单粗暴的判断方法:把雪花透明度调到最高、速度调到最快,然后看效果。如果它像雨丝,说明speedY过高或者radius过小;如果像棉絮,说明opacity太高且半径偏大。真正舒服的雪,看起来应该是"慢节奏、轻质感、有呼吸感"的。

另外一个容易忽略的点是背景颜色。白色雪花放在纯白色背景上等于隐形,放在太亮的背景上又显得脏。我推荐深蓝或藏青渐变色做底,配合雪花的白,对比度刚刚好。现在很多人喜欢的效果图都是深夜蓝+飘雪+暖黄色灯光文字,视觉层次极其舒适。如果页面上有文字,建议给文字加一个暖暖的发光效果,这种冷暖对比能极大提升浪漫氛围。

4. 雪花飘得优雅的关键:层次感与景深模拟

4.1 画"雪"还是画"点"?形态选择的取舍

第3章的代码里用的是ctx.arc画的实心圆点。简单、性能好,渲染几百个圆点毫无压力。但如果你追求更精细的雪花形态,比如六边形、星星状,canvas也有办法:用路径画一个六角星,或者直接贴一张半透明的雪花PNG图片。图片方案效果最好,但要管理资源加载,而且不同尺寸的雪需要不同尺寸的图片,比较麻烦。我的建议是:默认用圆形点,因为雪花很小的时候(半径1~3像素),人眼看不出形状细节,圆形已经是性价比最高的近似。

如果一定要做六边形雪花效果,可以用一个小的临时canvas当作图案,在动画里绘制时用drawImage把图案画上去,而不是每片雪花都重新算路径。后者会显著降低帧率。类似图片缓存的思路在粒子特效里很实用,凡是大量重复的图形,都应该只创建一次、反复引用。

// 生成一片六边形雪花的迷你画布,作为贴图 function createSnowTexture(size) { const temp = document.createElement('canvas'); temp.width = size * 2; temp.height = size * 2; const tctx = temp.getContext('2d'); tctx.translate(size, size); tctx.beginPath(); for (let i = 0; i < 6; i++) { const angle = (Math.PI / 3) * i - Math.PI / 2; const x = Math.cos(angle) * size; const y = Math.sin(angle) * size; if (i === 0) tctx.moveTo(x, y); else tctx.lineTo(x, y); } tctx.closePath(); tctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; tctx.fill(); return temp; }

4.2 颜色不是纯白

又一个容易踩的细节:雪的颜色不是rgba(255,255,255,1)。纯白雪花在深色夜空里会显得刺眼,像贴纸。模拟自然光,应该让雪花带一点点冷色调,比如rgba(220, 230, 255, opacity),或者用径向渐变,从中心的高光白过渡到边缘的淡蓝。

如果你用实心圆点,可以画两层:先画一个稍微大一圈的rgba(255,255,255,0.15)作为光晕,再画一个核心白点。这样做出来的雪会有一点"发光感",在节日氛围页面上特别好看。

// 给雪花画一圈微弱光晕 ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius * 1.6, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity * 0.2})`; ctx.fill(); ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(240, 245, 255, ${flake.opacity})`; ctx.fill();

4.3 层次审美:不只有雪,还有光的折射

如果只是雪花本身,看久了还是会腻。我后来给页面加了一个非常克制的效果:偶尔有几片"闪亮"的雪花——即在所有雪花中抽出10%左右,让它们的光晕更大、透明度更高,像是刚好被路灯照到。实现方法很简单:给Snowflake加一个glow布尔属性,初始化时10%概率为true,绘制时按glow的值决定是否额外画一圈大的渐变。

这种"被光照亮"的细节是大部分人一眼说不出、但会觉得"这雪很舒服"的原因。很多看起来高级的特效,拆解下来都是这种小技巧的叠加。你可以试试不告诉朋友区别,只看效果,十有八九他们会觉得你用的不是同一个模板。

5. 把"浪漫"稳稳落地:性能优化与踩坑实录

5.1 雪花数量不是越多越好

这是最常见的翻车点。很多人一激动,直接把雪花数量设定为500、1000,结果低端手机瞬间卡成PPT。雪花数量的合理值取决于屏幕面积和帧率目标,我用的经验公式是:

const count = Math.min(160, Math.floor((width * height) / 12000));

也就是每12000平方像素分配一片雪花。1080p的屏幕大约生成160片左右,手机上因为屏幕小,数量会自动降下来。这个数字在绝大多数设备上都能保持流畅。如果你想要极致流畅,可以把分母调大到16000;如果想要暴风雪效果,调到8000,但要保证你的目标用户设备不太老。

5.2 requestAnimationFrame的坑与正确用法

动画循环必须用requestAnimationFrame,不能用setInterval。原因有两个:一是rAF与屏幕刷新率同步(通常是60Hz),不会掉帧导致画面跳动;二是页面切到后台时,rAF会自动暂停,而setInterval还会继续跑,即使页面不可见也在白白占用CPU。

但rAF也有自己的坑,最典型的是"当页面里有多个动画循环时,忘记取消旧循环"。比如你在单页应用里反复切换路由,如果没有在组件卸载时执行:

let rafId = null; function animate() { // ...画图逻辑 rafId = requestAnimationFrame(animate); } // 需要停止时 cancelAnimationFrame(rafId);

页面就会存在多个动画循环同时跑,每一帧都多画一遍雪花,你看到的雪花速度变成两倍、三倍,最后浏览器崩溃。我在某个单页项目里踩过一次,排了半天才发现是路由切换没有清理动画循环。

5.3 高清屏缩放引发的坐标偏移

前面提到过dpr。如果你在窗口缩放时没有重新执行ctx.setTransform,而是继续用旧的缩放值,就会出现一种很诡异的bug:雪花位置和鼠标位置对不上,或者画布左上角突然冒出一块空白。处理缩放的正确姿势是:在resize监听里同时重设canvas像素尺寸和setTransform,并且重新生成雪花数组(因为width和height变了,旧雪花的坐标可能超出新画布范围)。

window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { resizeCanvas(); initFlakes(); }, 300); });

这里我加了一个300ms的防抖。如果不防抖,拖拽窗口边缘时resize事件会疯狂触发,导致雪花不断重建。防抖后只在停顿的瞬间处理一次,流畅不卡。

5.4 移动端与可滚动页面的取舍

雪花层如果做成position: fixed,它会固定盖在视口上,页面滚动时雪花丝毫不受影响,这是最常用的方案,适合整页都是氛围背景的场景。但如果你想在正常的长文页面里加雪,fixed会让雪花一直飘在文字上方,可能干扰阅读。这种情况下有两个选择:一是给雪花canvas加一个pointer-events: none(CSS属性),让鼠标事件直接穿透,不阻挡用户选择文本和点击按钮;二是把canvas改成position: absolute,放在页面根容器里,跟随文档流滚动。

在移动端还要注意:如果页面本身可以滚动,而你的canvas设置了fixed悬浮层,用户在页面上下滑动时,canvas不需要任何preventDefault,保持默认行为即可;千万不要为了所谓的"触摸响应"去全局拦截touchmove,那会让整个页面无法滚动。我见过不少初学者在这个问题上把体验做崩了。

6. 让雪花回应你的"情话":交互与场景进阶

6.1 鼠标/触摸晃动:让雪被人惊动

雪花特效进阶玩法之一,是让雪花对鼠标或触摸操作有反应。常见做法是监听pointermove事件,记录鼠标最近的位置,然后计算每片雪花与鼠标之间的距离,当距离小于某个阈值(比如120px)时,给雪花施加一个远离鼠标的推力。

let mouseX = null; let mouseY = null; window.addEventListener('pointermove', (e) => { mouseX = e.clientX; mouseY = e.clientY; }); // 在update里加入斥力逻辑 const dx = flake.x - mouseX; const dy = flake.y - mouseY; const dist = Math.hypot(dx, dy); if (mouseX !== null && dist < 120) { flake.x += (dx / dist) * 1.5; flake.y += (dy / dist) * 1.5; }

这个效果很像真实生活中用手拨开雪的感觉,互动性非常强,适合做表白页面或者活动页:访客想看清背景里的情话,就得用手把雪拨开。每次弹出鼠标,雪花散开的瞬间确实很有"浪漫"的味道。在移动端,touch事件会被pointermove兼容,不需要额外处理。

6.2 雪花汇聚成字的表白玩法

比拨雪更有仪式感的是"雪花汇聚成字"。做法是先准备好你要显示的文字,比如"Meet Me At Winter"或某个人的名字,渲染到一个不可见的canvas上,用getImageData提取像素点位置,作为目标坐标。再把雪花粒子初始位置随机散布在画布各处,每帧让它们向目标坐标移动。当雪花都落在目标点上,一幅由雪花组成的文字就慢慢浮现了。

核心伪代码如下:

function getTextTargets(text, fontSize, cw, ch) { const offscreen = document.createElement('canvas'); offscreen.width = cw; offscreen.height = ch; const octx = offscreen.getContext('2d'); octx.font = `bold ${fontSize}px sans-serif`; octx.textAlign = 'center'; octx.textBaseline = 'middle'; octx.fillStyle = '#fff'; octx.fillText(text, cw / 2, ch / 2); const imageData = octx.getImageData(0, 0, cw, ch).data; const points = []; const step = 2; // 每隔2个像素采样一次,控制粒子数量 for (let y = 0; y < ch; y += step) { for (let x = 0; x < cw; x += step) { const alpha = imageData[(y * cw + x) * 4 + 3]; if (alpha > 128) points.push({ x, y }); } } return points; }

粒子数量会被文字笔画面积影响,一个中文字符动辄上千个目标点,对性能压力不小。建议step至少取2或3,并且限制文字数量。达到目标点后,可以让雪花保持微弱的浮动,不要完全静止,否则就失去了雪的灵动感。

6.3 场景集成:从一个小文件到一套完整页面

把雪花特效集成到现有页面时,我建议把它独立成一个模块文件,比如snow.js,暴露一个初始化函数。这样既不会和业务代码纠缠,也方便随时销毁动画循环。

function initSnow(options = {}) { const canvas = options.canvas || document.getElementById('snowCanvas'); const ctx = canvas.getContext('2d'); // 其他参数从options读取:数量、速度范围、颜色等 // 返回一个stop函数用于销毁 return function stop() { cancelAnimationFrame(rafId); window.removeEventListener('resize', resizeHandler); }; }

我用这种模式在很多页面里复用同一个雪花文件,圣诞节布置一个带"Merry Christmas"汇字的版本,生日页面布置一个更慢更柔和的版本,甚至可以通过参数控制雪花颜色,比如把白色改成淡金色,就是另一种节日氛围了。这也是HTML+JS做特效最有价值的地方:一个通用模块,能适配无数种浪漫场景。

我在实际使用中发现,做这类氛围特效最打动人的并不是技术本身,而是你在细节上投入的心意。比如把生日页面里的雪花下落速度调慢,让整页水波一样温柔;比如在按钮点击后突然下一阵暴雪再慢慢恢复平静。这些东西不需要多复杂的算法,只需要你愿意多调几次参数、多跑几遍测试。最后再分享一个小技巧:做完的特效页面,尽量在性能较差的旧手机上验证一遍,因为你在自己电脑上看着流畅不等于别人打开也流畅,真正的浪漫是要让对方无负担地看到成片雪落下来。

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

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

立即咨询