资讯动态

程序员的浪漫:用HTML+JS制作高性能雪花特效

发布时间:2026/10/22 4:09:28 来源:尧图企业网站定制
做这一行久了我慢慢发现很多所谓的“浪漫”代码反而特别简单。就拿“程序员的浪漫如何用HTMLJS制作雪花特效”来说它既不需要后端也不需要框架本质就是一块画布、一组粒子、一个循环。用对了方法半小时就能给自己页面挂上一场安静的下雪效果。这个特效适合谁适合想给个人主页或博客加一点氛围的前端初学者也适合做活动页、新年页、圣诞主题页面时临时需要一个轻量落雪背景的开发者。你不需要买服务器不需要引入第三方库只需要一个文本编辑器加一个现代浏览器保存成HTML文件双击就能看到效果。1. 先拆开看这个雪花特效到底在做什么很多第一次接触的人会把“雪花特效”想象得很玄其实它做的事情特别直白你有一块随时可以擦干净重新画的画板画板上空飘着一堆白色小圆点每一帧它们往下挪一点同时带一点点左右摇晃循环往复看起来就像下雪。我习惯用生活化类比来解释这套机制。你可以想象自己站在白纸前每隔零点几秒就把白纸擦干净重新画一遍所有雪花的当前位置。每画一遍雪花的位置都比上一遍低一两毫米于是你的大脑就产生了“雪花在往下飘”的视觉印象。这和看连环画的原理一模一样只不过连环画是人手翻页这里是浏览器一帧一帧重绘。为什么不用几十个div去做因为雪花特效要的是数量感。一片两片可以用DOM加CSS动画搞定但当屏幕上同时飘着上百片雪花还每片都独立摆动、速度不一DOM节点的重排和重绘会让页面明显卡顿。而canvas是画像素不产生DOM节点一口气画几百个圆非常轻松还方便你精确控制每片雪花的位置、大小、透明度和运动轨迹。这就是我选canvas而不是DOM动画的根本原因效率和可控性差得太远。至于循环靠的是requestAnimationFrame。它能保证动画尽量贴着屏幕刷新率跑也就是大概每秒60帧而且它会在浏览器准备好画下一帧时才执行比盲目用setInterval更省资源。这一点后面讲原理时还会细说。2. 把原理讲透粒子系统三步走2.1 第一步用对象描述一片雪花要做雪花特效先从“造一片雪花”开始。在我写的代码里每一片雪花就是一个普通的JavaScript对象长这样{ x: 0, // 当前横坐标 y: 0, // 当前纵坐标 radius: 2, // 半径 opacity: 0.8, // 透明度 speedY: 1.5, // 下落速度每帧移动多少像素 swingPhase: 0, // 左右摆动的相位 swingSpeed: 0.006, // 摆动频率 swingAmp: 0.8 // 摆动幅度 }字段看着多但每一个都有它存在的理由。x和y是画布坐标决定雪花画在哪里radius决定雪片大小opacity决定它偏亮还是偏暗speedY控制下降快慢。真正让雪花不像“白色雨点直直落下”的是后面三个字段swingPhase、swingSpeed、swingAmp。给雪花随机位置时有一个很实用的小技巧。如果你把所有雪花的y都设成0到画布高度的随机数那么第一帧你会看到“画面上突然撒了一把均匀分布的雪”之后所有雪同时往下走没多久画面就只剩雪越积越少。我更喜欢把初始y设成负数范围从-height到0之间。这样第一帧就有足够多的雪花已经“埋伏”在屏幕上方之外动画一开始屏幕里自然就有前有后、有疏有密。一句话总结别让所有雪从同一条起跑线出发。2.2 第二步每个动画帧更新雪花状态每来一个新帧不能让雪花停在原地。更新逻辑主要做三件事flake.y flake.speedY; flake.swingPhase flake.swingSpeed; flake.x Math.sin(flake.swingPhase) * flake.swingAmp * 0.1;第一行是直落。第二行是推进摆动的相位相当于让雪花内部那个“左右晃的钟摆”往前走了一小格。第三行用Math.sin算出当前时刻的偏移量再乘上幅度参数让横坐标产生平滑的左右波动。如果不加第三行雪花走的是笔直线路这叫“下雨”加了这个正弦摆动后才会像雪花在空中被风吹得飘来飘去。这里有个细节Math.sin的值域是-1到1所以算出来的横坐标变化一定是平滑的左右摇摆。你不需要额外定义复杂的风场只要控好swingSpeed和swingAmp这两个随机范围就能调出“大雪纷飞”或“小雪轻舞”两种完全不同的气质。当一片雪花落到画布底部时不能任由它跑出视野就完事否则画面上的雪花会越来越少。所以在更新函数里要加一个回收判断if (flake.y height flake.radius) { flake.y -flake.radius; flake.x random(0, width); flake.opacity random(config.opacityMin, config.opacityMax); flake.radius random(config.minRadius, config.maxRadius); }意思就是它一掉出底部就把它的位置重置回顶部重新随机一个横坐标、大小和透明度。这样屏幕上始终保持着同一批“演员”只是每次出场换一身行头。2.3 第三步绘制到画布并循环起来状态更新完剩下的就是把雪花画出来。canvas的画法很简单先画圆再渲染ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fillStyle rgba(255, 255, 255, ${flake.opacity}); ctx.fill();arc里的0和Math.PI * 2表示画一个完整圆因为一个圆的弧度正好是2π。颜色用rgba形式给一个白色基底后面跟的透明度值就是每片雪花亮暗不同的来源。有了“更新”和“绘制”还不够还要有一条永不停歇的生产线把它们串起来function loop() { update(); draw(); requestAnimationFrame(loop); }requestAnimationFrame会等当前帧画完再告诉浏览器“下一帧继续调用loop”于是形成无限循环。为什么不用setInterval我踩过它的坑。setInterval不管上一帧有没有执行完到时间就继续往里塞任务如果画面卡了一下很容易造成回调堆积反而更卡。而requestAnimationFrame天然是“等这一帧结束才安排下一帧”浏览器切到后台时会自动降频甚至暂停不白耗CPU。做动画优先用它不会错。3. 源码与参数照着抄就能跑改一个数字看一种变化3.1 完整源码一个HTML文件直接跑下面这份代码我整理成了单文件HTML。你新建一个文本文件把内容粘进去保存为snow.html拖进浏览器就能看到效果。不需要启动任何本地服务器也不需要联网。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleHTML JS 雪花特效/title style html, body { margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden; background: linear-gradient(135deg, #0b1e3a 0%, #1b2f52 45%, #3a4a63 100%); font-family: Arial, Microsoft YaHei, sans-serif; } .snow-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99999; pointer-events: none; } .snow-tip { position: fixed; left: 50%; bottom: 6%; transform: translateX(-50%); color: rgba(255, 255, 255, 0.65); font-size: 14px; text-align: center; letter-spacing: 1px; } /style /head body canvas classsnow-canvas/canvas div classsnow-tip下雪了注意添衣/div script const canvas document.querySelector(.snow-canvas); const ctx canvas.getContext(2d); const config { snowCount: 180, minRadius: 1, maxRadius: 4, minSpeedY: 0.5, maxSpeedY: 2, minSwing: 0.2, maxSwing: 1.2, opacityMin: 0.3, opacityMax: 1 }; let width 0; let height 0; let snowflakes []; function resize() { const dpr window.devicePixelRatio || 1; 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.setTransform(dpr, 0, 0, dpr, 0, 0); } function random(min, max) { return Math.random() * (max - min) min; } function createSnowflake() { return { x: random(0, width), y: random(-height, 0), radius: random(config.minRadius, config.maxRadius), opacity: random(config.opacityMin, config.opacityMax), speedY: random(config.minSpeedY, config.maxSpeedY), swingPhase: random(0, Math.PI * 2), swingSpeed: random(0.003, 0.008), swingAmp: random(config.minSwing, config.maxSwing) }; } function init() { snowflakes []; for (let i 0; i config.snowCount; i) { snowflakes.push(createSnowflake()); } } function update() { for (const flake of snowflakes) { flake.y flake.speedY; flake.swingPhase flake.swingSpeed; flake.x Math.sin(flake.swingPhase) * flake.swingAmp * 0.1; if (flake.y height flake.radius) { flake.y -flake.radius; flake.x random(0, width); flake.opacity random(config.opacityMin, config.opacityMax); flake.radius random(config.minRadius, config.maxRadius); flake.speedY random(config.minSpeedY, config.maxSpeedY); } } } function draw() { ctx.clearRect(0, 0, width, height); for (const flake of snowflakes) { ctx.beginPath(); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); ctx.fillStyle rgba(255, 255, 255, ${flake.opacity}); ctx.fill(); } } function loop() { update(); draw(); requestAnimationFrame(loop); } resize(); init(); loop(); window.addEventListener(resize, () { resize(); init(); }); /script /body /html这套代码一共做了四件事初始化画布、生成雪花数组、每帧更新位置、每帧重新绘制。代码里把常用数值都收拢到了config这个对象里想调效果不用去翻业务逻辑改config一处即可。3.2 参数表这些数字分别决定什么很多新手拿到源码后第一反应是“我能改什么”。我专门把参数整理成了一张表参数建议值影响效果snowCount180雪花总量。低于80会显得很疏高于350要注意移动端性能minRadius/maxRadius1 / 4雪花大小范围。范围拉大后大小混合会让画面更有景深minSpeedY/maxSpeedY0.5 / 2每帧下落像素。整体调大雪会下得又急又快调小则变得轻盈minSwing/maxSwing0.2 / 1.2左右摆动幅度。调得越大雪花的飘动轨迹越明显opacityMin/opacityMax0.3 / 1透明度范围。有些雪花淡有些雪花亮层次感更强要提醒的是speedY的单位是“每帧移动的CSS像素”。如果最大值设成2在60帧每秒的刷新率下一片雪花一秒钟大约往下走120像素。如果页面高度是800像素那一片最快的雪花大约六七秒就落完一趟是比较舒服的节奏。你要是拿它做暴雪效果直接翻倍就行但数量要稍微降一点不然满屏白条会刺眼。3.3 开发者工具里调参不用反复刷新我最喜欢这个项目的一个点是它特别适合在浏览器控制台里做“热更新”。把页面打开后按F12切到手机模拟器或者直接改控制台里的参数config.snowCount 300; init();这样当前页面会马上重新铺上300片雪花不用刷新页面。你甚至可以连按几次config.maxSpeedY 4; config.minSpeedY 2; config.minRadius 2; config.maxRadius 6; init();雪会立刻变成“大雪纷飞”的加强版。做完特效调试最忌讳为了看一个参数变化而反复刷新页面那样既慢又很难对比前后效果。直接改config再调init()体验会顺畅很多。4. 性能与细节打磨别让氛围变成噪音4.1 为什么很多人觉得canvas又糊又拉伸多半是dpr没处理我在不少初学者代码里看到过一个典型问题canvas写好后在普通屏幕上没事放到笔记本外接显示器或者高分屏上画面就发糊。这通常不是代码逻辑错了而是没有处理设备像素比也就是常说的dpr。普通屏幕里一个CSS像素和一个物理像素通常是一比一。但在高分屏上一个CSS像素可能对应两个甚至三个物理像素。如果你只用canvas.width window.innerWidth画布实际尺寸是CSS尺寸浏览器强行把它铺到物理像素更多的屏幕时就会发生模糊。解决办法是让画布按dpr放大物理尺寸再用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把绘图坐标系缩放回CSS像素坐标系。上面给的完整源码里已经写好了这段canvas.width width * dpr同时设置canvas.style.width width px。这样画布物理分辨率够高但CSS样式尺寸仍然正好铺满视口。坐标计算也不用改因为setTransform已经帮你在逻辑层做了等比缩放。这段看起来不起眼却是我认为整个特效里最不能被删的部分。4.2 切浏览器后台再回来雪花会瞬移吗有人担心用户切到别的标签页待一会儿再切回来时雪花会不会已经跑出屏幕这得区分写法。如果用setInterval写动画浏览器在后台并没有立刻抛弃定时器它可能在后台偷偷执行了很多次回来时雪花位置早就飞出去了。而requestAnimationFrame不一样浏览器在标签页不可见时会自动暂停回调所以切回来时动画会从暂停的地方继续跑不会出现“神瞬移”。这也带来一个很实际的好处你不需要额外写document.visibilitychange监听去做暂停恢复逻辑框架已经帮你做了大部分的事情。对一个小雪花装饰来说这种“帧驱动”写法最省心也最不容易出事。4.3 数量上去了还想不卡试试合并绘制默认代码每片雪花都走一次beginPath、fillStyle、fill180片雪花就是180组路径操作。这个量级在普通设备上完全没问题但如果config.snowCount调到500或者页面本身还有很多其他动画画布就会成为性能瓶颈。优化思路很简单把同颜色的圆合并到同一条路径里减少状态切换。ctx.fillStyle rgba(255, 255, 255, 0.8); ctx.beginPath(); for (const flake of snowflakes) { ctx.moveTo(flake.x flake.radius, flake.y); ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2); } ctx.fill();arc之间用moveTo抬一下笔避免所有圆心之间连出奇怪的线。这样全部雪花只用一次beginPath、一次fill性能明显更好。代价是所有雪花透明度相同少了一点远近层次。我的建议是如果是180片以内保留独立透明度如果你追求极限性能或者要同时跑其他复杂动画再切换成合并绘制。4.4 别忘了让画布不挡页面交互第一次做这个特效时我踩过一个非常实际的坑canvas铺了position:fixed结果整个页面的按钮全部点不动。原因很简单canvas浮在所有元素上面鼠标点击被它拦截了。解决办法就是源码里那行pointer-events: none;。它会让canvas对鼠标事件“透明”点击、滚动、选择文字全都能穿透到下面。这条线别漏漏了你的页面会变成“只能看不能点”的装饰画。5. 实测遇到的坑调试实录与排查清单5.1 问题一页面全黑一片雪花都没有先打开F12看控制台有没有报错。最常见的情况是canvas初始化时宽度或高度为0。比如把char事件绑定在了一个隐藏的容器里或者页面还没渲染完就执行了初始化window.innerWidth本身没问题但如果canvas被display:none包裹获取到的实际绘图尺寸可能就不对。解决办法是把特效初始化放在页面可见之后或者临时给canvas设一个明确的宽高再调试。另一种常见原因是本地文件路径写错。如果你把代码拆成单独的snow.js记得用相对路径引入。单独一个HTML文件里写所有代码能绕开这个坑。提示如果浏览器控制台没有任何报错但canvas就是没内容试着在draw()里加一行console.log(snowflakes.length)。如果打印出来是0说明init()没执行或者config.snowCount被改成了0这是排查这类问题最快的定位方式。5.2 问题二雪花被拉伸成椭圆形雪花是圆形画出来却变成椭圆十有八九是画布的物理宽高和CSS显示宽高的比例不一致。举个例子canvas内部属性width是300CSS却把它拉到600像素宽画面内容就会被横向拉伸一倍。所以在改尺寸时canvas.width和canvas.style.width要一起改。完整源码里的resize()已经把这层关系处理好了你只需要保证不手工套一层百分比宽高把canvas又拉开否则还是会发生同样的画面拉伸。5.3 问题三雪花盖住了整个页面按钮没法点这个前面已经提过直接写CSS.snow-canvas { pointer-events: none; }加上之后鼠标事件会穿透canvas。如果还是被挡检查是不是给canvas外又包了一层父容器父容器如果没有设置pointer-events:none照样会拦截事件。做装饰类特效时最好让canvas直接挂在body下不包额外容器。5.4 问题速查表我把这片领域里常见的问题收成一张表下次排查直接按表走现象可能原因处理办法页面没有任何雪花config.snowCount为0、JS报错、画布尺寸为0查看控制台检查init()是否执行画面模糊没有处理devicePixelRatio按dpr设canvas.width并setTransform雪花被拉伸CSS尺寸和画布属性尺寸不一致保持canvas.width和style.width同步按钮全部点不动canvas拦截了鼠标事件加pointer-events:none页面很卡雪花数量太多或每片独立填充调低config.snowCount改用批量路径切回来时雪花消失重启后未重新init()在visibilitychange中重建雪花数组5.5 把它嵌进现有项目时注意什么如果你不是直接粘贴整套HTML而是打算把这段雪花逻辑塞进自己正在用的页面有两点值得特别注意。一是初始化时机要确保放入canvas的容器在DOM里已经就位等页面主要元素渲染完再执行resize()和init()。二是清理时机如果项目是单页应用切路由时特效还在后台跑白白消耗性能。至少你要把循环包装成一个可取消的形式let rafId null; function loop() { update(); draw(); rafId requestAnimationFrame(loop); } // 需要销毁时调用 function destroy() { cancelAnimationFrame(rafId); }虽然小特效看着不起眼但“能启动”和“能销毁”是两个境界。能够随时取消循环后面把它集成进任意框架都会从容许多。常写常改的一点心得把这个雪花特效从零写出来其实不难真正花时间的反而是适配各种边角情况高分辨率屏幕会不会糊、切后台会不会卡、鼠标点击会不会被挡、窗口缩放后会不会拉伸。如果你平时也喜欢做这种装饰型的小动画我建议你记住一个顺序先把画布尺寸、dpr、pointer-events这些基础设施钉死再回头调数量、速度、透明度这些“好看”的参数。不然你前面调得再满意换到一台高分屏或者手机上一测很多细节就要推倒重来。另外每次调试时我会习惯性在控制台里先执行一次config读取把当前参数打印出来再逐个改。看起来多了一步实际上能省掉大量“刚才改到哪了”的记忆负担。这个项目最好的地方在于它把HTML、JS、动画循环、随机数、物理运动这些前端基础概念全串在了一个能立刻看到效果的例子里。学完之后你想把它扩展成樱花飘落、落叶飞舞、甚至星空闪烁只需要换掉绘制函数里画圆的那几行同样一套粒子系统能玩出的花样远比你想象的多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