不知道你有没有接过这种需求:产品经理拿着活动图过来说,要做一个“红包雨”,用户点开红包能领到优惠券,时间只排了三天。很多人第一反应是找现成插件,但真跑起来你会发现,插件要么体积大、要么样式改不动、要么在安卓低端机上直接卡成幻灯片。这个看起来“不就是飘点红包”的效果,实际做起来全是细节。这篇文章我就把CSS3红包雨效果从需求拆解、参数设计到性能优化完整梳理一遍,很多坑是我自己跑项目时踩过的,希望对做活动页、游戏化交互的同学有帮助。
1. 红包雨效果的核心逻辑与技术选型
1.1 先拆清楚需求,再写第一行代码
红包雨不是“页面上飘几个红包图”那么简单。如果把需求拆开,它至少包含四个模块:红包生成器、移动引擎、点击反馈、结算逻辑。生成器决定红包从哪里出现、以什么频率出现;移动引擎决定红包怎么掉、掉多久;点击反馈负责用户按下去之后的开箱动画和结果展示;结算逻辑则是把优惠券、积分、奖品数据落到业务系统里。
很多初次实现的人会漏掉第四块,导致Demo里点开红包只能弹个alert,一接真实接口就手忙脚乱。我在做某个模拟项目X时,第一版就把生成和移动做得很炫,结果点击后需要等待接口返回,用户连续点红包导致重复请求,被测试提了“严重”级别的Bug。所以动手前,我强烈建议先画一条完整链路:红包生成 -> 掉落 -> 点击 -> 加载状态 -> 结果展示 -> 关闭后的剩余红包处理。
1.2 三种实现方案:纯CSS、CSS3加少量JS、Canvas 全量JS
红包雨常见的实现方案有三类,先说结论,再做对比。
- 纯CSS方案:用多个
animation定义红包下落、旋转,用定时器或者随机延迟制造错落感。优点是代码量小,但做不出复杂的点击结算和动态生成,只适合静态展示。 - CSS3动画 + JS调度:由JS负责创建红包节点、控制生命周期,CSS负责位移和视觉动画。这是我最推荐的做法,兼顾性能和维护性。
- Canvas方案:用Canvas绘制红包和粒子,适合红包数量非常多的情况,比如上百个金币烟花同时出现。缺点是事件命中、文本布局都更麻烦,开发成本明显高。
三者对比如下:
| 方案 | 开发成本 | 动画性能 | 交互粒度 | 适用场景 |
|---|---|---|---|---|
| 纯CSS | 低 | 中高 | 弱 | 展示型页面、简单飘落 |
| CSS3 + JS | 中 | 高 | 强 | 活动页、领券、抽奖 |
| Canvas | 高 | 很高 | 中 | 重特效、粒子量大的场景 |
很多团队喜欢一上来就Canvas,但忽略了一个事实:红包雨的用户核心动作是“点红包”,这个动作天然依赖DOM事件。CSS3方案里每个红包是一个独立DOM节点,命中判断、点击回调、动画暂停都很好做。Canvas虽然能把粒子数量堆上去,但每个红包都要自己做热区判断,代码复杂度翻倍。除非你需要一次性飘几百个红包,否则不要迷信Canvas。
1.3 为什么“动画交给CSS,逻辑交给JS”是性价比最高的组合
浏览器对CSS动画做了非常深的优化,尤其是transform和opacity,可以走独立的合成器线程,不会反复触发布局和绘制。而JS如果手动去改top、left驱动动画,每帧都会触发重排,节点一多就卡。所以我的分工原则是:CSS负责“怎么动”,JS负责“什么时候动、动完之后怎么办”。
具体来说,每个红包节点通过CSS类切换来启动动画,动画时长、延迟、路径由JS动态写入行内样式。这样既保留了CSS动画的流畅性,又能让JS精确控制每个红包的出现时机和回收时机。这套组合在后来的几次大型活动页面中实测下来非常稳,主流浏览器帧率都能维持在50帧以上。
2. 开工前必须敲定的参数与素材准备
2.1 红包雨的核心参数怎么定
红包雨效果想做得自然,不能所有红包都走同一条直线。需要调整的参数包括下落时长、下落速度、红包尺寸、生成间隔、水平偏移、旋转角度、透明度变化。初次开发的人最容易做成一排“匀速下落的矩形”,看起来很机械。
我常给活动页定的一组初始参数是:红包宽度48px、高度64px,下落时长4.5秒到8秒之间随机,每秒生成2到5个红包,水平偏移在容器宽度的10%到90%之间随机,旋转角度在-45度到45度之间随机,透明度从0.7到1之间随机。这套参数的体验比较友好:时间太长用户会等得不耐烦,时间太短点击难度又高。
这里可以给一个简单估算:如果想让屏幕同一时间保持大约20个红包,每秒生成数量乘以平均下落秒数就要接近20。比如平均下落时长6秒,每秒生成3个,那么屏幕上稳定维持在18个左右。这个公式能帮你反过来推算生成频率,不必反复试错。
2.2 参数不是拍脑袋,要考虑点击场景
把参数定为“每秒生成3个、下落6秒”之后,还要算一下用户点击的容错率。红包下落速度如果超过每秒300像素,用户手指移动速度跟不上,尤其是手机上一屏很窄,很容易点空。根据我的经验,移动端下落速度控制在80到240像素每秒比较合适,屏幕越大可以适当提速。
如果活动目标是“让用户尽量领到券”,命中率就要高一些,此时红包要大一点、密一点,速度慢一点。如果目标是“制造紧张感”,就反过来处理。这些都要在开发前想清楚,否则做完再改参数,代码本身改动不大,但要重新适配动画曲线和结算预期,容易留下隐患。
2.3 素材准备与容器布局的细节
红包素材建议使用两倍图甚至三倍图,避免高分屏下发虚。可以把红包的三种状态——普通、按压、拆开——合并进一张雪碧图,减少图片请求数。尤其是活动页加载环境复杂,图片请求越少越稳。
容器方面,我推荐用全屏定位的div作为红包容器,并且设置overflow: hidden,避免红包在屏幕边缘露头。背景层和红包层分离,背景负责视觉氛围,红包层只做逻辑容器。这里有一个容易被忽略的点:容器要设置pointer-events: none,但红包节点本身要恢复pointer-events: auto,否则点击事件会被容器吃掉。
3. 从零实现红包雨的核心逻辑
3.1 页面结构:容器、红包节点与基础样式
先搭一个最简单的页面骨架,不需要任何框架,原生HTML加CSS加JS就能跑。这也是我排查问题时的习惯:先保证最小可运行版本,再叠加复杂功能。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>红包雨效果</title> <style> #redpacket-rain { position: fixed; inset: 0; overflow: hidden; pointer-events: none; background: linear-gradient(180deg, #a00, #600); } .redpacket { position: absolute; width: 48px; height: 64px; background: url('redpacket.png') no-repeat center / contain; pointer-events: auto; cursor: pointer; will-change: transform; } </style> </head> <body> <div id="redpacket-rain"></div> <script> // 红包雨逻辑 </script> </body> </html>position: fixed的好处是不受页面滚动影响。如果需求是红包雨出现在页面某个区域,也可以改成absolute,但父容器要设置position: relative。另外inset: 0是top/right/bottom/left的简写,兼容性在近几个浏览器中都没问题。
3.2 节点池:避免频繁创建和销毁DOM
如果每秒生成3到5个红包,每个红包存活6秒,一分钟下来就会有超过200个节点创建又销毁。频繁操作DOM会带来明显的GC压力和卡顿。解决办法是节点池复用:提前创建一批红包节点,用的时候从池子里取,动画结束后还回去。
我在实际项目中会维护一个数组和一个空闲队列。节点池大小设置为峰值节点数加20%的余量,例如屏幕同时最多20个红包,池子就建25个。用DocumentFragment批量创建节点,一次性挂载到容器下,比循环appendChild快不少。
const pool = []; const activeNodes = []; const MAX_POOL_SIZE = 25; function getRedPacketNode() { if (pool.length > 0) { return pool.pop(); } return createRedPacketNode(); } function recycleRedPacketNode(node) { node.style.display = 'none'; activeNodes.splice(activeNodes.indexOf(node), 1); if (pool.length < MAX_POOL_SIZE) { pool.push(node); } else { node.parentNode && node.parentNode.removeChild(node); } }池子满了才真正移除节点,这个细节很关键。如果每回收一个就移除一个,池子永远攒不下来,复用效果就没了。
3.3 用 requestAnimationFrame 调度红包下落
红包位置更新的核心循环用requestAnimationFrame,不要用setInterval。requestAnimationFrame会自动匹配屏幕刷新率,并且当页面切到后台时会暂停,节省资源。setInterval在后台标签页里依然会执行,时间一长容易积累大量回调,导致回前台时动画瞬间跳变。
时钟循环里要做三件事:更新每个活跃红包的纵向位置、判断是否超出屏幕底部、超出的节点回收到池中。红包的横向飘移和旋转交给CSS动画处理,JS只更新transform: translateY()。
let lastTime = 0; let graceMS = 0; function tick(now) { const delta = now - lastTime; lastTime = now; graceMS += delta; for (let i = activeNodes.length - 1; i >= 0; i--) { const node = activeNodes[i]; const rect = node.getBoundingClientRect(); if (rect.top > window.innerHeight) { recycleRedPacketNode(node); } } if (graceMS >= 1200) { spawnRedPacket(); graceMS = 0; } requestAnimationFrame(tick); } requestAnimationFrame(tick);requestAnimationFrame的回调参数是当前时间戳,用它做帧间隔计算,就能在不同刷新率的屏幕上保持一致速度。生成红包的间隔我用一个graceMS累积变量控制,避免额外再开一个计时器。这里生成间隔定在1.2秒,实际项目里可以调成随机间隔。
3.4 红包生成函数的写法与随机策略
生成红包时,要让每个红包的位置、速度、旋转角度都不同。最简单的做法是把这些值存到节点的dataset里,后续动画循环读取。
function spawnRedPacket() { const node = getRedPacketNode(); const duration = 4.5 + Math.random() * 3.5; const left = 8 + Math.random() * 84; const rotate = -45 + Math.random() * 90; node.style.display = 'block'; node.style.left = left + '%'; node.style.transform = 'translateY(-100px) rotate(' + rotate + 'deg)'; node.style.transition = 'transform ' + duration + 's linear'; node.dataset.duration = duration; activeNodes.push(node); requestAnimationFrame(() => { const targetY = window.innerHeight + 120; node.style.transform = 'translateY(' + targetY + 'px) rotate(' + rotate + 'deg)'; }); }这里用两层requestAnimationFrame,第一层确保节点先渲染到初始位置,第二层再触发位移,浏览器才能正确执行CSS过渡。如果只写一次,浏览器会把初始状态和终止状态合并优化,过渡动画可能不生效。这个细节我吃过亏,写出来提醒大家注意。
left为什么要用百分比?因为容器宽度在不同设备上不一样,百分比可以让红包随机分布相对均匀,而不是在超宽屏上挤在左侧。旋转角用rotate叠加在位移上,让红包在下降过程中有点自然的晃动感。
3.5 点击开红包与结算反馈
点击事件要绑定在红包节点上,而不是整个容器。每个红包被点击后,应该立即停止它的移动动画,再执行开红包动画。如果用纯CSS过渡驱动,最简单的暂停方式是清除transition并把当前位置固化成transform的值。
这里要提一句:不要把点击逻辑写在tick循环里。有人为了判断“点没点到红包”,每帧都检测触摸坐标,性能和代码可读性都很差。DOM节点自带的点击事件天然帮我们做了命中检测,我们只需要关心事件处理逻辑。
点击后我会做三步:先加一个banking状态位,防止同一红包被重复点击;然后发送请求或读取本地中奖配置;最后展示开奖浮层。展示期间红包雨要暂停或继续,看产品需求。如果希望暂停,可以在容器上设置pointer-events: none,并停止生成新红包,但已掉落的红包暂时保留。
节流也是必要的。用户手速快的时候,一秒内可能点很多个红包,如果不做频率限制,结算接口会被刷爆。我习惯在每个红包点击后加一个300毫秒的全局冷却,冷却期间点击只播放按压反馈,不触发结算。
3.6 让红包雨更好看的三个进阶细节
基础功能跑通之后,可以加几个低成本但提升明显的效果。第一个是红包下落时轻微左右摇摆,可以用CSS@keyframes给每个红包加一个横向偏移,比如sway动画。第二个是红包透明度随机,让前后层次有区分,有一种“有的红包远、有的红包近”的景深感。第三个是红包快要落地时做一个缩放和淡出,避免硬邦邦地消失。
下面是一个摇摆动画的简单写法:
@keyframes sway { 0% { margin-left: 0; } 50% { margin-left: 12px; } 100% { margin-left: 0; } } .redpacket { animation: sway 1.8s ease-in-out infinite; }需要说明的是,margin-left会触发布局,大量红包叠加使用会加重渲染负担。如果在意性能,建议把摇摆也合并到transform里,用translateX实现。上面的写法更适合调试阶段快速查看效果。
4. 红包雨的体验优化与移动端适配
4.1 性能优化:为什么你的红包雨一多就卡
红包雨的卡顿通常有三个原因:频繁触发重排、节点数量失控、大量阴影滤镜特效堆叠。先说重排,很多人写红包位移时用node.style.top = y + 'px',这个属性一修改就会触发布局计算。几十个节点同时改,帧率直接掉到30以下。正确做法是用transform: translateY(),因为transform不会触发布局,而是走合成层。
其次是节点数量。我见过有人为了好看,每秒生成20个红包,结果手机上同时存在100多个节点。即使每个节点只做transform动画,合成层数量太多也会超过GPU处理能力。建议把屏幕内红包数量上限控制在30到50个。
最后是滤镜和阴影。红包图片如果能直接用PNG或WebP,就不要加filter: drop-shadow(...),更不要加box-shadow到每一个运行中的节点上。这些效果在低端机上会大幅拉低帧率。如果一定要有光感,可以把背景光效做成静态图层,而不是跟在红包后面实时渲染。
4.2 用 will-change 和 transform 让动画走合成层
给红包节点声明will-change: transform,等于提前告诉浏览器这个元素的变换会被频繁修改,浏览器会把它提升到单独的合成层。但要注意,will-change不能滥用,如果所有节点都提升合成层,反而会耗尽GPU内存。建议只对动画中的节点加,回收时移除。
我在自己的项目里是这样做的:生成节点时设置will-change: transform,回收节点时清空transform并移除will-change。这样既保证了动画过程中的性能,又不会把无用的层留在页面上。移除的方式是把节点display: none,同时把willChange设为auto。
还有一个容易被忽略的地方:容器本身不要设置filter或perspective。有些同学为了让红包有3D感,在容器上加了perspective: 1000px,结果所有子元素都被强制创建合成层,移动端一下子多出几十个层,不卡才怪。3D效果如果非要加,建议只对少量红包加,或者用后台特效层实现。
4.3 移动端适配:触摸、安全区与双击缩放
移动端和PC端差异很大。首先要禁用双击缩放,否则用户快速点红包时屏幕会突然放大。最有效的办法是在viewport中设置user-scalable=no,同时监听touchstart时调用preventDefault。考虑到部分浏览器对user-scalable=no的兼容问题,建议在触摸事件里手动处理。
其次要关注刘海屏。红包雨容器是position: fixed; inset: 0,会覆盖整个屏幕,但在iPhone的刘海区域,状态栏和部分操作手势可能会冲突。如果红包需要避开顶部刘海,可以通过env(safe-area-inset-top)调整容器顶部内边距,或者把生成范围限制在可视安全区内。我一般会给红包生成区域设置一个顶部偏移量,比如顶部留出44像素的安全距离。
还有文字选择和长按图片问题。活动页里用户长按红包图,在部分安卓机上会弹出“保存图片”菜单,体验很糟。可以在全局样式里加上:
.redpacket { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-user-drag: none; }-webkit-touch-callout专门控制iOS长按呼出菜单,-webkit-user-drag控制拖拽图片。这四个声明组合起来能挡掉绝大部分误操作。
4.4 资源加载与弱网策略
红包雨依赖的图片和接口在弱网下容易出问题。图片如果没有加载完成,用户看到的就是一堆空白位置,点击也无效。我建议在红包节点生成前预加载所有红包图片,并在加载完成之前先显示一个占位背景。
接口请求方面,如果中奖结果是服务端下发的,要考虑请求失败时的降级策略。我做过一个方案:请求超时或失败时,展示一个“手气不错,再来一次”的提示,不发放实际奖励。这样既能保证游戏体验,又不会在弱网下产生错误发放。
5. 红包雨常见问题与排查经验
5.1 红包“卡住不动”的排查思路
红包卡住不动,十有八九是CSS过渡没有触发,而不是JS失效。我在3.4里提过,如果同一次逻辑里同时设置了初始态和终止态,浏览器会把两个状态合并,过渡失效。排查方法是在浏览器开发者工具中选中红包节点,看它的transition和transform的实际值。如果transform已经变成了目标值但没有动画过程,就是过渡触发方式不对,需要在下一帧再设置目标态。
还有可能是transition属性写在了通用样式中,又因为某个操作把transition覆盖成none。比如点击红包后为了暂停动画,我把transition清空,结果后续新生成的节点也继承了空transition,导致所有新红包都没动画。解决方式是在生成新红包时显式重新设置transition,不要依赖旧状态。
5.2 点击红包没反应的三种原因
第一个原因是容器遮住了节点。虽然容器设置了pointer-events: none,但如果某个背景层忘了关,就会挡住红包的点击。排查时可以在开发者工具里点击红包,看实际命中的元素是哪个。第二个原因是节点被回收太快,用户看到的红包还在,但节点的动画状态已经被重置。第三个原因是事件绑定丢失,尤其是节点池复用后监听器被移除。
一个很隐蔽的坑是:节点池复用时,display: none的节点依然会占用事件队列里的位置。点击事件的event.target可能指向已经隐藏的节点,导致看似点击无效。我建议在点击回调里先判断节点是否为null且display是否为none,再处理业务逻辑。
5.3 低端设备上的“最低体验”保障
低端机性能有限,不能和旗舰机用同一套效果。我会在页面初始化时读取navigator.hardwareConcurrency和屏幕尺寸,如果逻辑核数少于4,或者设备内存较小,就自动降低红包数量、关闭摇摆动画、缩短粒子的存活时间。这样做既保障了基本体验,又不会被测试在低端机上提“卡顿”类Bug。
代码里可以这样做一个降级标志:
const isLowEndDevice = navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4; const config = isLowEndDevice ? { spawnInterval: 1800, maxNodes: 15, enableSway: false } : { spawnInterval: 800, maxNodes: 30, enableSway: true };降级不是砍掉功能,而是让效果在低端设备上还能维持一个可用的帧率。实测下来,限制到15个节点后,千元安卓机的表现明显好转。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决操作 |
|---|---|---|
| 红包卡在屏幕顶部不动 | CSS过渡未触发 | 用双层requestAnimationFrame触发位移 |
| 红包全部落在左侧 | 使用像素定位而非百分比 | 改用百分比或结合屏幕宽度计算 |
| 页面滚动时红包跟着滚 | 容器用了absolute且父级可滚动 | 改为fixed并保证父级高度锁定 |
| 点击后没有开奖反馈 | 节点池复用导致事件丢失 | 在生成节点时重新绑定事件 |
| 动画掉帧严重 | 修改了top/left或加了阴影 | 改用transform和opacity,去掉滤镜 |
| iOS用户无法点击 | 安全区遮挡或callout弹层 | 增加safe-area边距并禁止user-select |
6. 一点实际使用中的补充心得
做这个CSS3红包雨效果,我最大的体会是:动画效果和业务逻辑要解耦。把动画当成纯粹的视觉表现层,把所有状态管理放到JS层,后期加需求或换素材时不会牵一发动全身。我维护的老版本代码就是因为把中奖概率写死在了CSS动画的回调里,导致改概率时不得不重写动画逻辑,非常痛苦。
第二个小心得是,宁可先把参数做成可配置项,也不要硬编码。我早期做红包雨时,把下落时长和生成间隔写死在代码里。后来测试反馈说想要的节奏不一样,我每次都要改代码重新发布。改成活动配置项之后,运营自己调参数就能达到想要的效果,省了很多沟通成本。
如果你接下来要做类似的效果,建议从一个小型可运行版本开始,先把基本的生成、掉落、点击、回收跑通,再逐步加摇摆、景深、降级策略。红包雨这个效果上手不难,但做到“好看、不卡、能上线”,还是要花不少心思的。