ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

原生前端三件套打造七夕音乐祝福页:歌词同步+爱心雨特效

原生前端三件套打造七夕音乐祝福页:歌词同步+爱心雨特效 最近被一句文案戳中了“该怎么定义这场晚风是涟漪是汹涌是遇见你之后的每一天都觉得很走运。”如果只是把这句话发给对方总觉得少了点仪式感。于是我用原生 HTML、CSS 和 JavaScript 做了一个七夕限定音乐祝福页名字就叫《晚风定义》。页面可以播放音乐、同步滚动歌词点击按钮还会下起 99 颗爱心雨算是把这句文案真正“做成”了一个可以交互的小作品。这篇教程会带你从零实现同一个页面。不需要 Vue、React也不需要安装任何依赖一个浏览器加一个文本编辑器就够了。你可以把这个页面部署到任意静态服务器也可以直接双击打开本地文件适合用来做节日祝福、活动 H5 的练习或者作为前端入门阶段的一个完整小项目。1. 项目背景与功能拆解先不要急着写代码我们需要把这个文艺句子转化成具体的“产品需求”。文案本身没有明确功能但可以拆成几个页面表达“晚风定义”页面需要一个夜晚微风的视觉氛围用来承载主题。“是涟漪是汹涌”可以通过动画和歌词节奏传递情绪变化。“遇见你之后的每一天都觉得很走运”需要一句温暖的话出现在页面里。“七夕快乐”需要一个节日化的交互动作。“奖励 99”最自然的落地方式就是做一个“领取七夕奖励”按钮点击后掉落 99 颗爱心。把这些需求翻译成技术点就是以下四个核心功能音乐播放与暂停控制。歌词按时间同步高亮。播放进度条和音量调节。点击按钮生成 99 个爱心飘落动画。这四个功能用原生 JavaScript 完全能实现而且不需要借助任何第三方库。这样做的好处很明显没有依赖部署简单也更容易理解浏览器 API 背后的原理。如果你后面要迁移到 Vue 或 React这些逻辑也可以轻松改造。2. 技术选型与准备工作2.1 技术栈本项目的技术栈非常简单HTML5搭建页面结构。CSS3完成背景、卡片、动画和响应式样式。JavaScriptES6处理播放逻辑、歌词解析、DOM 操作和动画生成。HTML5 Audio API控制音频播放。字体图标直接用文字符号代替比如播放按钮用“▶”和“❚❚”爱心用 emoji。不引入框架的好处是你复制粘贴的代码可以直接运行不用处理 npm 依赖冲突和打包配置。对于初学者来说这种“纯原生”的方式最能帮助理解浏览器工作原理。2.2 运行环境你在本教程中不需要安装额外软件只需要准备现代浏览器Chrome、Edge、Firefox 或 Safari 均可。文本编辑器VS Code、Sublime Text 或记事本都行。一个本地音频文件建议命名为wanfeng.mp3放在项目根目录。注意音频文件必须有合法授权不要直接传播受版权保护的商业歌曲可以替换成无版权背景音乐或自己录制的音频。本文的示例会以本地音频文件wanfeng.mp3为例。2.3 项目结构为了方便演示我们最终会写成一个单文件index.html把 CSS 和 JS 都内联进去。如果你希望工程化管理可以拆分为index.html、style.css、main.js。单文件结构如下wanfeng/ ├── index.html └── wanfeng.mp3index.html中包含全部代码wanfeng.mp3是示例音频。在实际项目里建议把 CSS 和 JS 拆开便于维护。3. 页面设计与 HTML 骨架3.1 视觉设计思路视觉上我希望呈现“夏夜微风”的感觉。于是背景采用深蓝色到紫色的渐变模拟夜晚天空卡片使用半透明毛玻璃效果营造轻盈感封面用 CSS 画一个“月球”圆形爱心掉落动画则作为整页最高潮的交互反馈。页面从上到下分为背景层夜空渐变。主卡片封面、标题、播放器、歌词、按钮、日期。爱心层动态生成的爱心容器。3.2 HTML 基础结构下面先创建index.html的骨架。注意audio标签的src指向本地音频文件preloadmetadata表示先加载音频元数据而不是完整音频这样页面打开速度更快。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title晚风定义 · 七夕限定/title /head body div classsky/div main classcard div classcover div classmoon/div /div h1晚风定义/h1 p classdesc是涟漪是汹涌是遇见你之后的每一天都觉得很走运。/p audio idaudio src./wanfeng.mp3 preloadmetadata loop/audio div classplayer button idplayBtn aria-label播放或暂停▶/button div classprogress-bar idprogressBar div classprogress-fill idprogressFill/div /div span idtimeText00:00 / 00:00/span input typerange idvolume min0 max1 step0.01 value0.8 aria-label音量 /div div classlyrics idlyrics/div button idrewardBtn classreward-btn领取七夕奖励 99/button p classdate iddateText/p /main div idheartContainer/div /body /html这里有几个需要注意的地方aria-label用于增加可访问性屏幕阅读器可以正确朗读按钮用途。progressBar是自定义进度条我们不用原生input[typerange]来做进度条这样样式更好控制。heartContainer是爱心掉落动画的容器它固定在整个页面上层。歌词容器初始为空由 JavaScript 解析歌词后动态插入。4. 核心样式与动画实现4.1 夜空渐变背景背景层的.sky使用 CSS 渐变并让背景覆盖整个视口。为了让星空更有层次可以叠加多个radial-gradient圆点作为星星。* { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(160deg, #0b1026 0%, #1b1b4b 50%, #2d1b4e 100%); font-family: PingFang SC, Microsoft YaHei, sans-serif; overflow-x: hidden; } .sky { position: fixed; inset: 0; z-index: 0; background-image: radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.6), transparent), radial-gradient(2px 2px at 80px 120px, rgba(255,255,255,0.4), transparent), radial-gradient(1px 1px at 200px 60px, rgba(255,255,255,0.8), transparent), radial-gradient(3px 3px at 300px 200px, rgba(255,255,255,0.3), transparent), radial-gradient(1px 1px at 380px 80px, rgba(255,255,255,0.7), transparent), radial-gradient(2px 2px at 450px 260px, rgba(255,255,255,0.4), transparent); background-size: 500px 300px; opacity: 0.8; }这里使用多个radial-gradient模拟星星简单且性能不错。实际项目中你也可以用一张星空图片代替。4.2 毛玻璃卡片与封面主卡片使用半透明背景和backdrop-filter实现毛玻璃效果。封面是一个圆形中间放一个淡黄色“月亮”并加上柔光阴影。.card { position: relative; z-index: 1; width: min(90vw, 420px); background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 24px; padding: 32px 28px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); text-align: center; color: #f5f3ff; } .cover { width: 120px; height: 120px; border-radius: 50%; margin: 0 auto 20px; background: radial-gradient(circle at 40% 35%, #f8f0d5, #d6b98c); display: flex; align-items: center; justify-content: center; box-shadow: 0 0 40px rgba(255, 220, 150, 0.4); } .moon { width: 70px; height: 70px; border-radius: 50%; background: #fff7e0; box-shadow: 0 0 30px #ffe9b3; }backdrop-filter有些浏览器需要-webkit-前缀所以上面同时写了两行。如果你的浏览器版本较老毛玻璃效果可能不显示但页面整体不会崩。4.3 播放器样式我把播放器布局设置为播放/暂停按钮 进度条 时间 音量排成一行。手机屏幕窄时可以通过换行来适配。.player { display: flex; align-items: center; gap: 12px; margin: 24px 0 16px; flex-wrap: wrap; justify-content: center; } .player button { width: 44px; height: 44px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.15); color: #fff; font-size: 18px; cursor: pointer; transition: transform 0.2s, background 0.2s; } .player button:hover { background: rgba(255, 255, 255, 0.3); transform: scale(1.05); } .progress-bar { flex: 1; min-width: 120px; height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.2); cursor: pointer; position: relative; } .progress-fill { width: 0%; height: 100%; border-radius: 3px; background: linear-gradient(90deg, #ff9a9e, #fad0c4); transition: width 0.1s linear; } #timeText { font-size: 12px; opacity: 0.7; white-space: nowrap; } #volume { width: 80px; accent-color: #ff9a9e; }进度条是用div模拟的所以宽度变化由 JavaScript 控制。transition: width 0.1s linear可以让进度条移动更顺滑但如果你用鼠标拖动进度条可能会觉得跟不上手后续会说明如何避免。4.4 爱心掉落动画爱心容器#heartContainer需要固定定位否则无法从浏览器视口任意位置生成爱心。#heartContainer { position: fixed; inset: 0; pointer-events: none; z-index: 999; overflow: hidden; } .heart { position: fixed; left: 50%; top: 100%; font-size: 20px; opacity: 0.9; user-select: none; animation: heartFloat linear forwards; } keyframes heartFloat { 0% { transform: translateY(0) rotate(0deg) scale(0.8); opacity: 0; } 10% { opacity: 0.9; } 100% { transform: translateY(-120vh) rotate(360deg) scale(1.1); opacity: 0; } }这里爱心从top: 100%屏幕底部外侧开始向上飘出屏幕外。JavaScript 会设置每个爱心随机的left、font-size、animation-duration和animation-delay让爱心雨看起来自然。5. JavaScript 核心逻辑下面进入 JavaScript 部分。我会分模块解释最后再整合。5.1 获取元素与工具函数先拿到页面上的所有关键 DOM 元素然后写一个时间格式化函数。const audio document.getElementById(audio); const playBtn document.getElementById(playBtn); const progressBar document.getElementById(progressBar); const progressFill document.getElementById(progressFill); const timeText document.getElementById(timeText); const volume document.getElementById(volume); const lyricsBox document.getElementById(lyrics); const rewardBtn document.getElementById(rewardBtn); const heartContainer document.getElementById(heartContainer); const dateText document.getElementById(dateText); function formatTime(t) { if (!isFinite(t) || t 0) return 00:00; const minute Math.floor(t / 60).toString().padStart(2, 0); const second Math.floor(t % 60).toString().padStart(2, 0); return ${minute}:${second}; }formatTime会把秒数转换成MM:SS格式。注意处理了t不是有限数字的情况避免音频加载异常时页面显示NaN。5.2 播放与暂停点击播放按钮时判断audio.paused来决定是play()还是pause()。同时切换按钮文字和身体上的playing类后续可以基于这个类实现封面旋转等效果。playBtn.addEventListener(click, () { if (audio.paused) { audio.play(); playBtn.textContent ❚❚; document.body.classList.add(playing); } else { audio.pause(); playBtn.textContent ▶; document.body.classList.remove(playing); } });这里没有调用audio.load()因为src已经写在 HTML 标签里。audio.play()返回一个 Promise在浏览器自动播放策略下可能被拒绝但我们点击按钮时属于用户手势所以一般能正常播放。如果遇到播放失败可以监听Promise.catch来提示用户。5.3 进度条与时间更新音频播放过程中会不断触发timeupdate事件。我们在该事件里更新进度条宽度和时间文本。audio.addEventListener(timeupdate, () { const current audio.currentTime; const duration audio.duration || 0; const percent duration ? (current / duration) * 100 : 0; progressFill.style.width ${percent}%; timeText.textContent ${formatTime(current)} / ${formatTime(duration)}; highlightLyric(current); });点击进度条可以实现跳转播放。因为进度条是用div模拟的我们需要通过点击位置的clientX和进度条的getBoundingClientRect()计算比例。progressBar.addEventListener(click, (e) { const rect progressBar.getBoundingClientRect(); const ratio (e.clientX - rect.left) / rect.width; if (audio.duration) { audio.currentTime ratio * audio.duration; } });这里有个细节timeupdate默认每 250 毫秒触发一次所以进度条不是像素级平滑但视觉上足够。想要更平滑可以改用requestAnimationFrame循环更新后面扩展建议会提到。音量控制直接用input[typerange]把值同步到audio.volume。音量范围是 0 到 1步长 0.01。volume.addEventListener(input, () { audio.volume parseFloat(volume.value); });5.4 解析并同步歌词歌词同步是一个很常见的播放器功能。我们使用最简单的 LRC 格式[00:00.00]晚风定义 [00:05.00]如果今晚的风是甜的 [00:10.00]一定是它偷偷尝过你的笑 [00:15.00]是涟漪 是汹涌 [00:20.00]是遇见你之后的每一天 [00:25.00]都觉得很走运 [00:30.00]七夕快乐每一行由时间标签[分钟:秒.毫秒]和歌词内容组成。我们先用正则解析成对象数组再生成对应的p节点。const lrcText [00:00.00]晚风定义 [00:05.00]如果今晚的风是甜的 [00:10.00]一定是它偷偷尝过你的笑 [00:15.00]是涟漪 是汹涌 [00:20.00]是遇见你之后的每一天 [00:25.00]都觉得很走运 [00:30.00]七夕快乐 ; function parseLrc(text) { return text .split(\n) .filter(line line.trim() line.includes([)) .map(line { const match line.match(/\[(\d{2}):(\d{2})\.(\d{2})\](.*)/); if (!match) return null; const minute parseInt(match[1], 10); const second parseInt(match[2], 10); const millisecond parseInt(match[3], 10); return { time: minute * 60 second millisecond / 100, text: match[4].trim() }; }) .filter(item item ! null); } const lyrics parseLrc(lrcText); lyrics.forEach((item, index) { const p document.createElement(p); p.className lyric-line; p.textContent item.text; p.dataset.index index; lyricsBox.appendChild(p); });注意正则\[(\d{2}):(\d{2})\.(\d{2})\]中毫秒50解析成50 / 100 0.5这样就得到 0.5 秒不会出错。如果你的歌词文件毫秒是[00:00.500]这种三位数需要调整正则但常见的.lrc歌词通常是两位。歌词高亮逻辑在timeupdate中已经调用了highlightLyric(current)。我们需要找到“当前时间已经超过的最近一句歌词”然后让它高亮同时让其他歌词恢复普通样式。function highlightLyric(current) { const lineEls lyricsBox.querySelectorAll(.lyric-line); let activeIndex -1; for (let i 0; i lyrics.length; i) { if (current lyrics[i].time) { activeIndex i; } else { break; } } if (activeIndex 0 || lyricsBox.dataset.activeIndex activeIndex) return; lineEls.forEach(el el.classList.remove(active)); lineEls[activeIndex].classList.add(active); lyricsBox.dataset.activeIndex activeIndex; lineEls[activeIndex].scrollIntoView({ block: nearest, behavior: smooth }); }scrollIntoView可以让当前高亮歌词滚动到歌词区域的可视范围内。block: nearest表示除非歌词已经在可视区域否则不滚动避免每句歌词都强行居中。接下来为歌词行添加样式.lyrics { min-height: 90px; max-height: 120px; overflow-y: auto; margin: 12px 0 20px; display: flex; flex-direction: column; justify-content: center; scrollbar-width: thin; color: rgba(255, 255, 255, 0.6); } .lyric-line { margin: 6px 0; line-height: 1.6; transition: color 0.3s, transform 0.3s, opacity 0.3s; opacity: 0.6; } .lyric-line.active { opacity: 1; color: #fff; font-size: 16px; transform: scale(1.05); }5.5 七夕奖励按钮99 颗爱心雨这是整个页面最有“仪式感”的交互。点击按钮后生成 99 个爱心并让它们依次从底部向上飘落。为了避免同时生成 99 个 DOM 节点导致页面卡顿我使用setTimeout让它们每隔 40 毫秒生成一个这样 99 个爱心会形成“连续下雨”的效果视觉上也更自然。rewardBtn.addEventListener(click, () { const count 99; for (let i 0; i count; i) { setTimeout(createHeart, i * 40); } rewardBtn.textContent 99 颗爱心已送达 ♥; setTimeout(() { rewardBtn.textContent 领取七夕奖励 99; }, 6000); }); function createHeart() { const heart document.createElement(div); heart.className heart; heart.textContent [❤, , , , ][Math.floor(Math.random() * 5)]; heart.style.left ${Math.random() * 100}vw; heart.style.fontSize ${14 Math.random() * 24}px; heart.style.animationDuration ${3 Math.random() * 3}s; heart.style.animationDelay ${Math.random() * 0.3}s; heartContainer.appendChild(heart); heart.addEventListener(animationend, () heart.remove()); }这里生成爱心时把left设置为Math.random() * 100vw可以让爱心在屏幕水平方向随机分布。animationDelay加上随机 0.3 秒让第一批爱心不会完全同步起飘。还有一个细节rewardBtn.textContent在 6 秒后恢复是因为最后一批爱心在 99×40ms≈4 秒后才生成动画最长需要 6 秒所以等爱心雨结束后恢复按钮文字更合适。5.6 显示当前日期七夕是农历节日精确计算农历比较复杂本文不引入农历库。页面只显示当前公历日期和一句祝福。const now new Date(); const dateTextValue ${now.getFullYear()}年${now.getMonth() 1}月${now.getDate()}日; dateText.textContent 今天是${dateTextValue}愿你的晚风有人懂;如果你需要精确的七夕倒计时可以使用第三方农历库或者预置未来几年的农历日期表但这不是本教程重点。5.7 音频加载失败提示本地文件路径不对或文件损坏时播放器会静默失败用户体验很差。所以我们监听audio的error事件把错误信息显示在时间区域。audio.addEventListener(error, () { timeText.textContent 音频加载失败请检查 wanfeng.mp3; });这个提示只解决文本反馈问题真正排查还需要看浏览器开发者工具中的网络面板。6. 完整可运行代码为了方便你直接复制我把所有代码整合到一个index.html中。你可以先创建文件夹wanfeng把下面的代码保存为index.html再放一个合法授权的wanfeng.mp3到同一个目录中用浏览器打开即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title晚风定义 · 七夕限定/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(160deg, #0b1026 0%, #1b1b4b 50%, #2d1b4e 100%); font-family: PingFang SC, Microsoft YaHei, sans-serif; overflow-x: hidden; } .sky { position: fixed; inset: 0; z-index: 0; background-image: radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.6), transparent), radial-gradient(2px 2px at 80px 120px, rgba(255,255,255,0.4), transparent), radial-gradient(1px 1px at 200px 60px, rgba(255,255,255,0.8), transparent), radial-gradient(3px 3px at 300px 200px, rgba(255,255,255,0.3), transparent), radial-gradient(1px 1px at 380px 80px, rgba(255,255,255,0.7), transparent), radial-gradient(2px 2px at 450px 260px, rgba(255,255,255,0.4), transparent); background-size: 500px 300px; opacity: 0.8; } .card { position: relative; z-index: 1; width: min(90vw, 420px); background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 24px; padding: 32px 28px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); text-align: center; color: #f5f3ff; } .cover { width: 120px; height: 120px; border-radius: 50%; margin: 0 auto 20px; background: radial-gradient(circle at 40% 35%, #f8f0d5, #d6b98c); display: flex; align-items: center; justify-content: center; box-shadow: 0 0 40px rgba(255, 220, 150, 0.4); transition: transform 0.5s ease; } .moon { width: 70px; height: 70px; border-radius: 50%; background: #fff7e0; box-shadow: 0 0 30px #ffe9b3; } body.playing .cover { transform: rotate(360deg); animation: spin 6s linear infinite; } keyframes spin { to { transform: rotate(360deg); } } h1 { font-size: 28px; letter-spacing: 4px; margin-bottom: 8px; } .desc { font-size: 14px; line-height: 1.8; opacity: 0.85; margin-bottom: 16px; } .player { display: flex; align-items: center; gap: 12px; margin: 24px 0 16px; flex-wrap: wrap; justify-content: center; } .player button { width: 44px; height: 44px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.15); color: #fff; font-size: 18px; cursor: pointer; transition: transform 0.2s, background 0.2s; } .player button:hover { background: rgba(255, 255, 255, 0.3); transform: scale(1.05); } .progress-bar { flex: 1; min-width: 120px; height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.2); cursor: pointer; position: relative; } .progress-fill { width: 0%; height: 100%; border-radius: 3px; background: linear-gradient(90deg, #ff9a9e, #fad0c4); transition: width 0.1s linear; } #timeText { font-size: 12px; opacity: 0.7; white-space: nowrap; } #volume { width: 80px; accent-color: #ff9a9e; } .lyrics { min-height: 90px; max-height: 120px; overflow-y: auto; margin: 12px 0 20px; display: flex; flex-direction: column; justify-content: center; scrollbar-width: thin; color: rgba(255, 255, 255, 0.6); } .lyric-line { margin: 6px 0; line-height: 1.6; transition: color 0.3s, transform 0.3s, opacity 0.3s; opacity: 0.6; } .lyric-line.active { opacity: 1; color: #fff; font-size: 16px; transform: scale(1.05); } .reward-btn { width: 100%; padding: 12px 20px; border: none; border-radius: 50px; background: linear-gradient(90deg, #ff758c, #ff7eb3); color: #fff; font-size: 16px; font-weight: bold; cursor: pointer; box-shadow: 0 10px 30px rgba(255, 118, 136, 0.4); transition: transform 0.2s, box-shadow 0.2s; } .reward-btn:hover { transform: translateY(-2px); box-shadow: 0 14px 40px rgba(255, 118, 136, 0.5); } .reward-btn:active { transform: translateY(0); } .date { margin-top: 16px; font-size: 12px; opacity: 0.6; } #heartContainer { position: fixed; inset: 0; pointer-events: none; z-index: 999; overflow: hidden; } .heart { position: fixed; left: 50%; top: 100%; font-size: 20px; opacity: 0.9; user-select: none; animation: heartFloat linear forwards; } keyframes heartFloat { 0% { transform: translateY(0) rotate(0deg) scale(0.8); opacity: 0; } 10% { opacity: 0.9; } 100% { transform: translateY(-120vh) rotate(360deg) scale(1.1); opacity: 0; } } /style /head body div classsky/div main classcard div classcover div classmoon/div /div h1晚风定义/h1 p classdesc是涟漪是汹涌是遇见你之后的每一天都觉得很走运。/p audio idaudio src./wanfeng.mp3 preloadmetadata loop/audio div classplayer button idplayBtn aria-label播放或暂停▶/button div classprogress-bar idprogressBar div classprogress-fill idprogressFill/div /div span idtimeText00:00 / 00:00/span input typerange idvolume min0 max1 step0.01 value0.8 aria-label音量 /div div classlyrics idlyrics/div button idrewardBtn classreward-btn领取七夕奖励 99/button p classdate iddateText/p /main div idheartContainer/div script const audio document.getElementById(audio); const playBtn document.getElementById(playBtn); const progressBar document.getElementById(progressBar); const progressFill document.getElementById(progressFill); const timeText document.getElementById(timeText); const volume document.getElementById(volume); const lyricsBox document.getElementById(lyrics); const rewardBtn document.getElementById(rewardBtn); const heartContainer document.getElementById(heartContainer); const dateText document.getElementById(dateText); function formatTime(t) { if (!isFinite(t) || t 0) return 00:00; const minute Math.floor(t / 60).toString().padStart(2, 0); const second Math.floor(t % 60).toString().padStart(2, 0); return ${minute}:${second}; } playBtn.addEventListener(click, () { if (audio.paused) { audio.play(); playBtn.textContent ❚❚; document.body.classList.add(playing); } else { audio.pause(); playBtn.textContent ▶; document.body.classList.remove(playing); } }); audio.addEventListener(timeupdate, () { const current audio.currentTime; const duration audio.duration || 0; const percent duration ? (current / duration) * 100 : 0; progressFill.style.width ${percent}%; timeText.textContent ${formatTime(current)} / ${formatTime(duration)}; highlightLyric(current); }); progressBar.addEventListener(click, (e) { const rect progressBar.getBoundingClientRect(); const ratio (e.clientX - rect.left) / rect.width; if (audio.duration) { audio.currentTime ratio * audio.duration; } }); volume.addEventListener(input, () { audio.volume parseFloat(volume.value); }); audio.addEventListener(error, () { timeText.textContent 音频加载失败请检查 wanfeng.mp3; }); // 歌词解析与渲染 const lrcText [00:00.00]晚风定义 [00:05.00]如果今晚的风是甜的 [00:10.00]一定是它偷偷尝过你的笑 [00:15.00]是涟漪 是汹涌 [00:20.00]是遇见你之后的每一天 [00:25.00]都觉得很走运 [00:30.00]七夕快乐 ; function parseLrc(text) { return text .split(\n) .filter(line line.trim() line.includes([)) .map(line { const match line.match(/\[(\d{2}):(\d{2})\.(\d{2})\](.*)/); if (!match) return null; const minute parseInt(match[1], 10); const second parseInt(match[2], 10); const millisecond parseInt(match[3], 10); return { time: minute * 60 second millisecond / 100, text: match[4].trim() }; }) .filter(item item ! null); } const lyrics parseLrc(lrcText); lyrics.forEach((item) { const p document.createElement(p); p.className lyric-line; p.textContent item.text; lyricsBox.appendChild(p); }); function highlightLyric(current) { const lineEls lyricsBox.querySelectorAll(.lyric-line); let activeIndex -1; for (let i 0; i lyrics.length; i) { if (current lyrics[i].time) { activeIndex i; } else { break; } } if (activeIndex 0 || lyricsBox.dataset.activeIndex activeIndex) return; lineEls.forEach(el el.classList.remove(active)); lineEls[activeIndex].classList.add(active); lyricsBox.dataset.activeIndex activeIndex; lineEls[activeIndex].scrollIntoView({ block: nearest, behavior: smooth }); } // 爱心雨 rewardBtn.addEventListener(click, () { const count 99; for (let i 0; i count; i) { setTimeout(createHeart, i * 40); } rewardBtn.textContent 99 颗爱心已送达 ♥; setTimeout(() { rewardBtn.textContent 领取七夕奖励 99; }, 6000); }); function createHeart() { const heart document.createElement(div); heart.className heart; heart.textContent [❤, , , , ][Math.floor(Math.random() * 5)]; heart.style.left ${Math.random() * 100}vw; heart.style.fontSize ${14 Math.random() * 24}px; heart.style.animationDuration ${3 Math.random() * 3}s; heart.style.animationDelay ${Math.random() * 0.3}s; heartContainer.appendChild(heart); heart.addEventListener(animationend, () heart.remove()); } // 日期显示 const now new Date(); const dateTextValue ${now.getFullYear()}年${now.getMonth() 1}月${now.getDate()}日; dateText.textContent 今天是${dateTextValue}愿你的晚风有人懂; /script /body /html打开这个 HTML 文件后你应该能看到渐变夜空背景和毛玻璃卡片。点击播放按钮音乐开始播放按钮变成暂停图标封面开始旋转。进度条随着音乐前进时间文本同步更新。歌词随时间逐句高亮。点击“领取七夕奖励 99”爱心从屏幕底部依次飘起。页面底部显示当前日期。如果音频正常播放这个页面就已经算是完整可用了。7. 常见问题与排查思路7.1 点击播放按钮没有声音这是最常见的问题原因通常集中在三个地方wanfeng.mp3文件不存在或路径写错。请先在开发者工具的 Network 面板查看音频请求是否 404。浏览器自动播放策略拦截。虽然点击按钮属于用户手势但如果你在audio.play()之前还执行了耗时同步操作某些浏览器仍可能拦截。可以把播放代码直接放在click事件回调中不要放进异步请求里。音频文件编码格式不支持。建议使用浏览器兼容性最好的 MP3 格式取样率 44.1kHz 以内。排查顺序建议是先打开浏览器控制台看有没有报错再切换到 Network 面板确认音频请求状态最后换一个音频文件测试。7.2 进度条点击不精准有时点击进度条会跳到误差比较大的位置主要原因是预览代码中进度条 CSS 使用了transition: width 0.1s linear。这个过渡会影响视觉更新但不会影响currentTime设置。如果你希望拖动进度条而不是点击跳转需要额外处理鼠标事件监听mousedown、mousemove、mouseup。简单版本如下思路在progressBar上监听mousedown时打开一个标志位mousemove时更新标志位状态mouseup时写入audio.currentTime。考虑到篇幅这里只实现点击你可以按这个思路自行扩展。7.3 爱心雨导致页面卡顿一次性创建 99 个 DOM 节点不算太多但如果用户连续点击多次按钮就会出现几百个爱心同时存在造成动画性能下降。为了避免这种情况可以在函数开始时判断heartContainer.childElementCount如果已经超过一定数量比如 150就先清空容器再重新生成。另一种优化方案是使用 Canvas 绘制爱心雨。Canvas 只需要一个画布节点然后通过requestAnimationFrame更新粒子位置。这种方案性能更好适合对动画效果要求更高的场景。7.4 歌词不滚动歌词区域的max-height: 120px是为了容纳三行左右的歌词。如果歌词很多滚动条会出现但scrollIntoView在部分浏览器中可能表现不符合预期。如果遇到滚动异常可以改用以下方式计算滚动位置const activeEl lineEls[activeIndex]; lyricsBox.scrollTop activeEl.offsetTop - lyricsBox.clientHeight / 2 activeEl.clientHeight / 2;这种方法直接把歌词区域滚动到可视中心兼容性更好。8. 最佳实践与工程建议这个项目虽然小但可以延伸到很多实际工程问题。以下几点建议值得你关注。8.1 资源路径与构建单文件适合演示但真实项目里应该把 CSS、JS 拆成独立文件。如果你的页面需要部署到服务器子目录建议音频路径使用相对路径./wanfeng.mp3这样更稳。如果部署到 CDN可以考虑使用绝对路径或根据环境自动生成资源地址。8.2 音频版权安全这是非常重要的一点。不要随意把受版权保护的音乐打包到你的项目中更不要公开发布。文中演示音频需要替换为无版权背景音乐、你自己制作的音频或者已经获得授权的素材。这是一个开发者应该养成的基本版权意识。8.3 性能优化backdrop-filter毛玻璃效果在某些低端手机上非常消耗性能可以用纯色半透明背景代替。爱心雨如果要做大量粒子优先使用 Canvas 而不是 DOM 节点。歌词高亮时避免每 250 毫秒都遍历全部歌词。如果歌词很多可以使用二分查找但在本项目里歌词数量很少线性遍历完全足够。8.4 代码可维护性建议把歌词文本抽成全局常量不要直接硬编码在页面中。更进一步可以通过fetch加载外部.lrc文件这样修改歌词不需要改 JavaScript。音频播放状态可以考虑写一个简单的状态机包含idle、playing、paused、ended等状态。这样后续增加“上一首/下一首”时逻辑会更清晰。8.5 响应式与移动端适配页面主要适配手机竖屏。可以在 CSS 中增加媒体查询在屏幕高度较小的情况下缩小封面和歌词区域避免内容超出视口。media (max-height: 700px) { .cover { width: 80px; height: 80px; margin-bottom: 12px; } .card { padding: 20px 18px; } }这个不是必选项但能提升不同设备上的体验。9. 总结回顾一下这个《晚风定义》页面用原生前端三件套实现了音乐播放、歌词同步、进度控制、音量调节和 99 颗爱心雨特效。虽然功能不复杂但它把一个偏感性的文案变成了一个可以被点击、被播放、被观看的交互页面这是前端开发里很有意思的体验。你可以在当前基础上继续挑战这些方向用 Canvas 重写爱心雨让粒子数量扩展到 500 并且不掉帧。添加“上一句歌词点击回跳”功能。封装成组件风格接入 Vue 或 React。加入分享海报或二维码生成功能把页面分享给更多人。如果这篇文章对你有帮助可以保存下来当作原生前端练手的参考。下次再做节日祝福页时试着把一句简单的文案用代码变成完整的交互作品你会发现前端开发的表达力比想象中强得多。
返回列表