news 2026/9/4 11:05:30

从零实现满屏爱心弹窗:CSS动画与DOM管理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现满屏爱心弹窗:CSS动画与DOM管理实战

你身边一定见过这样的程序员:平时话不多,但是遇到重要日子,习惯用代码表达心意。比起买一束花,他更愿意先打开编辑器,偷偷敲一段代码,然后在对方毫无防备的时候,屏幕上下起一场爱心雨。

这个“满屏爱心弹窗”的项目,听起来像是个整活小工具,但它背后的技术含量并没有想象中那么低。它至少涉及三块前端基础知识:浏览器弹窗机制、DOM 动态创建与销毁、CSS 动画与定时任务调度。把这三点吃透,你不但能做出一个让对象眼前一亮的表白页面,还能进一步把它改造成自己项目里真正可复用的“通知弹窗组件”。

这篇文章会做三件事:第一,带你从零实现一个满屏爱心弹窗效果,包括最简单的 alert 版本、更漂亮的 CSS 动画版本,以及一个可配置的封装版本;第二,把实现过程中的核心知识点拆开讲清楚,包括弹窗阻塞、动画性能、节点清理这些容易踩坑的地方;第三,给出常见问题排查表和工程化建议,确保你写完的代码既能感动人,也能经得起审查——毕竟,代码最终跑在别人的电脑上,安全边界和体验边界同样重要。

如果你正打算给喜欢的人准备一个惊喜,或者想把“爱心弹窗”当成一个练手项目,这篇文章可以直接照着做。

1. 这个项目真正要解决的问题

很多人第一次看到“爱心弹窗”的需求,会下意识觉得:不就是个 alert 循环吗?写个 for 循环,每 300 毫秒弹一次,弹 20 次,任务完成。

真跑起来你才发现,这条路根本走不通。浏览器一旦弹出 alert 对话框,JavaScript 的执行就被阻塞住了。后面的setTimeout不会按预期排队执行,用户必须一次一次手动点“确定”,体验极其糟糕。这不是“浪漫”,这是“折磨人”。

所以这个项目的真正难点不在“弹”,而在三个更深的问题上:

  1. 怎么弹:用系统原生弹窗,还是自己用 DOM 节点模拟?这个选择决定了整个交互体验。
  2. 怎么弹得自然:爱心不能简单粗暴地“啪”一下出现,最好有飘落、淡入、随机位置等动画效果,这需要 CSS 动画和 JS 定时器配合。
  3. 怎么收场:弹窗不能永远关不掉,更不能把浏览器搞到卡死。如果对方看完之后连关闭按钮都找不到,那惊喜就变成惊吓了。

文章后面所有的代码和方案,都围绕这三个问题展开。理解了这三个约束,你也就能理解为什么最终版的封装方式是这样设计的。

2. 核心原理:弹窗、动画和节点管理

在写代码之前,先用最直白的方式把三个核心概念理清。

2.1 弹窗的本质

前端里“弹窗”这个词,至少包含两种完全不同的实现方式:

  • 原生模态对话框alert()confirm()prompt()这类浏览器 API。它们由浏览器直接绘制,样式不可定制,且会阻塞页面脚本执行。
  • 自定义 DOM 弹窗:在页面中动态创建一个div,通过 CSS 给它设置固定定位、高 z-index、半透明背景,模拟出“弹窗”的效果。这种弹窗完全不阻塞页面,样式可定制,还可以添加动画。

爱心雨场景的体验要求决定了我们只能选择第二种方案。你需要一个不打断用户操作、可以持续播放动画、并且随时能优雅关闭的弹窗层。这些能力原生 alert 都给不了。

2.2 CSS 动画:从 A 点到 B 点,加上过程

CSS 动画的核心是@keyframes规则。你可以定义一组关键帧,让元素从初始状态逐步过渡到结束状态。

爱心雨的心形元素,通常会定义三类动画:

  • 飘落:元素从屏幕顶部运动到底部,改变transform: translateY()
  • 旋转:在飘落过程中让元素自我旋转,改变transform: rotate()
  • 淡入淡出:控制元素的opacity,让爱心出现和消失都更柔和。

CSS 动画的好处是性能好。浏览器会对transformopacity这类属性做合成器优化,动画过程不强制触发重排和重绘。相比之下,频繁修改lefttop会让页面在低性能设备上明显变卡。

2.3 DOM 节点管理:创建了,一定要销毁

