写在前面:如果你最近在刷前端页面开发相关的系列教程,可能会看到“△1-17~1-21”这种带编号的进度说明。我把它理解为一段连续迭代的过程:从一个静态页面逐步加入文字效果、粒子动画、音频交互和移动端适配,最终形成一个完整可部署的“表达型”网页。本文就用“你是我的初恋”作为项目代号,完整拆解这五个版本节点背后的实现思路和关键代码。适合刚学完 HTML/CSS/JavaScript 基础、想独立完成一个小型前端页面的同学;如果你已经在做项目,也可以直接看第 4 到第 6 章的代码模块,方便复用。
1. 项目背景与版本定位
1.1 为什么选择纯前端而不是框架
很多人一提到做页面,第一反应是选 React 或 Vue。但对于一个以视觉表达为主、交互逻辑较轻的页面来说,引入框架反而会增加构建成本和认知负担。它不需要复杂的状态管理,不需要路由,也不依赖后端数据,最适合用原生 HTML + CSS + JavaScript 来完成。
“你是我的初恋”这个项目最初的定位就是一个单页应用,所有内容都放在一个页面里,通过滚动或按钮触发交互。这样做的好处是:
- 部署简单,静态文件扔到任意 Web 服务器即可。
- 调试方便,打开浏览器控制台就能看到全部报错。
- 学习价值高,能清楚看到 DOM 操作、事件监听、Canvas 绘图等基础能力如何配合。
1.2 “△1-17~1-21”到底代表什么
这里的 △ 可以理解为一次迭代的标记,1-17 到 1-21 对应的是五个功能版本:
- 1-17:搭建页面基础结构,完成视觉框架。
- 1-18:实现文字打字机效果,让页面有动态叙事感。
- 1-19:加入 Canvas 爱心粒子动画,增强视觉表现力。
- 1-20:接入背景音乐和播放控制,处理移动端音频限制。
- 1-21:适配移动端并完成静态部署。
这样分割的好处是每个版本都有独立可运行的状态,即使中间某个功能做坏了,也不会影响前面的成果。实际的开发顺序也是按照这个路径推进的。
2. 环境准备与项目结构
2.1 开发环境说明
这个项目不依赖 Node.js 构建工具,但为了后面部署方便,你仍然可以选择安装 Node.js。如果你只写代码,使用浏览器开发者工具预览,那么只需要三个工具:
- 一个现代浏览器,推荐 Chrome 或 Edge。
- 一个文本编辑器,VS Code 或任何熟悉的编辑器都可以。
- 一个本地静态服务器环境,可以使用 VS Code 的 Live Server 插件。
如果你使用的是 Windows 系统,直接右键 index.html 选择“打开方式”再用浏览器预览也可以,但一些浏览器特性(比如模块加载)可能受限制。为了避免这类问题,建议尽量通过 Live Server 或者 Python 的http.server启动一个本地服务。
# 在项目根目录执行 python -m http.server 8080启动后浏览器访问http://localhost:8080即可。
2.2 目录结构规划
在动手写代码前,先把目录规划好。这个项目的文件结构如下:
your-first-love/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── typing.js │ ├── heart.js │ └── music.js ├── assets/ │ ├── music.mp3 │ └── avatar.png └── README.md如果你不喜欢把 JavaScript 拆成多个文件,也可以全部写在一个main.js中。但拆分的做法更符合工程习惯,每个文件只负责一件事:
typing.js管文字逐字显示。heart.js管 Canvas 粒子动画。music.js管音频播放和控制按钮的逻辑。
这种命名方式也可以直接告诉阅读者文件的作用,后期维护时会轻松很多。
3. 页面整体框架与视觉基础(1-17 版本)
3.1 HTML 骨架
第一个版本的目标是先让页面“立起来”,不需要花哨的动画,先把文字、图片、按钮的位置定好。核心 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> <link rel="stylesheet" href="css/style.css"> </head> <body> <div class="container"> <div class="card"> <div class="avatar"> <img src="assets/avatar.png" alt="avatar"> </div> <h1 id="typed-text"></h1> <p class="desc">这里会显示一句关于初心的文案</p> <div class="actions"> <button id="music-btn">播放音乐</button> </div> </div> </div> <canvas id="heart-canvas"></canvas> <script src="js/typing.js"></script> <script src="js/heart.js"></script> <script src="js/music.js"></script> </body> </html>这里先预留了id="typed-text"的空标题,后续版本会通过 JavaScript 往里填充文字。canvas元素一开始是透明的,不会影响页面布局,后面会用它绘制爱心粒子。
3.2 CSS 基础样式
为了让页面视觉上先过关,需要设置全屏背景、卡片居中和整体的字体氛围。这里使用了一个渐变背景,从浅粉到浅紫色,比较贴合“初恋”这个主题调性。
* { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #fbc2eb, #a6c1ee); font-family: "PingFang SC", "Microsoft YaHei", sans-serif; overflow: hidden; } .container { z-index: 2; position: relative; } .card { width: 360px; padding: 40px 30px; background: rgba(255, 255, 255, 0.75); border-radius: 20px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); text-align: center; backdrop-filter: blur(8px); } .avatar img { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; margin-bottom: 16px; } .desc { color: #666; font-size: 14px; margin: 12px 0 24px; } .actions button { border: none; padding: 10px 24px; border-radius: 24px; background: #ff6b9d; color: #fff; font-size: 14px; cursor: pointer; transition: opacity 0.3s ease; } .actions button:hover { opacity: 0.85; } #heart-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }这里要特别注意一个细节:canvas的pointer-events: none一定要加上。如果不加,它会挡住下方卡片的点击事件,音乐按钮就点不到了。
3.3 1-17 版本验证
写完这组代码后,打开页面应当能看到一个居中的毛玻璃卡片,卡片上有头像占位图、空标题、一段说明文字和“播放音乐”按钮,背景是粉紫渐变,卡片上方没有任何粒子。如果这些效果都正常,说明页面基础框架已经完成。
4. 打字机效果与文案叙事(1-18 版本)
4.1 打字机效果原理
打字机效果的核心思路是:把句子拆成单个字符,每隔一段时间向页面追加一个字符。实际开发中通常有两种实现方式:
setInterval定时轮询,每 100ms 追加一个字符。requestAnimationFrame配合时间差,更平滑但代码量稍多。
对于 1-18 版本,使用setInterval完全够用。这里采用 Function Declaration 封装typeText函数,参数分别是目标 DOM 元素、完整文案、速度、完成后回调。
4.2 typing.js 完整代码
// 文件路径:js/typing.js const typeText = (el, text, speed = 120, onDone = null) => { let index = 0; el.textContent = ""; const timer = setInterval(() => { if (index < text.length) { el.textContent += text.charAt(index); index++; } else { clearInterval(timer); if (typeof onDone === "function") { onDone(); } } }, speed); }; const titleEl = document.getElementById("typed-text"); const titleText = "你是我的初恋"; typeText(titleEl, titleText, 150, () => { // 打字完成后可以在控制台输出日志,方便调试 console.log("type finished"); });这段代码有一个值得注意的性能点:每次只追加一个字符,所以 DOM 操作很轻。如果你把textContent改成innerHTML,要担心 XSS 问题,但这里只是纯文本,所以textContent是最安全的选择。
4.3 速度与文案的搭配建议
“初恋”这个主题的文案不需要太长,六到十个字最佳。如果文字过长,打字速度可以适当调快;如果文字很短但速度很慢,页面会显得拖沓。
一个常用的搭配是:
- 标题 6 个字,速度 150ms;
- 副标题 15 个字以内,速度 90ms;
- 说明文案不超过一句,速度 70ms。
因为 1-18 版本只处理了一个标题,所以其他区域的打字效果可以后续扩展。你只需要把typeText函数换成自己需要的文案即可。
5. Canvas 爱心粒子动画(1-19 版本)
5.1 粒子系统的组成
爱心粒子动画的视觉效果来自大量小圆点围绕一个固定轨迹运动。实现思路可以分为三步:
- 在 Canvas 上绘制一个爱心轨迹,得到轨迹上的坐标点。
- 创建多个粒子,每个粒子初始位置在爱心轨迹附近。
- 每一帧更新粒子的位置和角度,形成围绕爱心旋转的效果。
这里我用参数方程来描述爱心曲线,这样代码更简洁。
爱心轨迹的参数方程:
x = 16 * sin³(t)y = 13 * cos(t) - 5 * cos(2t) - 2 * cos(3t) - cos(4t)
其中t取值范围是 0 到 2π。用这个方程可以生成一组点,再通过 Canvas 的scale和translate将坐标平移到画布中心。
5.2 heart.js 完整代码
// 文件路径:js/heart.js const canvas = document.getElementById("heart-canvas"); const ctx = canvas.getContext("2d"); const particles = []; let width, height, centerX, centerY; function resizeCanvas() { width = window.innerWidth; height = window.innerHeight; canvas.width = width * window.devicePixelRatio; canvas.height = height * window.devicePixelRatio; canvas.style.width = width + "px"; canvas.style.height = height + "px"; ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); centerX = width / 2; centerY = height / 2; } function getHeartPoint(t) { const x = 16 * Math.pow(Math.sin(t), 3); const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); return { x, y }; } function createParticles(count) { particles.length = 0; for (let i = 0; i < count; i++) { const t = Math.random() * Math.PI * 2; const point = getHeartPoint(t); particles.push({ baseX: point.x, baseY: point.y, x: point.x, y: point.y, angle: Math.random() * Math.PI * 2, speed: 0.02 + Math.random() * 0.03, size: Math.random() * 2 + 1, color: `rgba(255,${90 + Math.floor(Math.random() * 80)},150,0.8)`, }); } } function updateParticles() { for (const p of particles) { // 围绕基础坐标做小幅偏移 p.angle += p.speed; const offsetX = Math.cos(p.angle) * 3; const offsetY = Math.sin(p.angle) * 3; p.x = p.baseX * 10 + offsetX; p.y = p.baseY * -10 + offsetY; } } function drawParticles() { ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(centerX, centerY); for (const p of particles) { ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fillStyle = p.color; ctx.fill(); } ctx.restore(); } function animate() { updateParticles(); drawParticles(); requestAnimationFrame(animate); } window.addEventListener("resize", resizeCanvas); resizeCanvas(); createParticles(400); animate();这段代码里比较关键的细节是devicePixelRatio处理。如果不按设备像素比缩放 Canvas 的宽高,在 Retina 屏幕上粒子会非常模糊。代码中先把实际宽高乘以devicePixelRatio,再用ctx.setTransform把绘图坐标恢复为 CSS 像素坐标,这样既能保持清晰,又能让后续的translate逻辑更直观。
5.3 粒子数量与性能取舍
上面的代码设置了 400 个粒子。对于大多数电脑和手机来说,400 个粒子不会造成压力。但如果你在低端手机上测试,出现卡顿,可以把粒子数降到 200,或者把p.size和偏移量调小一些。
一个更稳妥的做法是通过navigator.hardwareConcurrency判断 CPU 核心数,然后动态调整粒子数量。不过在 1-19 版本里,暂时先用固定值。
6. 背景音乐播放与控制(1-20 版本)
6.1 移动端自动播放限制
很多初学者在接入音频时都会遇到一个现象:电脑上打开页面音乐能正常播放,但手机上一打开就是静音。这是因为 iOS Safari 和部分 Android 浏览器要求用户必须通过触摸事件触发音频播放,不允许网页自动播放有声内容。
所以常见的处理方式是:页面加载时不自动播放,用户点击按钮后开始播放,同时把按钮状态切换为暂停状态。
6.2 音乐控制代码
// 文件路径:js/music.js const musicBtn = document.getElementById("music-btn"); const audio = new Audio("assets/music.mp3"); audio.loop = true; let isPlaying = false; musicBtn.addEventListener("click", () => { if (isPlaying) { audio.pause(); musicBtn.textContent = "播放音乐"; } else { audio.play().then(() => { isPlaying = true; musicBtn.textContent = "暂停音乐"; }).catch((err) => { console.error("音乐播放失败", err); }); } });这里使用了Audio.prototype.play()返回的 Promise 来判断播放是否成功。在部分浏览器中,如果资源加载失败或不符合自动播放策略,play()会返回一个 rejected Promise,捕获后打印日志,方便排查。
6.3 音频文件体积优化建议
示例中直接把music.mp3放在assets目录下,但实际项目里音频文件可能会很大。建议在部署前对音频做以下处理:
- 使用
.mp3+.ogg两种格式,兼容不同浏览器。 - 把音频压缩到 128kbps 以下,一首纯音乐大约在 2MB 到 4MB 之间。
- 如果只做背景氛围音,甚至可以用 64kbps,人耳感知差异不大。
你可能需要换成一段轻音乐来配合页面,比如缓慢的钢琴曲。但要注意,不要随便使用有版权的音乐,如果是学习测试,可以先用自己创作的音频,或者使用无版权音乐平台下载的素材。
7. 移动端适配与静态部署(1-21 版本)
7.1 viewport 与字体适配
到 1-20 版本为止,页面在电脑上已经比较完整了,但在手机上打开可能有两个问题:
- 卡片宽度超出屏幕。
- 文字大小太小,看不清。
第一个问题可以通过.card { width: 88vw; max-width: 360px; }解决,第二个问题可以使用clamp()函数设置字体大小。
举个例子:
h1 { font-size: clamp(22px, 5vw, 32px); }这个写法的意思是:字体大小最小 22px,最大 32px,在中间区域根据视口宽度动态变化。
对于卡片内的间距,也可以使用相对单位:
.card { padding: clamp(20px, 5vw, 40px); }这样在窄屏下不会显得拥挤。
7.2 防止页面滚动穿透
本项目的背景是不滚动的,但移动端浏览器在触摸 Canvas 区域时可能会触发滚动。为了避免页面边缘出现空白,可以在 CSS 中加入:
html, body { overflow: hidden; position: fixed; width: 100%; }但要注意,position: fixed会让页面无法通过普通方式滚动。如果你的后续版本需要滚动查看长文案,就不要用这个方案,而是给body设置overscroll-behavior: none。
7.3 部署到 GitHub Pages
这一步是 1-21 的收尾工作。部署纯静态页面最推荐 GitHub Pages,因为免费、支持 HTTPS 且操作简单。
假设你的代码已经推送到 GitHub 仓库,可以在仓库设置中开启 Pages。部署地址格式通常是:
https://用户名.github.io/仓库名/也可以用 Vercel 部署,先安装 Vercel CLI:
npm install -g vercel然后在项目根目录执行:
vercel它会自动识别静态项目并生成一个预览地址,确认没问题后再vercel --prod发布到生产环境。如果你不想安装命令行工具,也可以直接在 Vercel 的网页控制台导入 GitHub 仓库。
8. 常见问题与排查思路
8.1 问题速查表
下面是开发这个项目时最容易遇到的几个问题,以及对应的排查方向。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 音乐不播放 | 移动端未触发用户手势 | 改为点击按钮后再调用 play() |
| Canvas 粒子模糊 | 没有处理 devicePixelRatio | 按设备像素比设置 canvas 尺寸 |
| 按钮点击没反应 | canvas 遮住了按钮 | 给 canvas 加 pointer-events: none |
| 打字机光标不消失 | 缺少光标隐藏逻辑 | 设置 cursor 样式或动画结束移除光标 |
| 手机页面可以左右滑动 | 内容宽度溢出 | 检查卡片宽度,改用 vw 或 clamp |
| 部署后图片加载 404 | 资源路径使用了绝对本地路径 | 改用相对路径或仓库根目录匹配的路径 |
| iOS 下背景音乐断断续续 | 没有设置 audio.loop 或音频格式不支持 | 改为 mp3 并开启 loop |
8.2 详细排查:Canvas 显示异常
如果打开页面后发现爱心粒子完全看不到,可以按下面的顺序排查:
- 打开控制台,看是否有红色报错。
- 打印
particles.length,确认粒子数组是否创建成功。 - 确认
canvas.getContext("2d")没有返回null。 - 检查
centerX和centerY是否在页面可见范围内。 - 查看
canvas元素的尺寸,如果宽高为 0,说明resizeCanvas()没有正确执行。
最常见的问题是忘记调用resizeCanvas(),或者在createParticles()之前就执行了animate()。按照代码顺序:先 resize,再 create,再 animate,就能避免大部分问题。
8.3 详细排查:音频加载失败
音频加载失败时,play()返回的 Promise 会进入 rejected 状态。在music.js中已经通过.catch()打印了错误,如果错误信息是NotSupportedError,说明浏览器无法解码该音频格式。此时可以:
- 转码为 MP3 格式。
- 检查
audio.src路径是否正确。 - 使用
audio.addEventListener("loadeddata", ...)监听音频是否真正加载完成。
如果文件路径错误,Network 面板里会显示 404。记得确认项目部署后目录结构是否和本地一致。
9. 最佳实践与工程建议
9.1 按功能拆分 JavaScript 文件
虽然这个项目很小,但拆分文件仍然有价值。它让你的代码更容易被阅读和测试,也方便后续加入新功能时明确修改位置。比如后面加入滚动动画,可以新增一个scroll.js,而不是把所有逻辑都塞进heart.js。
9.2 用模块化方式组织常量
很多同学会在 JavaScript 文件里写死文案和颜色,这样其实不太好。建议把可配置内容统一放到一个配置对象里:
const CONFIG = { title: "你是我的初恋", subtitle: "有些话,想慢慢说给你听", particleCount: 400, musicSrc: "assets/music.mp3", audioLoop: true, };这样做的好处是:以后修改文案、粒子数或者音乐路径时,只需要改一处,不需要在整个文件里搜索。
9.3 避免把交互状态分散在多个全局变量
在这个项目中,音乐播放状态用了一个isPlaying变量。如果是更复杂的交互,比如多首音乐、多个切换按钮,状态集中管理会更稳妥。现阶段只需要维护一个布尔值,暂时不需要引入复杂状态管理,但至少要把变量命名清晰,不要使用a1、tmp这类无意义命名。
9.4 性能优化与安全边界
Canvas 动画的每一帧都在执行循环,这在低性能设备上会占用较多 CPU。可以提供“低性能模式”,通过matchMedia("(prefers-reduced-motion: reduce)")检测用户是否开启了减少动态效果的系统设置,如果开启就降低粒子数量或停止动画。
安全方面要注意:不要在页面上直接填充不可信用户内容。如果用到了用户输入,请优先使用textContent而不是innerHTML。本项目没有后端,信息暴露面很小,但代码里依然不要写任何敏感信息,比如隐私密钥等。
9.5 部署前检查清单
在发布到线上之前,你可以按照下面的清单快速检查:
- 页面在 iPhone 和 Android 上都测试过。
- 音乐播放按钮能正常切换状态。
- Canvas 粒子不会遮挡卡片按钮。
- 页面标题、描述、favicon 已设置。
- 所有资源文件都已提交到仓库。
- 部署后访问的链接不是本地
localhost。 - 页面加载速度可接受,没有超大体积的图片和音频。
10. 总结与后续扩展方向
到这里,五个版本的内容已经全部梳理完了。从 1-17 的静态页面,到 1-18 的打字机效果,再到 1-19 的 Canvas 粒子动画、1-20 的音频交互,最后在 1-21 完成移动端适配与部署,一个完整的单页项目就成型了。
如果你想把项目继续做下去,可以考虑这些方向:
- 增加一个倒计时模块,显示从某个日期到当前时间的距离。
- 增加留言输入框,把内容保存到 LocalStorage。
- 加入滚动叙事,让不同文案和图片随着页面滚动依次出现。
- 换成 PWA 方案,让页面可以被安装到桌面和手机主屏。
- 把 Canvas 粒子改为跟随鼠标点击生成,增加互动感。
不要急着把所有效果都堆上去,先保证现有功能稳定,再逐个添加新模块。每次新增版本时,参考这篇文章里的迭代路径:先写基础结构,再补动态效果,最后做适配和部署。这样即使某个功能出问题,也能快速定位到对应版本。
希望这篇解读能帮你少踩一些坑。如果你在实现过程中遇到了不同的问题,也建议把报错内容原样贴到搜索引擎或社区,配合控制台日志一起排查,效率会高很多。动手改一改、试一试,你才能真正掌握这些代码背后的逻辑。