
不知道你有没有接过这种需求产品经理拿着活动图过来说要做一个“红包雨”用户点开红包能领到优惠券时间只排了三天。很多人第一反应是找现成插件但真跑起来你会发现插件要么体积大、要么样式改不动、要么在安卓低端机上直接卡成幻灯片。这个看起来“不就是飘点红包”的效果实际做起来全是细节。这篇文章我就把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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno 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 idredpacket-rain/div script // 红包雨逻辑 /script /body /htmlposition: 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 让红包雨更好看的三个进阶细节基础功能跑通之后可以加几个低成本但提升明显的效果。第一个是红包下落时轻微左右摇摆可以用CSSkeyframes给每个红包加一个横向偏移比如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-scalableno同时监听touchstart时调用preventDefault。考虑到部分浏览器对user-scalableno的兼容问题建议在触摸事件里手动处理。其次要关注刘海屏。红包雨容器是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-select6. 一点实际使用中的补充心得做这个CSS3红包雨效果我最大的体会是动画效果和业务逻辑要解耦。把动画当成纯粹的视觉表现层把所有状态管理放到JS层后期加需求或换素材时不会牵一发动全身。我维护的老版本代码就是因为把中奖概率写死在了CSS动画的回调里导致改概率时不得不重写动画逻辑非常痛苦。第二个小心得是宁可先把参数做成可配置项也不要硬编码。我早期做红包雨时把下落时长和生成间隔写死在代码里。后来测试反馈说想要的节奏不一样我每次都要改代码重新发布。改成活动配置项之后运营自己调参数就能达到想要的效果省了很多沟通成本。如果你接下来要做类似的效果建议从一个小型可运行版本开始先把基本的生成、掉落、点击、回收跑通再逐步加摇摆、景深、降级策略。红包雨这个效果上手不难但做到“好看、不卡、能上线”还是要花不少心思的。