爱心雨最容易被忽略的问题不是“怎么生成”,而是“怎么清理”。每颗爱心都是一个div节点,如果创建了 N 个节点但从不移除,页面上的 DOM 数量会持续膨胀。随着时间推移,浏览器内存占用会上升,动画帧率开始下降,最终页面可能失去响应。

正确做法是在动画结束后监听animationend事件,把对应的节点从 DOM 中移除。这就是“负责任的前端”和“写完就跑”的分水岭。

2.4 定时器与动画调度

JavaScript 创建“满屏很多个元素”的方式有两种常见选择:

  • setTimeout/setInterval:简单直观,适合控制生成节奏。
  • requestAnimationFrame:由浏览器在下次重绘之前调用回调,适合逐帧驱动连续动画。

在爱心雨场景中,我们主要用setTimeout来控制爱心的产生节奏,比如每 80 毫秒生成一个。动画本身的连续运动交给 CSS 处理,不需要 JS 每帧介入。这是最省资源也最稳定的组合。

3. 环境准备:一个记事本和一个浏览器就够了

这个项目的环境要求低到几乎没有门槛。

  • 操作系统:Windows / macOS / Linux 均可
  • 开发工具:记事本、VS Code、Sublime Text 任意一种文本编辑器
  • 浏览器:Chrome、Edge、Firefox 等现代浏览器,推荐 Chrome
  • 运行方式:直接双击 HTML 文件,或通过 VS Code 的 Live Server 插件启动本地服务
  • 基础语言:HTML、CSS、JavaScript

不需要安装 Node.js,不需要配置 npm,不需要引入任何第三方库。只要你的电脑能打开网页,就能跑通下面的代码。为了方便以后扩展,你也可以新建一个项目文件夹,把 HTML、CSS、JS 分别放在不同文件里。但是为了演示方便,这篇文章的前几个示例会使用单个 HTML 文件。

如果你希望用 VS Code 做热更新预览,在扩展商店安装 Live Server,右键 HTML 文件选择 “Open with Live Server” 即可。这一步不是必须的,但可以在改样式时明显提升效率。

4. 入门版:先看看为什么 alert 做不了这件事

先写一个“错误示范”,让你直观感受一下 alert 弹窗的问题。

新建一个 HTML 文件,粘贴下面的代码,然后用浏览器打开。

<!-- 文件路径:01-alert-demo.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Alert 爱心弹窗 - 错误示范</title> </head> <body> <script> const messages = ['❤️', '💕', '🌹', '💌', '我爱你']; for (let i = 0; i < 10; i++) { setTimeout(() => { alert(messages[i % messages.length]); }, i * 300); } </script> </body> </html>

你运行之后会发现问题:alert 确实弹了,但样式极其简陋,用户需要手动点 10 次确定,而且每次弹窗都会中断页面脚本,setTimeout的间隔往往比预期更长。用户体验可以用四个字总结:想要逃跑。

这个示例的核心价值在于演示一个结论:浏览器原生 alert 不适合做连续、可定制、带动画的弹窗场景。后面所有代码都会避开 alert,改用自定义 DOM 弹窗。

5. 进阶版:满屏爱心雨自定义弹窗

下面进入正题。这一版代码会创建一个全屏遮罩层,然后在遮罩层里随机生成无数颗爱心,让它们从屏幕上方飘落。整个效果是完全不阻塞的,用户可以随时点右上角的关闭按钮结束爱心雨。

5.1 完整代码

