节日祝福网页源码拆解:CSS动效与Canvas粒子性能优化
简介:十一国庆节祝福网页是一份面向Web开发初学者和节日互动页面制作爱好者的轻量源码包,可用于快速搭建国庆主题送祝福页面。用户只需在文本框中填写送祝福人与自己的名字,点击提交后即可自动复制专属地址并转发给亲友,适合节日问候、课堂练习和HTML/CSS入门实践。压缩包共9个文件,约152KB,包含5个JPG和2个GIF图像素材,用于页面配图与动效装饰,另有1个HTM页面文件和1个DB数据文件,分别承载页面结构与提交数据记录。目前已有2276人学习下载,说明其具备一定参考价值。读者可从中了解表单输入、页面排版、图片素材调用及简单提交处理的组织方式,并基于现有页面替换文案、图片和样式,快速改造成个性化祝福页。
1. 一份国庆祝福网页源码,先别急着套模板:它到底解决什么问题
拿到一份节日主题的 Web 开发源代码,多数人的第一动作是把标题一改、背景一换、直接丢服务器。真上线才发现:手机端横向能拖动、低端安卓一进页面就发烫、烟花动画在微信里黑屏。这套 HTML + CSS 源代码包解决的其实不是「做个好看的页面」,而是把节日氛围动效拆成可控制的层,让它在三年前的千元机和最新的旗舰机上都不至于失控。它适合三类人:需要一天内交付一个节日专题页的前端、要给公众号或活动落地页做视觉包装的运营开发、以及想拿一份完整静态页练手 CSS 动效与 Canvas 粒子的新手。我把包拆开跑了一遍,下面按文件结构、动效层、粒子层、坑位逐段说。
2. 拆开压缩包看骨架:index.html 的语义结构与四个资源入口
静态祝福页最容易出问题的地方不在视觉,在结构。结构定错了,后面每一层动效都在跟文档流打架。这份包的骨架相对克制,没有把整页塞进一个巨大的 div,也没有用绝对定位把所有元素钉死,这一点值得保留。
2.1 目录结构与文件依赖关系
解压后第一件事不是打开 index.html 看效果,而是先看依赖关系。节日页的加载顺序错了,会出现「动画先跑、背景后到」的观感断层。常见做法是按下面这种结构组织,把首屏必须的资源和其他资源分开:
festival-page/ ├── index.html # 唯一入口,首屏所有内容都在这一个文件里 ├── css/ │ ├── base.css # reset、字体、CSS 变量、主题色 │ ├── layout.css # 三层舞台、容器尺寸、响应式断点 │ └── animate.css # 关键帧与动画类,全部只碰 transform/opacity ├── js/ │ ├── fireworks.js # Canvas 粒子主逻辑 │ └── main.js # 初始化、事件绑定、可见性暂停 ├── assets/ │ ├── img/ # 背景、飘带、纹理,优先 webp │ └── font/ # 可选,非必须不要引外部字体 └── favicon.ico这张清单里最关键的一条是:animate.css单独拆出来。节日页的动效经常被反复调时长和节奏,和布局代码混在一个文件里,改一次动画就要重新读一遍布局,血泪经验。另外assets/font/标了可选——外部字体是首屏白屏的头号嫌疑,能用系统字体栈顶住就别引。
依赖关系上,base.css必须在layout.css之前,因为主题色变量定义在 base 里,layout 里会引用;animate.css放最后,方便用层叠优先级覆盖个别元素的动画。JS 全部用defer引入,不要阻塞首屏渲染:
<link rel="stylesheet" href="css/base.css"> <link rel="stylesheet" href="css/layout.css"> <link rel="stylesheet" href="css/animate.css"> <script src="js/fireworks.js" defer></script> <script src="js/main.js" defer></script>defer保证脚本在 DOM 解析完成后按顺序执行,fireworks.js先于main.js,后者才能拿到前者暴露的初始化函数。如果改成async,两个文件的执行顺序就不确定了,初始化时拿到undefined是经典翻车点。
2.2 语义化骨架:从 header 到 canvas 的挂载顺序
骨架的核心是「三层舞台 + 一个画布」。三层分别是背景层(静态图或渐变)、中景层(飘带、光晕)、前景层(祝福文案与按钮),Canvas 挂在背景层和中景层之间,这样烟花在后面炸、文案在前面压得住。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <!-- 关键:viewport 不加 user-scalable=no,会被无障碍审计直接判不合格 --> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="theme-color" content="#c8102e"> <meta name="description" content="节日祝福主题静态页"> <title>节日祝福</title> </head> <body> <!-- 舞台容器:所有绝对定位元素的定位基准 --> <main class="stage"> <!-- 第一层:静态背景,纯 CSS 渐变,不依赖图片 --> <div class="layer layer-bg" aria-hidden="true"></div> <!-- 第二层:Canvas,烟花画在这里 --> <canvas id="fx" class="layer layer-fx" aria-hidden="true"></canvas> <!-- 第三层:中景装饰,飘带与光晕,纯装饰给 aria-hidden --> <div class="layer layer-mid" aria-hidden="true"> <span class="ribbon ribbon-1"></span> <span class="ribbon ribbon-2"></span> <span class="glow"></span> </div> <!-- 第四层:文案,唯一需要被读屏软件读到的内容 --> <section class="layer layer-content"> <h1 class="title">节日快乐</h1> <p class="subtitle">愿你今年顺遂,所愿皆成</p> <button class="btn-fire" type="button">点亮烟花</button> </section> </main> </body> </html>这段骨架有三个设计决定值得说清楚。第一,装饰性元素全部加aria-hidden="true",读屏软件只会读到标题、副标题和按钮,不然一进页面就是十几条无意义的「图片」播报。第二,viewport-fit=cover是为刘海屏准备的,配合 CSS 里的env(safe-area-inset-*)用,不加这一条,全屏页在异形屏上文案会被裁掉一截。第三,按钮用原生<button type="button">而不是 div,键盘 Tab 能聚焦、回车能触发,成本几乎为零,收益是整页可访问性达标。
挂载顺序也有讲究:Canvas 必须在.layer-mid之前、.layer-bg之后。DOM 顺序决定默认层叠顺序,后面的元素压在前面的上面,这样烟花不会盖住飘带和文案,视觉上才有纵深。如果嫌 DOM 顺序不好调,用z-index显式指定也行,但别两套混着用,容易在某个浏览器里表现不一致。
2.3 meta 与资源加载参数怎么配
静态页的性能预算基本在 head 里就决定了一半。下面这张表是我在几个节日专题页上反复调过之后留下来的配置,按需取用:
| 配置项 | 推荐值 | 作用与边界 |
|---|---|---|
viewport | width=device-width, initial-scale=1, viewport-fit=cover | 禁止写maximum-scale=1,会挡住用户缩放 |
theme-color | 主题主色,如#c8102e | 移动端浏览器地址栏染色,只影响观感 |
preload背景图 | <link rel="preload" as="image" href="..."> | 只对首屏大图用,超过 200KB 建议先压缩 |
font-display | swap | 若必须引外部字体,用 swap 避免文字长时间不可见 |
| 图片格式 | webp 优先,png 兜底 | 背景图是首屏最大体积项,压缩收益最高 |
| 首屏 JS | 全部defer,总体积控制在 30KB 内 | 粒子逻辑不复杂,超了说明写了冗余代码 |
theme-color这条经常被忽略,但它对「节日感」的贡献比想象中大:移动端浏览器顶部那条色带和页面主色一致,整页的完整度立刻不一样。注意theme-color只认一个颜色值,不支持渐变,别写linear-gradient(),写了也不生效。
如果背景图确实大,除了preload还有一招:在.layer-bg上先铺一层同色系的纯色或径向渐变,图片加载完再淡入覆盖。用户看到的是「颜色先到、细节后到」,比整块白屏强得多。这套做法不需要 JS,纯 CSS 就能完成:
.layer-bg { background-color: #7a0d18; /* 兜底底色,和主图色调接近 */ background-image: radial-gradient(circle at 50% 30%, #c8102e 0%, #7a0d18 70%); background-size: cover; background-position: center; opacity: 1; transition: opacity .6s ease; } /* 主图加载完成后由 JS 加 loaded 类,叠一层真实背景 */ .layer-bg.loaded { background-image: url("../assets/img/bg.webp"), radial-gradient(circle at 50% 30%, #c8102e 0%, #7a0d18 70%); }transition: opacity .6s在这里是给后续可能加的淡入淡出留的口子,本身不触发重排。background-position: center保证不同宽高比下裁切位置一致,比top稳妥,因为节日页的视觉重心通常在中上部,裁底部不会伤到主体。
3. CSS 动效层怎么排:飘带、光晕与三层合成的参数表
CSS 动效是这类页面观感的主要来源,也是性能问题的主要来源。同样的飘带,用left做位移和用transform: translateX()做位移,在千元机上的帧率能差一倍。
3.1 三层结构:先把 z-index 和定位定死
动手写动画之前,先把层级和定位方式一次性定死,后面所有元素都往这个框架里塞。我一般用下面这套:
.stage { position: relative; width: 100%; min-height: 100vh; min-height: 100svh; /* 小视口单位,规避移动端地址栏高度抖动 */ overflow: hidden; /* 关键:把飘带的溢出裁掉,防止横向滚动条 */ } .layer { position: absolute; inset: 0; /* 等价于 top/right/bottom/left 全 0 */ } .layer-bg { z-index: 0; } .layer-fx { z-index: 1; pointer-events: none; } /* 画布不拦截点击 */ .layer-mid { z-index: 2; pointer-events: none; } .layer-content { z-index: 3; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2rem; padding: 0 1.5rem; text-align: center; }四个参数点值得单独说。overflow: hidden放在.stage上而不是body上,因为body上加overflow: hidden在部分移动端浏览器里会连带把整个页面的滚动锁死,节日页如果下面还有内容就滚不动了。pointer-events: none给layer-fx和layer-mid,是为了让点击穿透到按钮上——画布全屏铺开,不给这个属性,按钮永远点不到,这是新手最常踩的一个坑。min-height: 100svh是100vh的替代,移动端地址栏收缩展开时vh不变,会导致页面顶部被裁或者底部留白,svh取小值,稳。最后inset: 0比写四个方向属性短,兼容性现在没问题,但如果你要支持很老的浏览器,还是老实写四个属性。
3.2 关键帧只碰 transform 和 opacity
这条原则说起来简单,做起来要克制。节日页里飘带要飘、光晕要呼吸、标题要轻微浮动,全部只用transform和opacity两把刀:
/* 飘带:水平往复 + 轻微旋转,全程不碰 left/top */ @keyframes ribbon-drift { 0% { transform: translate3d(-10%, 0, 0) rotate(-6deg); } 50% { transform: translate3d(10%, -2%, 0) rotate(4deg); } 100% { transform: translate3d(-10%, 0, 0) rotate(-6deg); } } .ribbon { position: absolute; width: 60vw; height: 60vh; background: linear-gradient(135deg, rgba(255, 214, 120, .35), rgba(200, 16, 46, 0)); filter: blur(28px); /* 模糊只在静态时算一次 */ border-radius: 45% 55% 60% 40%; will-change: transform; /* 提前提升合成层 */ animation: ribbon-drift 18s ease-in-out infinite; } .ribbon-1 { top: -10%; left: -15%; animation-duration: 22s; } .ribbon-2 { bottom: -15%; right: -20%; animation-duration: 26s; animation-delay: -6s; } /* 光晕呼吸:只改 opacity,不缩放,避免边缘模糊抖动 */ @keyframes glow-breathe { 0%, 100% { opacity: .35; } 50% { opacity: .75; } } .glow { position: absolute; inset: 20% 10%; background: radial-gradient(circle, rgba(255, 220, 150, .8), transparent 65%); animation: glow-breathe 6s ease-in-out infinite; }translate3d而不是translateX,是为了显式触发 GPU 合成,代价很小但收益稳定。filter: blur(28px)写在不参与动画的属性里,浏览器会把元素先模糊再合成,模糊本身只算一次;如果你把filter的数值写进关键帧,每一帧都要重新算模糊,千元机直接跪。animation-delay: -6s是负值,作用是让第二条飘带从动画中途开始,两条飘带不会同步摆动,省掉一个随机化的 JS。
will-change: transform要克制用。它会让浏览器提前创建合成层,层多了内存吃紧,低端机上反而掉帧。我的经验是只给「持续动画 + 面积超过视口 30%」的元素加,其他元素交给浏览器自己判断。动画结束后如果元素不再动,用 JS 把will-change删掉,别一直挂着。
3.3 动画参数表与时长节奏
节奏感靠参数表管,不靠手感。同一页里所有动画的时长最好落在同一个数量级上,否则看起来各动各的。我常用的配置如下:
| 元素 | 属性 | 时长 | 缓动 | 延迟 | 说明 |
|---|---|---|---|---|---|
| 飘带 1 | transform | 22s | ease-in-out | 0 | 大面积慢速,撑住氛围 |
| 飘带 2 | transform | 26s | ease-in-out | -6s | 与飘带 1 错开相位 |
| 光晕 | opacity | 6s | ease-in-out | 0 | 呼吸感,不要低于 4s |
| 标题 | transform | 3.2s | cubic-bezier(.4,0,.2,1) | 0 | 轻微上下浮动,幅度 ≤ 6px |
| 按钮 | transform | 2.4s | ease-in-out | 0 | 缩放 1 到 1.04,幅度再大就廉价 |
| 文案入场 | opacity + transform | .8s | cubic-bezier(.2,.8,.2,1) | .2s | 一次性,不循环 |
表里最容易被忽略的是「幅度」。标题浮动超过 6px、按钮缩放超过 1.05,观感立刻从「精致」变成「页游」。慢速大面积的动画(飘带、光晕)负责氛围,快速小幅度的动画(按钮)负责反馈,两类动画的时长至少差一个数量级,这是节奏感的来源。
另外,页面上的循环动画总数建议控制在 6 个以内。超过这个数,低端机的合成线程压力明显上来,而且视觉上会显得乱。如果确实需要更多装饰,用静态图代替动画元素,成本几乎为零。
3.4 低端设备与减弱动效的降级路径
有两个降级必须做。第一个是系统级的「减弱动态效果」,用户在系统设置里开了这个开关,页面就不该继续播放循环动画:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } /* 保留静态视觉,只停掉运动 */ .layer-mid { display: none; } #fx { display: none; } /* 画布直接隐藏,省掉粒子开销 */ }这里用!important是合理的,降级优先级最高,压过所有具体规则。注意画布是直接display: none,不是暂停——暂停了 rAF 还在跑,白烧电。JS 侧也要配合,读一次matchMedia('(prefers-reduced-motion: reduce)').matches,为真就不初始化粒子系统,双重保险。
第二个降级是设备能力。低端机的判定不用太精细,用navigator.hardwareConcurrency和deviceMemory粗略估一下就够:
// 设备能力粗判:核心数少或内存小,直接降低粒子预算 function getParticleBudget() { const cores = navigator.hardwareConcurrency || 4; const mem = navigator.deviceMemory || 4; // 单位 GB,部分浏览器不支持 if (cores <= 4 || mem <= 2) return 40; // 低端:少量粒子 if (cores <= 6) return 90; // 中端 return 150; // 高端 }hardwareConcurrency在部分浏览器里会返回固定的假值,deviceMemory支持面更窄,所以这段只能当参考,真正的兜底还是靠帧率监测:跑起来之后统计每帧耗时,连续 30 帧超过 22ms 就自动把粒子预算砍半。这个自适应逻辑比任何静态判定都可靠,后面第 4 章会写具体实现。
4. Canvas 烟花粒子:DPR 适配、对象池与 rAF 帧控
烟花是这类页面的视觉重心,也是最容易写成性能黑洞的地方。见过太多实现是每次点击new一堆粒子对象、每帧创建数组、每帧ctx.clearRect之外还加shadowBlur——单看代码没毛病,跑在千元机上就是灾难。
4.1 画布尺寸与设备像素比的一次性校准
Canvas 的尺寸有两个概念:CSS 尺寸(元素占多大)和位图尺寸(画多少像素)。不处理 DPR,高分屏上线条糊成一团;处理错了,位图尺寸爆炸,内存直接翻四倍。
const canvas = document.getElementById('fx'); const ctx = canvas.getContext('2d', { alpha: true }); let W = 0, H = 0, DPR = 1; function resizeCanvas() { // clientWidth 是 CSS 像素,乘 DPR 得到物理像素 DPR = Math.min(window.devicePixelRatio || 1, 2); // 上限 2,3x 屏收益极低 W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR); // 用 setTransform 而不是 scale,避免多次 resize 后变换叠加 ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } // 首次校准 + 尺寸变化时重算 resizeCanvas(); window.addEventListener('resize', debounce(resizeCanvas, 200));三个细节决定了这段代码能不能长期稳定。第一,DPR上限锁 2,3x 屏上画布像素数比 2x 多一倍多,肉眼几乎看不出差别,但内存和填充率压力实实在在,这是性价比最高的一个截断。第二,用setTransform而不是ctx.scale(DPR, DPR),因为scale是叠加的,屏幕旋转几次之后变换矩阵会累乘,画面越缩越小。第三,resize必须防抖,移动端地址栏收缩会连续触发 resize,不防抖就是连续重设位图,卡顿的元凶。
还有一个隐性问题:canvas.clientWidth在元素还没布局完成时是 0,这时候设canvas.width = 0,后面所有绘制都是空操作,表现就是「画布一片空白」。所以resizeCanvas()的调用时机必须在 DOM 就绪之后,defer脚本天然满足;如果画布所在的容器是动态显示的,还得用ResizeObserver监听容器尺寸,别指望window.resize。
4.2 粒子对象与对象池复用
一次烟花爆炸产生 60 到 120 个粒子,用户连点五下就是几百个对象。如果每次new Particle(),垃圾回收会在最容易掉帧的时候触发。常用做法是对象池:预分配固定数量的粒子,用完归还,永不新建。
// 对象池:预分配,状态复用,避免运行期 GC const POOL_SIZE = 600; const pool = []; for (let i = 0; i < POOL_SIZE; i++) { pool.push({ alive: false, x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 0, hue: 0, size: 0 }); } function spawn(x, y, count) { let created = 0; for (let i = 0; i < pool.length && created < count; i++) { const p = pool[i]; if (p.alive) continue; // 跳过在用的 const angle = Math.random() * Math.PI * 2; const speed = 1.8 + Math.random() * 4.2; // 速度区间决定爆炸半径 p.alive = true; p.x = x; p.y = y; p.vx = Math.cos(angle) * speed; p.vy = Math.sin(angle) * speed; p.maxLife = 45 + Math.random() * 35; // 单位:帧 p.life = p.maxLife; p.hue = 20 + Math.random() * 30; // 暖金到橙红的色相区间 p.size = 1 + Math.random() * 2; created++; } return created; // 返回实际生成数,池满时可用于降级判断 }speed区间1.8 ~ 6.0是调出来的:低于 1.8 爆炸半径太小像一团糊,高于 6 粒子飞出画面浪费。maxLife用帧数而不是毫秒,配合固定步长积分更简单;如果你后面改成时间驱动,记得把这几个值换算一下。hue限制在暖色区间,冷色系混进去会破坏节日氛围,这个属于美术决定,改之前先看一眼整体色调。
speed和maxLife的乘积决定视觉半径,调参时这两个要一起动。半径 = 速度 × 生命周期 × 阻尼衰减系数,vy每帧加一点重力,实际轨迹是抛物线,比直线更耐看:
function update(dt) { for (let i = 0; i < pool.length; i++) { const p = pool[i]; if (!p.alive) continue; p.vy += 0.06; // 重力加速度,数值越大下落越快 p.vx *= 0.985; // 空气阻力,越接近 1 滑行越远 p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.life -= 1; if (p.life <= 0 || p.y > H + 40) p.alive = false; // 归池 } }阻尼系数0.985每帧乘一次,60fps 下跑一秒衰减到约 0.4,视觉上是「先快后慢」的自然减速。p.y > H + 40这个兜底判断不能省,粒子飞出视口下方后如果还活着,会一直在池里占坑,点几次按钮池就满了。
4.3 主循环:dt 驱动与帧率上限
requestAnimationFrame的调用频率跟着屏幕刷新率走,120Hz 屏上动画速度是 60Hz 的两倍。所以主循环必须按时间差推进,不能按帧数硬编码。
let last = 0; let running = false; const FRAME = 1000 / 60; // 目标 60fps,120Hz 屏上做节流 function loop(now) { if (!running) return; const elapsed = now - last; if (elapsed < FRAME - 1) { // 不足一帧,跳过绘制 requestAnimationFrame(loop); return; } last = now; // dt 归一化到 60fps 的倍数,update 内部仍可按「帧」写 const dt = Math.min(elapsed / FRAME, 2); // 上限 2,防止切回标签页时瞬移 ctx.clearRect(0, 0, W, H); update(dt); draw(); requestAnimationFrame(loop); }Math.min(elapsed / FRAME, 2)这一行是黑匣子级别的细节。用户切到别的标签页再切回来,elapsed可能是几万毫秒,不截断的话所有粒子一帧内飞出天际,画面直接崩。截断成 2 倍帧,最多是「快进一点点」,可以接受。
页面不可见时要主动停掉循环,这是省电的关键:
document.addEventListener('visibilitychange', () => { if (document.hidden) { running = false; // 停止调度 } else { running = true; last = performance.now(); // 重置时间基准,避免首帧 dt 巨大 requestAnimationFrame(loop); } });注意重新启动时一定要重置last,否则第一帧的elapsed不可控。这个last的重置我在早期的版本里漏过一次,表现是「切回页面后烟花瞬移一下」,很难复现,查了很久。
4.4 点击与触摸的触发点换算
画布有 DPR 缩放,坐标换算必须用getBoundingClientRect而不是offsetX/offsetY,后者在不同浏览器里对滚动和变换的处理不一致。
function handleFire(clientX, clientY) { const rect = canvas.getBoundingClientRect(); // 转成 CSS 像素坐标,与 ctx 的 setTransform 对应 const x = clientX - rect.left; const y = clientY - rect.top; if (x < 0 || x > W || y < 0 || y > H) return; // 边界外丢弃 spawn(x, y, getParticleBudget()); } // 指针事件统一处理鼠标、触摸、手写笔 canvas.addEventListener('pointerdown', (e) => { handleFire(e.clientX, e.clientY); }); // 按钮点击:在页面上方随机位置爆一朵 document.querySelector('.btn-fire')?.addEventListener('click', () => { handleFire(W * (0.25 + Math.random() * 0.5), H * (0.2 + Math.random() * 0.3)); });pointerdown一个事件覆盖三种输入设备,比同时绑mousedown和touchstart干净得多,也避免了触摸设备上「先触发 touch 再触发 mouse」的双重触发。坐标用clientX/Y减去rect.left/top,得到的是相对画布的 CSS 像素坐标,和setTransform(DPR, ...)后的逻辑坐标系一致,不需要再乘 DPR。
按钮触发的位置限制在画面中上部0.2 ~ 0.5的纵向区间,是因为烟花往下落的时候需要有空间展示弹道,贴着顶部炸开,粒子一半飞出画面,观感大打折扣。横向0.25 ~ 0.75是避开左右两侧的安全边距。
5. 排查与避坑:节日页上线前最容易翻车的五个地方
下面这五条全是我在不同项目里真实踩过的,按出现频率排序。每条都按「现象、原因、解决」写,遇到同类问题直接对号入座。
5.1 移动端出现横向滚动条,怎么拖都能往右移一屏
现象:桌面端正常,手机上整页可以左右拖动,右侧露出一条空白。
原因:飘带元素用了left: -15%和width: 60vw,超出了视口宽度,.stage上没有裁切。很多人第一反应是给body加overflow-x: hidden,这在 iOS Safari 上有时不生效,因为body的滚动容器是html,而且加了之后可能连带锁死纵向滚动。
解决:把裁切放在舞台容器上,overflow: hidden写在.stage里,同时确认.stage有position: relative。如果页面下方还有内容需要滚动,舞台容器必须限制高度(用min-height: 100svh而不是height: 100%),把滚动交给外层容器。另外60vw这类视口单位在页面出现纵向滚动条时会比可视宽度大一点点,桌面端也可能触发横向滚动,稳妥写法是width: 60%配合父容器的百分比宽。
5.2 低端安卓一进页面就发烫,动画还一顿一顿
现象:iPhone 上丝滑,两三千的安卓机上帧率掉到 20 出头,手机背面明显发热。
原因:八成是动画属性没选对。检查关键帧里有没有box-shadow、filter、width/height、top/left参与动画。box-shadow的每一帧都要重新计算阴影模糊,代价极高;filter: blur()写在关键帧里同理。另一个常见原因是will-change加得太滥,几十个元素全部提升合成层,GPU 内存吃满后反而更慢。
解决:动画属性收敛到transform和opacity两个。确实需要模糊的,把filter写在静态样式里,让浏览器只算一次。will-change只给面积大、持续动画的元素加,并且用 JS 在动画结束后移除。改完再用 Chrome DevTools 的 Performance 面板录一段,看有没有长任务和强制同步布局,通常能定位到具体是哪个元素。
5.3 画布在部分内置浏览器里一片空白
现象:外部浏览器正常,从聊天软件里点开链接,画布区域全黑或者透明,什么都不显示。
原因:canvas.clientWidth在初始化时读到 0。这类内置浏览器对defer脚本的执行时机、以及首屏布局完成的时间判断,和标准浏览器有差异;另外画布容器如果被设了display: none或者父级高度为 0,clientWidth一样是 0。
解决:初始化前加一道尺寸校验,读到 0 就延迟重试,同时用ResizeObserver兜底:
function initCanvas(retry = 0) { const w = canvas.clientWidth; const h = canvas.clientHeight; if ((w === 0 || h === 0) && retry < 10) { // 布局未完成,下一帧重试,最多 10 次 requestAnimationFrame(() => initCanvas(retry + 1)); return; } resizeCanvas(); running = true; requestAnimationFrame(loop); } initCanvas();重试上限设 10 次(约 160ms),超过就放弃初始化,让页面退化成一个没有烟花的静态页,而不是卡在循环里。同时给.layer-fx一个兜底背景色或者静态图,画布失效时视觉上不至于开天窗。
5.4 首屏白屏一两秒,背景图最后才跳出来
现象:弱网下打开,先是白屏,然后文字突然出现,最后背景图才补上,观感割裂。
原因:背景图没压缩、没预加载,而且被 CSS 的background-image引用——浏览器要等 CSS 解析完才开始下载,天然晚一步。图片如果是几 MB 的 PNG,弱网下就是几秒。
解决:三步。第一步压缩,背景图转 webp,控制在 150KB 以内,必要时降分辨率再用 CSS 拉伸。第二步给.layer-bg铺兜底渐变,让颜色先到。第三步对首屏大图加preload:
<link rel="preload" as="image" href="assets/img/bg.webp" type="image/webp">不要在<head>里预加载超过两张图,会挤占关键 CSS 和 JS 的带宽,反而更慢。
5.5 切走标签页再切回来,烟花位置乱跳或者直接消失
现象:页面在后台放一会儿,切回来瞬间粒子全部消失,或者集中在角落。
原因:requestAnimationFrame在后台标签页会被节流甚至暂停,但visibilitychange里没做处理,切回来时elapsed是一个巨大的值,update里一帧把所有粒子推出边界,全部归池。
解决:如第 4 章写的,visibilitychange时停掉循环、重置last,并且dt做上限截断。另外粒子归池条件里加p.y > H + 40这种边界判断,本身也是一种容错,即使逻辑出错,也不会出现「粒子永远活着占坑」的情况。这一条我建议直接在代码里写死,不要指望用户不去切标签页。
6. 上线前的自检与一个换肤技巧:把主题色收进 CSS 变量
最后说一个我每次交付节日页都会做的事:把所有颜色收进:root的 CSS 变量,页面里任何地方都不写死色值。
:root { --c-primary: #c8102e; /* 主色,按钮、主题色 */ --c-accent: #ffd678; /* 点缀色,飘带、光晕 */ --c-deep: #7a0d18; /* 深色底,背景兜底 */ --c-text: #fff8ec; /* 正文色 */ --glow-size: 28px; /* 光晕模糊半径 */ --ribbon-dur: 22s; /* 飘带时长 */ } .btn-fire { background: var(--c-primary); color: var(--c-text); box-shadow: 0 0 var(--glow-size) rgba(255, 214, 120, .45); } /* 换肤:只改根节点变量,整页跟着变 */ body.theme-warm { --c-primary: #b8500f; --c-accent: #ffcf8a; --c-deep: #5c2a08; }这套写法的价值不在「能换主题」,在于改一版配色只需要动四行。节日专题页最常见的需求就是「今年换个色调」,客户临时改主色,五分钟能出第二版,不至于翻遍 CSS 找#c8102e。参数化还有个副作用好处:动效时长也做成变量之后,调节奏变成改一个数,比翻关键帧快得多。
上线前的自检我固定跑一遍:手机上真机打开看有没有横向滚动、点击按钮能不能触发、切到后台再切回来画面是否正常、关掉动画(系统减弱动态效果)后页面是否还有内容、断网状态下首屏是否至少有色块和文案。这五项过了,剩下的都是锦上添花。
有个教训我记了很久:早期做这类页面,我总觉得视觉越满越好,飘带叠三层、光晕加两个、画布粒子开到三百,验收时在演示机上效果拉满,交付后用户反馈「打开像幻灯片」。从那以后我每次交节日页,都强制先在最便宜的那台测试机上跑一遍,帧率不到 45 就直接砍装饰,哪怕效果图再好看。动效这东西,能跑满 60 帧的七十分视觉,永远胜过卡成 20 帧的一百分视觉。希望帮到你。
本文还有配套的精品资源,点击获取