原生Canvas 2D实现情人节烟花代码:单文件零依赖,可定制情侣生日礼物
1. 从一行标题拆解出的真实需求第一次看到“烟花代码情人节情侣生日礼物代码适用”这个标题我脑子里冒出来的不是“浪漫”而是一连串很实际的问题谁在用在什么设备上跑对方看到的是网页、是手机屏幕还是一段能发出去的链接代码是纯前端还是要跑脚本如果对方完全不懂技术怎么保证打开就能看到效果先把场景说透。这类内容的核心用户其实分三拨。第一拨是想给对象制造惊喜但自己不会写代码的人他们需要的是“复制粘贴就能用”的成品最好还能改名字、改日期、改一句告白。第二拨是刚学前端不久、想拿一个完整小项目练手的人他们关心的是动画怎么组织、粒子怎么算、性能怎么优化。第三拨是做小工具、做内容号的人他们想把烟花效果包装成一个可分享的页面节日当天发出去靠传播涨一波关注。这三拨人的需求叠在一起就决定了这个项目的形态它必须是一个单文件、零依赖、打开即用、可自定义文案的网页。为什么是单文件因为要发给别人多一个文件就多一道“怎么打开”的门槛。为什么零依赖因为对方网络环境不确定引用外部库一旦加载失败页面就是一片黑惊喜直接变尴尬。为什么可自定义因为“情人节”和“生日”的文案不一样名字不一样日期不一样不能写死。我见过太多人做这类东西一上来就堆库Three.js、GSAP、p5.js全上本地跑得飞起发给对象打开是白屏。问题就出在没想清楚“交付场景”。这个项目的本质不是技术炫技是一次性的情感交付稳定性优先级远高于效果复杂度。所以我的整体思路是用原生 Canvas 2D 做粒子系统用 requestAnimationFrame 驱动所有逻辑塞进一个 HTML 文件文案通过页面上的输入框或者直接改几行配置来定制。关键词里“烟花代码”是核心“情人节”“情侣生日礼物”是场景“代码适用”暗示了它要能适配不同场合。这几点合起来就是一篇完整的、可复现的实操记录。下面我按自己实际做这个项目的顺序把设计思路、核心算法、实操步骤和踩过的坑全部摊开讲。2. 整体设计与技术选型为什么不用现成库2.1 交付形态决定了技术栈做任何东西之前先问一句“这东西最终以什么形式交到用户手里”。烟花代码的交付形式只有三种可能一是直接发一个 HTML 文件对方双击打开二是部署成一个链接对方点开就能看三是录屏发视频。第三种最稳但最没诚意第一种最直接但受限于对方设备第二种体验最好但需要一点部署知识。我最终选择的是单 HTML 文件 可选部署的组合。单文件保证离线可用部署保证分享方便。既然要单文件就不能依赖构建工具不能有 npm不能有 import。所有代码必须是浏览器直接能跑的 ES5 或 ES6CSS 内联JS 内联。这就直接排除了 Three.js 这类体积大、需要额外加载的库。有人会问Canvas 2D 做烟花够不够我的实测结论是对于单次几十到几百个粒子的烟花Canvas 2D 完全够用甚至在移动端比 WebGL 更稳。WebGL 的优势在大量粒子和复杂光照但烟花这个场景粒子数量通常在几百到两千之间Canvas 2D 的 fillRect 和 globalCompositeOperation 足以做出漂亮的拖尾和发光效果。而且 Canvas 2D 的兼容性极好老手机、老浏览器都能跑这对“发给对象”这个场景太重要了。2.2 粒子系统的核心模型烟花效果拆开看就是三个阶段升空、爆炸、消散。升空阶段是一颗粒子从底部往上飞带一点左右摆动爆炸阶段是这颗粒子突然分裂成几十上百个小粒子向四周飞散消散阶段是这些小粒子受重力影响下落同时透明度衰减到零。这个模型里最关键的两个参数是速度向量和衰减系数。速度向量决定爆炸的形状衰减系数决定烟花“活”多久。我试过几种不同的爆炸分布最后选了极坐标均匀分布 随机扰动的方案。具体做法是爆炸时生成 N 个粒子每个粒子的角度是2π * i / N速度大小在基准值上下随机浮动 20%。这样出来的形状是一个比较均匀的圆但边缘有自然的毛刺感不会像数学圆那么死板。为什么不用完全随机因为完全随机的角度会导致粒子分布不均有的方向密有的方向疏看起来像“炸歪了”。均匀分布加扰动是兼顾美观和自然的最优解。这个结论是我对比了五六种分布方式之后得出的后面在实操部分会给出具体代码。2.3 性能与兼容性的取舍性能这块我踩过最大的坑是粒子数量失控。一开始我觉得粒子越多越壮观一次爆炸放 500 个同时放 10 个烟花就是 5000 个粒子每个粒子每帧都要重绘结果在中端手机上直接掉到 20 帧以下画面卡成幻灯片。后来我把单次爆炸粒子数压到 80 到 150 之间同时存活烟花数控制在 5 个以内帧率立刻回到 55 以上。兼容性方面有两个点必须注意。第一是globalCompositeOperation的lighter模式这个模式能让重叠的粒子变亮是做出“发光”效果的关键但部分老设备支持不好需要做降级。第二是devicePixelRatio在高分屏上如果不处理Canvas 会模糊处理了又增加渲染负担。我的做法是根据设备像素比动态设置 Canvas 尺寸但上限锁定在 2既保证清晰度又不至于让低端机爆掉。提示如果你的目标用户主要是用手机看建议把粒子数再降 30%并且关闭lighter模式改用普通叠加实测在千元机上更稳。3. 核心细节解析从一颗粒子到一场烟花3.1 升空粒子的运动逻辑升空粒子看起来简单其实有几个细节决定了“像不像”。第一是初速度太快像炮弹太慢像气球我实测下来垂直初速度在-8到-12像素每帧之间比较自然注意 Canvas 的 y 轴向下所以向上是负值。第二是重力加速度升空阶段重力要小大概0.05左右让粒子有一个减速的过程到最高点速度接近零时再爆炸这个“顿一下”的感觉是真实烟花的关键。第三是拖尾。真实烟花升空时后面会拖一条亮线这个效果在代码里不是靠画线实现的而是靠不清空画布、只叠加半透明遮罩实现的。具体做法是每帧用rgba(0,0,0,0.15)填充整个画布这样上一帧的粒子会残留一点形成拖尾。这个 0.15 的系数很讲究太大拖尾短太小画面糊我试了 0.1 到 0.3最后定在 0.18。// 升空粒子更新逻辑 particle.vy gravity; // 重力影响 particle.x particle.vx; // 水平移动 particle.y particle.vy; // 垂直移动 particle.life - 0.01; // 生命衰减 if (particle.vy 0 || particle.life 0) { explode(particle.x, particle.y); // 到达顶点或生命耗尽则爆炸 }这段逻辑里vy 0是判断到达最高点的条件因为向上是负值当速度变成正数说明开始下落此时爆炸最自然。这个判断比用固定高度判断更可靠因为不同初速度的粒子到达的高度不一样用速度判断能自适应。3.2 爆炸粒子的分布算法爆炸是整个效果的高潮分布算法直接决定好不好看。我前面说了用极坐标均匀分布加扰动具体实现是这样的function explode(x, y) { const count 100 Math.floor(Math.random() * 50); // 100到150个 const baseSpeed 3 Math.random() * 2; // 基准速度3到5 for (let i 0; i count; i) { const angle (Math.PI * 2 * i) / count; // 均匀角度 const speed baseSpeed * (0.8 Math.random() * 0.4); // 20%扰动 particles.push({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, color: getColor() }); } }这里有几个经验值。count我试过 50 到 30050 太稀疏像散架300 太密像一团糊100 到 150 是甜点区。baseSpeed决定爆炸范围3 到 5 在 800x600 的画布上大概炸开直径 300 到 400 像素视觉上比较饱满。扰动系数 0.4 是我反复调出来的再大形状就乱了再小就太规整。颜色方面我建议一次爆炸只用一种主色不要五颜六色混在一起。真实烟花也是一颗弹一种颜色混色反而廉价。颜色可以从一个预设的节日色板里随机取比如情人节用红粉紫生日用金蓝绿。3.3 重力、阻力与生命衰减爆炸后的粒子要下落要减速要消失这三个效果分别对应重力、阻力和生命衰减。重力我设的是0.08比升空阶段大让粒子有下坠感。阻力是每帧速度乘以0.98模拟空气摩擦让粒子飞出去之后慢慢停下来。生命衰减是每帧life - 0.012大概 80 帧左右消失也就是 1.3 秒这个时长刚好够看清形状又不拖沓。这三个参数是联动的。如果重力太大粒子还没散开就掉下去了形状不圆如果阻力太大粒子飞不远爆炸范围小如果生命衰减太快还没看清就没了。我建议的调试顺序是先调重力定下坠感再调阻力定范围最后调生命定时长。每次只动一个参数不然会互相干扰。注意生命衰减不要用线性减到 0 就删除最好在 life 低于 0.3 时开始加速衰减这样消失得更自然不会突然“啪”一下全没了。3.4 发光效果的实现与降级发光效果靠的是globalCompositeOperation lighter这个模式下颜色会叠加变亮重叠的粒子中心会发白看起来就像在发光。用法是在绘制粒子前设置这个模式绘制完再恢复成source-over。ctx.globalCompositeOperation lighter; particles.forEach(p { ctx.globalAlpha p.life; ctx.fillStyle p.color; ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, Math.PI * 2); ctx.fill(); }); ctx.globalCompositeOperation source-over;但lighter在部分低端设备上会导致颜色过曝整个画面变成一团白。我的降级方案是检测设备性能如果帧率持续低于 40就自动切换成普通模式并且把粒子半径从 2 降到 1.5。这个检测逻辑很简单记录最近 30 帧的平均耗时超过 25 毫秒就降级。4. 完整实操流程从零到可发送的成品4.1 文件结构与基础配置整个项目就是一个 HTML 文件结构分三块style放样式canvas放画布script放逻辑。样式部分要做的是让画布铺满全屏、去掉滚动条、背景纯黑。这里有个细节body的margin必须设为 0否则画布周围会有白边在黑背景下特别明显。!DOCTYPE html html head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title给你的惊喜/title style * { margin: 0; padding: 0; } body { background: #000; overflow: hidden; } canvas { display: block; } #msg { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 24px; font-family: sans-serif; text-align: center; pointer-events: none; opacity: 0; transition: opacity 1s; } /style /head body canvas idc/canvas div idmsg生日快乐/div script // 逻辑代码 /script /body /htmlviewport那行很关键user-scalableno是防止手机上双指缩放把画面搞乱。#msg是最后浮现的文案层用pointer-events: none让它不挡点击用opacity过渡做淡入。4.2 画布初始化与自适应画布初始化要处理三件事尺寸、像素比、窗口变化。尺寸用window.innerWidth和innerHeight像素比用devicePixelRatio但上限锁 2。为什么要锁 2因为有些设备像素比是 3 甚至 4按真实值设置画布会变得巨大渲染压力翻几倍而肉眼在 2 以上几乎看不出差别。const canvas document.getElementById(c); const ctx canvas.getContext(2d); let W, H; function resize() { const dpr Math.min(window.devicePixelRatio || 1, 2); W window.innerWidth; H window.innerHeight; canvas.width W * dpr; canvas.height H * dpr; canvas.style.width W px; canvas.style.height H px; ctx.scale(dpr, dpr); } window.addEventListener(resize, resize); resize();注意ctx.scale(dpr, dpr)这行它让后续所有绘制坐标都按逻辑像素来不用手动乘 dpr。但有个坑每次 resize 都会再 scale 一次导致累积放大。解决办法是在 resize 开头先ctx.setTransform(1, 0, 0, 1, 0, 0)重置变换矩阵再重新 scale。4.3 烟花发射与定时控制烟花的发射节奏很重要太密像打仗太疏像卡顿。我的方案是随机间隔发射间隔在 400 到 1200 毫秒之间随机这样既有节奏感又不机械。同时限制屏幕上同时存在的烟花数量超过 5 个就不再发射等有烟花消散了再补。let lastLaunch 0; let activeFireworks 0; function loop(timestamp) { requestAnimationFrame(loop); // 拖尾遮罩 ctx.fillStyle rgba(0, 0, 0, 0.18); ctx.fillRect(0, 0, W, H); // 发射控制 if (timestamp - lastLaunch 400 Math.random() * 800 activeFireworks 5) { launchFirework(); lastLaunch timestamp; activeFireworks; } updateAndDraw(); }activeFireworks的递减时机是在爆炸粒子全部消失时。这里要小心不能简单地在爆炸时递减因为升空粒子还在。我的做法是给每个烟花一个状态标记升空时算一个爆炸后粒子全部清空时再减。4.4 文案定制与浮现时机文案是这个项目的“情感落点”不能一开始就显示要等烟花放了几轮之后再淡入。我的做法是设置一个计时器页面加载后 3 秒开始显示文案用 CSS 过渡淡入。文案内容通过一个配置对象来改放在代码最前面方便不懂代码的人也能改。const CONFIG { message: 生日快乐, subMessage: 愿你年年有今日, duration: 8000, // 烟花持续时长 colors: [#ff4d6d, #ff85a1, #ffb3c6, #ffd6e0] };colors是情人节色板如果是生日可以换成金色系[#ffd700, #ffed4a, #fff59d]。duration控制烟花放多久到了时间就停止发射让现有烟花自然消散然后文案保持显示。提示文案的字体建议用系统默认的 sans-serif不要引用外部字体否则加载失败会变成宋体气质全无。4.5 部署与分享的两种方式做完之后怎么发给对方第一种是直接发 HTML 文件微信、邮件都能发对方收到后需要用浏览器打开。安卓上一般没问题iOS 上如果通过微信打开可能会被当成文本需要“用其他应用打开”。第二种是部署成链接找一个静态托管服务把 HTML 传上去得到一个网址发链接就行体验最好。部署这块我不展开讲具体平台只说原则选不需要登录、不需要备案、支持拖拽上传的静态托管。上传后得到一个 https 链接这个链接在任何设备上打开都能跑。如果对方网络不好链接加载慢可以在页面里加一个简单的 loading 提示虽然单文件加载很快但有个提示更稳妥。5. 常见问题与排查技巧实录5.1 画面全黑什么都没有这是最常见的问题原因通常有三个。第一是 Canvas 尺寸为 0检查resize函数是否在 DOM 加载后执行如果脚本放在head里document.getElementById会拿到 null。解决办法是把脚本放到/body前或者用DOMContentLoaded包裹。第二是requestAnimationFrame没启动检查loop函数是否被调用。第三是绘制颜色和背景一样检查fillStyle是否设成了黑色。排查顺序我建议从后往前先确认loop在跑在函数里console.log一下再确认画布尺寸打印W和H最后确认绘制颜色。这个顺序能最快定位问题。5.2 手机上卡顿掉帧卡顿的根源永远是粒子太多或绘制太重。排查方法是逐项减负先把粒子数减半看是否流畅再把lighter模式关掉看是否改善再把粒子半径从 2 降到 1看是否好转。三项都试完基本能定位瓶颈。我实测下来中端安卓机上粒子总数控制在 800 以内、关闭lighter、半径 1.5能稳定 50 帧以上。如果还卡那就是设备太老建议直接降级成静态图片加 CSS 动画虽然效果差但至少能看。5.3 拖尾效果变成一团糊拖尾糊的原因是遮罩透明度太低残留太多。rgba(0,0,0,0.18)是甜点值如果改成 0.05几帧之后整个屏幕都是亮的什么都看不清。解决办法是把遮罩透明度调高0.2 到 0.3 之间。但调太高拖尾就没了所以要在“有拖尾”和“不糊”之间找平衡。另一个原因是粒子生命太长残留的粒子叠加太多。检查life的衰减速度如果每帧只减 0.005粒子能活 200 帧肯定糊。建议每帧减 0.012 到 0.02。5.4 文案不显示或显示错位文案不显示通常是opacity没变检查 CSS 过渡和 JS 里设置opacity的时机。显示错位多半是transform和position配合问题position: fixed加top: 50%; left: 50%加transform: translate(-50%, -50%)是标准居中方案缺一不可。如果文案在手机上被状态栏挡住可以加padding-top: env(safe-area-inset-top)这是处理刘海屏的安全区域。虽然烟花项目不一定需要但加上更稳妥。5.5 常见问题速查表问题现象最可能原因排查动作解决方向画面全黑画布尺寸为0打印W和H调整脚本位置或resize时机卡顿掉帧粒子过多减半粒子数控制总数在800以内拖尾糊成一片遮罩透明度过低调高到0.2以上同时加快生命衰减文案不显示opacity未变检查过渡和JS赋值确认计时器触发爆炸形状不圆角度分布不均检查是否均匀分布用极坐标加扰动颜色过曝发白lighter模式过强关闭lighter测试降级为普通叠加高分屏模糊未处理像素比检查dpr设置设置dpr上限为2窗口缩放后错位变换矩阵累积检查scale调用resize前重置矩阵这张表是我自己踩坑之后整理的基本上覆盖了 90% 的问题。遇到新问题先对照这张表能省很多时间。5.6 几个不外传的调试技巧第一个技巧是用慢动作调试。把requestAnimationFrame换成setTimeout(loop, 200)整个动画变成 5 帧每秒能清楚看到每一帧粒子的位置和状态排查逻辑错误特别有效。调完再换回来。第二个技巧是给粒子画编号。在调试模式下在每个粒子旁边画一个序号能直观看到哪个粒子行为异常。这个技巧在排查“某个粒子不消失”这类问题时特别好用。第三个技巧是用颜色标记生命周期。把粒子的颜色按life值映射比如 life 高时偏红低时偏蓝一眼就能看出哪些粒子活得久。这个技巧帮我发现过一次生命衰减没生效的 bug。6. 效果扩展与个性化改造6.1 从单色到渐变色的升级单色烟花看久了会腻升级成渐变色很简单在爆炸时给每个粒子分配一个色相偏移让整个爆炸从一种颜色渐变到另一种。具体做法是记录爆炸中心的主色然后每个粒子的颜色根据角度做色相偏移。function getGradientColor(baseHue, angle) { const hue (baseHue angle * 30) % 360; return hsl(${hue}, 100%, 60%); }这样出来的效果是爆炸中心一种色边缘另一种色层次感立刻上来。但要注意色相偏移不要超过 60 度否则颜色太杂反而不好看。6.2 加入文字烟花文字烟花是把爆炸粒子的分布改成文字形状。原理是先在一个离屏 Canvas 上画出文字然后读取像素数据把有像素的位置作为粒子目标点。这个效果很惊艳但性能开销大因为粒子要飞向目标点需要额外的插值计算。我的建议是只在最后压轴时放一次文字烟花不要频繁放。文字内容建议控制在 4 个字以内字太多粒子太密手机上扛不住。实现时把文字渲染成 100x100 左右的离屏画布采样步长设为 2 像素这样粒子数能控制在 500 以内。6.3 配合音效的注意事项加音效能让体验提升一个档次但有个大坑浏览器不允许自动播放音频必须用户交互之后才能播。解决办法是在页面上放一个“点击开始”的按钮用户点了之后才启动烟花和音效。这个按钮可以做得很好看比如一个心形点一下才开始反而增加了仪式感。音效文件建议用短小的 mp3爆炸声和升空声分开用Audio对象预加载。音量不要太大0.3 到 0.5 之间太大会吓到人。6.4 适配不同节日的配置方案这个项目最大的价值是可复用。情人节用红粉色板加“喜欢你”生日用金色板加“生日快乐”纪念日用蓝紫色板加日期。把这些做成配置对象改几行就能换一个场景。const THEMES { valentine: { colors: [#ff4d6d, #ff85a1, #ffb3c6], message: 喜欢你, subMessage: 每一天都想见你 }, birthday: { colors: [#ffd700, #ffed4a, #fff59d], message: 生日快乐, subMessage: 愿你被世界温柔以待 }, anniversary: { colors: [#7b68ee, #9d8df1, #c3b9f7], message: 纪念日快乐, subMessage: 一起走过的第 N 年 } };用的时候const theme THEMES.valentine然后所有颜色和文案都从 theme 里取。这样一份代码能应付三个场景性价比很高。6.5 性能与效果的最终平衡点做了这么多扩展最后还是要回到平衡。我的最终配置是单次爆炸 120 个粒子同时最多 4 个烟花粒子半径 1.8开启 lighter 但带降级检测拖尾遮罩 0.18生命衰减 0.015。这套配置在中端手机上稳定 50 帧以上效果也足够好看。如果对方设备很老可以在页面加载时做一个简单的性能检测跑 60 帧空循环如果耗时超过 100 毫秒就自动切换到低配模式粒子数减半、关闭 lighter、半径降到 1.2。这个检测逻辑不复杂但能保证“最差也能看”。我个人在实际操作中的体会是这类情感向的代码项目稳定性永远比炫技重要。你做得再花哨对方打开是白屏一切归零。所以我的建议是先把基础版跑通、跑稳再考虑加文字烟花、加音效这些锦上添花的东西。基础版就是单文件、原生 Canvas、可改文案这三样做到位已经能应付绝大多数场景了。