<!-- 文件路径:02-heart-rain.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>满屏爱心雨</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f7f0f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; } /* 触发按钮 */ #startBtn { border: none; outline: none; background: linear-gradient(135deg, #ff6b81, #ff4757); color: #fff; font-size: 18px; padding: 14px 32px; border-radius: 50px; cursor: pointer; box-shadow: 0 8px 24px rgba(255, 71, 87, 0.35); transition: transform 0.2s ease, box-shadow 0.2s ease; } #startBtn:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(255, 71, 87, 0.45); } /* 爱心雨遮罩层 */ #heartRainMask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(255, 240, 245, 0.85); z-index: 9999; display: none; overflow: hidden; pointer-events: auto; } #heartRainMask.show { display: block; } /* 关闭按钮 */ #closeBtn { position: absolute; top: 18px; right: 24px; z-index: 10001; background: rgba(255, 71, 87, 0.9); color: #fff; border: none; border-radius: 50%; width: 40px; height: 40px; font-size: 20px; line-height: 40px; text-align: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } /* 单颗爱心 */ .heart-item { position: absolute; top: -80px; user-select: none; pointer-events: none; animation-name: heartFall; animation-timing-function: linear; animation-fill-mode: forwards; } @keyframes heartFall { 0% { transform: translateY(0) rotate(0deg); opacity: 0.9; } 100% { transform: translateY(110vh) rotate(360deg); opacity: 0.2; } } </style> </head> <body> <button id="startBtn">点我开始下雨</button> <!-- 爱心雨遮罩 --> <div id="heartRainMask"> <button id="closeBtn">×</button> </div> <script> const mask = document.getElementById('heartRainMask'); const closeBtn = document.getElementById('closeBtn'); const startBtn = document.getElementById('startBtn'); const emojiList = ['❤️', '💕', '💖', '🌹', '💌', '💘']; const DEFAULT_COUNT = 40; // 爱心总数 // 创建一颗爱心并放入遮罩层 function createHeart() { const heart = document.createElement('div'); heart.className = 'heart-item'; // 随机选择表情 const emoji = emojiList[Math.floor(Math.random() * emojiList.length)]; heart.textContent = emoji; // 随机大小和位置 const fontSize = Math.floor(Math.random() * 32 + 28); // 28 ~ 60 px const left = Math.random() * 100; // 0 ~ 100 vw const duration = Math.random() * 4 + 4; // 4 ~ 8 秒 heart.style.fontSize = fontSize + 'px'; heart.style.left = left + 'vw'; heart.style.animationDuration = duration + 's'; mask.appendChild(heart); // 动画结束后移除节点,避免内存泄漏 heart.addEventListener('animationend', () => { heart.remove(); }); } // 启动爱心雨 function startHeartRain() { mask.classList.add('show'); for (let i = 0; i < DEFAULT_COUNT; i++) { setTimeout(() => { // 如果遮罩层已经被手动关闭,就不再生成新爱心 if (!mask.classList.contains('show')) return; createHeart(); }, i * 80); } } // 停止爱心雨 function stopHeartRain() { mask.classList.remove('show'); // 移除所有残留的爱心节点 const hearts = mask.querySelectorAll('.heart-item'); hearts.forEach((heart) => heart.remove()); } startBtn.addEventListener('click', startHeartRain); closeBtn.addEventListener('click', stopHeartRain); </script> </body> </html>

5.2 代码关键逻辑解读

这段代码的精髓在于三个细节。

第一,爱心节点的位置和动画时长全部随机。如果每颗爱心的大小、起始位置、下落速度完全一致,画面会显得像军队列队,毫无浪漫感。通过Math.random()控制fontSizeleftanimationDuration,每颗爱心的运动轨迹都不一样,整体效果才自然。

第二,遮罩层通过display: nonedisplay: block切换显示状态。display: none的元素不参与渲染,也不占据交互区域。停止爱心雨之后如果只是隐藏遮罩层,页面上的残留节点仍然存在;所以stopHeartRain()里还做了一次querySelectorAll清理,确保再次开启时不会叠加大量旧节点。

第三,animationend事件负责收尾。每颗爱心在动画跑完之后自动从 DOM 中移除。这样做既不会造成节点无限累积,也让浏览器可以及时回收不再需要的布局信息。

5.3 如何运行

把以上代码保存为.html文件,使用浏览器打开。点击页面中央的“点我开始下雨”按钮,你会看到一张淡粉色半透明遮罩层覆盖屏幕,随后爱心从顶部陆续出现并缓缓飘落。点击右上角的红色 × 按钮,爱心雨停止,页面恢复干净状态。

如果页面没有反应,先按 F12 打开开发者工具,查看 Console 面板有没有红色报错。最常见的问题是document.getElementById对应的元素 id 与 HTML 不一致,检查一下拼写即可。

6. 升级版:封装成可复用的爱心弹窗组件

上面的代码已经能跑,但它有一个明显的工程问题:所有变量都暴露在全局作用域,如果页面里还有其他 JavaScript 逻辑,很容易产生命名冲突。而且参数固定,不方便复用。

当你把“爱心弹窗”当成一个正经组件来写,需要考虑的事情就不一样了:调用方只需要一行代码就能启动、停止,所有细节都应该被封装在类内部。下面是一个简化但足够健壮的封装版本。

6.1 完整组件代码

