资讯动态

用Canvas打造网页雪花特效:代码与浪漫的结合

发布时间:2026/10/22 23:27:05 来源:尧图企业网站定制
你有没有试过在某个冬夜打开一个网页代码里慢悠悠飘起雪来明明只是白色的小点却让人盯着看了好几分钟我第一次做这个HTMLJS雪花特效其实不是为了什么项目需求只是想在一个很重要的日子里让网页替我下一场雪。后来发现这是一件特别程序员的浪漫的事——不送花不送礼而是把一个自定义的冬日氛围页面丢给朋友打开就是漫天雪花。这篇文章把整套思路、源码和踩坑经历都整理出来了适合前端入门到进阶的开发者也适合任何想在网页里加一点仪式感的人。1. 从一场代码雪说起为什么用canvas做雪花特效1.1 浪漫场景里的真实需求我做这个特效的最初动机很简单。有位平时老加班的朋友过生日我想做一个生日祝福页面但光有祝福文字总觉得干巴巴的。后来灵机一动页面背景里加一层飘落的雪花配上深蓝色夜空渐变氛围感一下就拉满了。对方打开页面不说惊喜至少能愣一下——这玩意儿还能下雪这类需求其实很常见。圣诞节的电商活动页需要下雪氛围个人博客到了冬季想换皮肤还有人做表白页面想让对方点开就是一屏雪花。雪花特效几乎成了网页氛围组的标配。相比去找现成的雪花动图或者背景视频用HTMLJS写一个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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCanvas 雪花特效/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 idsnowCanvas/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.9wobble摆动相位0 ~ 2πwobbleSpeed摆动角速度0.01 ~ 0.03wobbleRange摆动幅度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: noneCSS属性让鼠标事件直接穿透不阻挡用户选择文本和点击按钮二是把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汇字的版本生日页面布置一个更慢更柔和的版本甚至可以通过参数控制雪花颜色比如把白色改成淡金色就是另一种节日氛围了。这也是HTMLJS做特效最有价值的地方一个通用模块能适配无数种浪漫场景。我在实际使用中发现做这类氛围特效最打动人的并不是技术本身而是你在细节上投入的心意。比如把生日页面里的雪花下落速度调慢让整页水波一样温柔比如在按钮点击后突然下一阵暴雪再慢慢恢复平静。这些东西不需要多复杂的算法只需要你愿意多调几次参数、多跑几遍测试。最后再分享一个小技巧做完的特效页面尽量在性能较差的旧手机上验证一遍因为你在自己电脑上看着流畅不等于别人打开也流畅真正的浪漫是要让对方无负担地看到成片雪落下来。

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

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

免费获取报价 →
↑