// 文件路径:heart-rain.js class HeartRain { constructor(options = {}) { this.emojiList = options.emojiList || ['❤️', '💕', '💖', '🌹', '💌', '💘']; this.count = options.count || 40; this.maskColor = options.maskColor || 'rgba(255, 240, 245, 0.85)'; this.closeVisible = options.closeVisible !== false; this.mask = null; this.closeBtn = null; this.isRunning = false; } /** * 构建遮罩层 DOM */ buildMask() { this.mask = document.createElement('div'); this.mask.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: ${this.maskColor}; z-index: 9999; display: none; overflow: hidden; `; if (this.closeVisible) { this.closeBtn = document.createElement('button'); this.closeBtn.textContent = '×'; this.closeBtn.style.cssText = ` position: absolute; top: 18px; right: 24px; z-index: 10001; background: rgba(255, 71, 87, 0.9); color: #fff; border: none; border-radius: 50%; width: 40px; height: 40px; font-size: 20px; line-height: 40px; text-align: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); `; this.closeBtn.addEventListener('click', () => this.stop()); this.mask.appendChild(this.closeBtn); } document.body.appendChild(this.mask); } /** * 创建单颗爱心,并设置随机属性 */ createHeart() { const heart = document.createElement('div'); const emoji = this.emojiList[Math.floor(Math.random() * this.emojiList.length)]; const fontSize = Math.floor(Math.random() * 32 + 28); const left = Math.random() * 100; const duration = Math.random() * 4 + 4; heart.textContent = emoji; heart.style.position = 'absolute'; heart.style.top = '-80px'; heart.style.left = left + 'vw'; heart.style.fontSize = fontSize + 'px'; heart.style.userSelect = 'none'; heart.style.pointerEvents = 'none'; heart.style.animation = `heartFall ${duration}s linear forwards`; heart.style.opacity = '0.9'; heart.addEventListener('animationend', () => heart.remove()); this.mask.appendChild(heart); } /** * 启动爱心雨 */ start() { if (this.isRunning) return; this.isRunning = true; if (!this.mask) { this.buildMask(); } this.mask.style.display = 'block'; // 注入动画样式 const style = document.createElement('style'); style.textContent = ` @keyframes heartFall { 0% { transform: translateY(0) rotate(0deg); opacity: 0.9; } 100% { transform: translateY(110vh) rotate(360deg); opacity: 0.1; } } `; document.head.appendChild(style); for (let i = 0; i < this.count; i++) { setTimeout(() => { if (!this.isRunning) return; this.createHeart(); }, i * 80); } } /** * 停止并清理爱心雨 */ stop() { this.isRunning = false; if (this.mask) { this.mask.style.display = 'none'; const hearts = this.mask.querySelectorAll('div'); hearts.forEach((heart) => { if (heart !== this.closeBtn) { heart.remove(); } }); } } }

6.2 调用方式

<!-- 文件路径:03-component-demo.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>HeartRain 组件调用示例</title> </head> <body> <button id="openBtn">打开爱心弹窗</button> <button id="closeBtn">手动关闭</button> <script src="./heart-rain.js"></script> <script> const rain = new HeartRain({ count: 60, emojiList: ['🧡', '💛', '💚', '💜'], maskColor: 'rgba(255, 250, 240, 0.8)', closeVisible: true }); document.getElementById('openBtn').addEventListener('click', () => { rain.start(); }); document.getElementById('closeBtn').addEventListener('click', () => { rain.stop(); }); </script> </body> </html>

6.3 组件设计说明

封装成HeartRain类之后,调用方不再需要关心 DOM 结构。你可以通过构造函数参数控制爱心数量、表情列表、遮罩层颜色、关闭按钮是否显示。要开始就start(),要结束就stop(),不会出现全局变量被其他脚本覆盖的情况。

这个版本还处理了一个细节:setTimeout的回调里会在生成爱心之前检查this.isRunning。如果用户在雨还没下完时就点了关闭按钮,还没有创建的爱心就不会继续生成。加上stop()里的节点清理,整个组件的生命周期是完备的。

如果你想把同样的思路迁移到 Vue 或 React,核心逻辑一样,只是把 DOM 操作部分换成框架的渲染方式。在 Vue 中你可以用v-for渲染爱心列表,在 React 中你可以用 state 管理爱心数组。组件化的思想是通用的,具体框架只是表达方式不同。

7. 运行结果与效果验证

打开02-heart-rain.html03-component-demo.html,按以下步骤验证功能是否正常:

  1. 页面加载后,确认背景为浅色,按钮正常显示。
  2. 点击“点我开始下雨”按钮,观察遮罩层是否在 0.5 秒内覆盖全屏。
  3. 确认爱心从屏幕顶部陆续出现,且大小、位置、下落速度不一致。
  4. 等待所有爱心落下后,确认没有报错,页面内存不持续增长。
  5. 点击关闭按钮,确认遮罩层隐藏,残留爱心节点全部移除。
  6. 连续执行两次“开启 -> 关闭 -> 开启”,确认第二次效果与第一次一致,没有节点叠加问题。

如果第 4 步出现问题,打开开发者工具的 Performance 面板,录制 5 秒动画过程,观察帧率是否稳定在 30fps 以上。正常情况下,40 到 60 颗 CSS 动画爱心在主流浏览器中不会造成明显掉帧。如果感觉卡顿,优先降低count参数,或者把animationDuration的随机范围调大一些,减少同一时间内正在动画的节点数。

在验证过程中,一个容易忽略的观察点是console面板的报错。如果heart-rain.js文件没有正确加载,页面会报Uncaught ReferenceError: HeartRain is not defined。这种问题通常是因为脚本路径不对。在 Chrome 中,网络请求面板(Network)会显示heart-rain.js的加载状态,直接确认状态码是否为 200 即可。

8. 常见问题与排查思路

这部分是实战排错清单,建议直接收藏。爱心弹窗本身代码量不大,真正烦人的问题往往出现在跨设备、跨浏览器和复用场景里。

问题现象可能原因排查方式解决方案
页面打开后点击按钮无反应JavaScript 文件加载失败或存在语法错误打开 F12 开发者工具,查看 Console 面板红色报错检查脚本路径,修正语法错误
爱心飘落动画不生效浏览器不支持@keyframes动画,或样式被覆盖在元素上手动设置animation属性,确认 CSS 优先级为动画类添加!important,或改为使用 Web Animations API
关闭爱心雨后再次开启,节点重复stop()没有清空已创建的爱心节点在 stop 后打断点查看mask内 childElementCount在 stop 方法中遍历清除所有 heart 元素
页面明显卡顿,风扇转速升高单次生成爱心数量过多,或动画元素数过大打开 Performance 面板分析帧率降低count,增大动画间隔,删除大尺寸背景图片
移动端点击按钮后无法滚动遮罩层position: fixed覆盖全屏,阻止了滚动事件检查遮罩层 CSS,确认pointer-events属性在非弹窗区域将pointer-events: none,或移除遮罩层
emoji 在不同系统显示不一致Windows 和 macOS 的 emoji 字体不同在多个系统截图对比改用 CSS 绘制的心形或 SVG 图标
setTimeout生成的爱心在窗口最小化后批量出现浏览器为了省电会延后定时器回调观察恢复窗口后的动画表现改用requestAnimationFrame驱动生成逻辑
Vue 项目中使用组件报找不到@keyframes动态生成的 style 节点未挂载到document.head检查 style 节点的插入时机将动画样式写入全局 CSS 文件,或使用框架的 scoped style

一个特别值得注意的点是:不要把“弹窗”做成无边界的自动运行脚本。在用户没有授权的情况下,程序不应该自动弹窗、自动下载文件、自动读取本地数据。如果你要在朋友的电脑上展示这个效果,建议先征得对方同意,并且确保代码只运行在浏览器页面内,不触碰操作系统文件和网络接口。

9. 安全边界与最佳实践

这部分很少被网上“示例代码讲解”类文章提到,但恰恰是最重要的工程素养。

第一,任何弹窗代码都不应该被滥用为骚扰工具。如果别人打开一个网页,电脑就不断被弹窗骚扰,甚至无法关闭,这会直接影响他人对程序员的信任。你一定要保证弹窗内容可控制、可关闭、可停止。这一点也是自己和“流氓软件”之间的区别。

第二,不要在工作电脑或生产环境设备上自动运行这类动画。如果你在领导演示项目的时候,屏幕上突然下起爱心雨,尴尬程度可想而知。更稳妥的做法是:先在本地环境测试,确认行为符合预期后,再决定是否在目标设备上打开。

第三,这个项目的代码没有访问任何网络、没有读取任何本地文件,也不该有这些功能。“爱心弹窗”应该是一个纯前端的视觉表现层,不需要后端接口,不需要数据库,更不需要往服务器上传任何数据。这是它最安全的形态。如果你看到有人把“爱心弹窗”做成一个需要安装驱动的可执行文件,请直接拒绝运行。

第四,如果你要给对象一个惊喜,最稳妥的交付方式是:把HTML文件打包成 zip,或者直接发送一个.html文件给对方。对方用浏览器打开即运行,用完即走,不留后门,不需要安装任何软件。这样既保留了仪式感,也避免了安全风险。

第五,从组件设计角度,推荐把“爱心弹窗”和业务代码解耦。如果你以后真的要把它嵌入自己的 Web 项目,建议按“单例模式”封装,避免一个页面上存在多个HeartRain实例互相干扰。同时在start()开头做一次if (this.isRunning) return;判断,能有效防止重复启动。

10. 从整活代码到工程组件:下一步还能做什么

爱心弹窗只是一个起点。当你把它封装成可复用组件之后,继续往深里做,你会发现它背后其实是一整套前端工程能力的缩影。

第一个方向是消息队列与限频控制。如果弹窗系统被用于真正业务场景,比如“新消息提醒”“系统公告”,那么同一时间出现的弹窗必须有限制。你可以给HeartRain增加一个队列机制,新任务来了先排队,上一批动画结束后再启动下一批。

第二个方向是可访问性优化。你的页面如果要做成真正的产品,需要考虑键盘操作。比如遮罩层出现后,是否支持 Esc 键关闭?焦点是否应该自动转移到关闭按钮?这些都值得单独写文章讨论。对比之下,现在很多“弹窗”动不动全屏遮罩却无法用键盘关闭,这在正规项目评审里是扣分项。

第三个方向是跨端适配。同样的爱心效果,在 PC 浏览器、移动端浏览器、小程序里实现方式完全不同。在移动端,你需要注意安全视口100dvh、触摸事件、性能限制;在小程序里,你需要用wx.createSelectorQuerycanvas2D 接口重写动画逻辑。这些差异不深入实践是体会不到的。

第四个方向是自定义渲染器。当前代码用的是 emoji 字符,替换成图片只需要修改createHeart()中创建元素的逻辑。比如用img标签加载透明 PNG 的爱心素材,效果会更精致。更进一步,你可以在 Canvas 上绘制粒子爱心,让爱心由成百上千个粒子组成,点击鼠标时粒子爆炸——这种效果在小红书、抖音上很受欢迎,但它的技术栈已经从 DOM 动画升级到 Canvas 渲染了。

无论你的终极目标是写一个表白小工具,还是想提升自己的前端工程能力,这个小项目都值得你亲手敲一遍。先跑通02-heart-rain.html,再改成组件化版本,然后尝试加入你自己的创意。等你把关闭逻辑、节点清理、随机动画这些细节都吃透了,下一个项目遇到类似的弹窗方案时,你就能快速判断应该选择哪种实现方式,而不是拿到需求就只会alert一把梭。

把代码发给那个重要的人之前,记得自己先在本地跑一遍,确保第一次打开就是最完美的效果。顺便说一句——如果对方问你“这个爱心是怎么做的”,你可以认真地给他讲一遍@keyframesanimationend的原理。那一刻,技术就不再只是代码,而是一份心意。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 11:04:41

端侧SoC技术解析:瑞芯微AI芯片与中科蓝讯RISC-V方案对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 11:03:11

预制菜批量制作指南:高效备餐与营养均衡方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 11:02:54

单片机毕设项目:基于 STM32 的环境感知智能风扇移动端远程控制系统实现 基于 STM32 的 LU-ASR01 语音智能风扇物联网终端设计(018506)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/4 11:02:17

Java调用YOLO ONNX模型实现实时视频目标检测全流程实战

简介&#xff1a;本资源是一个面向Java开发者与AI视觉应用工程师的纯Java视觉智能识别项目&#xff0c;解决在非Python环境下高效调用YOLO系列模型进行实时视频分析的工程化难题&#xff0c;适用于安防监控、智慧交通、工业质检等场景中的目标检测、实例分割&#xff08;seg&am…

作者头像 李华
网站建设 2026/9/4 11:00:55

怎么可以把文字转语音?本地合成MP3音频的完整操作记录

文字转语音&#xff08;Text-to-Speech&#xff0c;TTS&#xff09;是将书面文本通过语音合成引擎转换为可播放音频文件的技术&#xff0c;常见输出格式包括 WAV、MP3、M4A 等。TTS 在日常办公和开发中主要承担短视频配音、课程旁白、有声内容批量生成、无障碍朗读等任务。 本…

作者头像 李华