简介:这是一份面向程序员及前端初学者的 520 浪漫表白页面源码,围绕 HTML、CSS 与 JavaScript 组合实现逐行打印文案、文字随机变色、夜空背景、玫瑰花飘落,以及按钮触发烟花动画与背景音乐等效果,可用于情人节、纪念日、生日等需要营造氛围的场景,也适合作为前端动效与页面交互的练习素材。压缩包共 8 个文件,包含 1 个 HTML 页面、1 个 CSS 样式表、2 个 JavaScript 脚本、3 张 PNG 图片和 1 个 MP3 音效文件,整体约 524KB,结构轻量,便于按需修改文字、颜色、字体与背景素材。已有 5713 人浏览学习,说明其作为创意表白与前端入门案例具有一定参考价值。拿到后可直接在主流现代浏览器中打开运行,并依据源码理解定时输出、颜色变化、粒子或烟花动画和音频交互的实现思路,快速完成个性化定制。
1. 当 520 撞上浏览器:一份能直接跑起来的前端表白页面长什么样
五月中旬总有人丢个链接过来,问:这东西改个名字能不能直接用。打开是一个会跳动的心形,底下几行字慢慢浮出来。说穿了,它就是一份用 HTML、CSS 和 JavaScript 写的单页,没有后端、没有框架、没有构建步骤,双击index.html就能跑。这类 520 礼物页面的门槛已经被压到「会改字符串就能用」,可真动手时,麻烦从来不在浪漫的那一半,而在粒子数量、移动端适配、音频自动播放这几件枯燥的事上。这份代码适合三类人:赶纪念日交差的前端、想拿 Canvas 练手的学生、要给活动页做倒计时视觉的开发。接下来按文件结构、动效原理、交互实现、排错、复用的顺序拆开看。
2. 拆开代码包看骨架:HTML 结构、CSS 变量与文件组织
拿到一份别人写好的表白页,第一件事不是打开浏览器看效果,而是先把目录树列出来。这一步决定了后面改色、改文案、改文案出现顺序的时候,是在三个文件里来回跳,还是只动一个配置对象。我见过太多这类小页面最后被改成「每一处文案都硬编码在 HTML 里、每一个颜色都散落在 CSS 的三百行里」,改一句话要全局搜索六次。骨架没搭好,浪漫就变成了体力活。
2.1 三件套的职责边界:为什么不做成纯 Canvas
很多人第一反应是「全用 Canvas 一把梭」,连文字都画上去,这样看起来最酷。实际做下来会撞上一堆额外成本:文字不能被选中、不能被浏览器翻译、字体加载时机不好控,一旦用户系统里缺字体,fillText画出来的字形和设计稿能差出两个字号;更麻烦的是高 DPI 屏幕下要手动缩放字号,改一次文案就得重算一次基准线。
反过来,全用 DOM 也不行。几百个粒子如果每个都是一个div绝对定位,每秒改一次left/top,浏览器要重排几百个盒子,中低端手机上帧率会掉到十几。所以这份代码的做法是把职责切干净:
- Canvas 只画粒子、心形轮廓、飘落的光点,这些元素数量多、生命周期短、不需要被辅助技术读取。
- DOM 承担文案、按钮、日期、签名,可选中、可翻译、可被读屏软件识别。
- CSS 负责这两层之间的层叠关系、淡入淡出、以及不依赖 JavaScript 的那部分装饰动效。
层的顺序用一个很朴素的规则控制:Canvas 用position: fixed铺满视口并放在z-index: 0,文案容器放在z-index: 1,并且给 Canvas 加pointer-events: none。这样用户点按钮时事件不会被 Canvas 吞掉,也不用在 JavaScript 里做命中测试。
<!-- index.html 里的层叠结构 --> <canvas id="stage" aria-hidden="true"></canvas> <main class="content"> <h1 class="title">致那个总在加班的人</h1> <p class="typing" id="typing"></p> <button class="btn" id="startBtn" type="button">点我</button> </main>aria-hidden="true"是必须加的。Canvas 里的粒子对读屏软件毫无意义,不加的话有些读屏器会念出一段无意义的占位描述,反而干扰了真正重要的文案内容。这个属性属于「不加不会报错、加了才知道好在哪」的那类细节。
2.2 目录结构与入口文件的最小骨架
代码包的目录按职责分三层,不引入任何构建工具,直接用原生 ES Module 加载。现代浏览器对<script type="module">的支持已经没有兼容性顾虑,本地开发用一个静态服务器跑就行,file://协议下模块会被 CORS 拦掉,这是新手最容易卡住的第一关。
love-page/ ├── index.html ├── css/ │ ├── reset.css # 归零默认样式 │ └── main.css # 变量、布局、动画 ├── js/ │ ├── config.js # 文案、颜色、粒子上限,全部配置集中在这 │ ├── heart.js # 心形采样与粒子系统 │ └── main.js # 交互编排、打字机、生命周期 └── assets/ ├── bgm.mp3 └── photo.jpg入口文件保持极简,只做三件事:声明视口、引入样式、按依赖顺序引入脚本。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <!-- viewport-fit=cover 用于适配刘海屏,别顺手关掉用户缩放 --> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <title>给你的一个小页面</title> <link rel="stylesheet" href="./css/reset.css" /> <link rel="stylesheet" href="./css/main.css" /> </head> <body> <canvas id="stage" aria-hidden="true"></canvas> <main class="content"> <h1 class="title">致那个总在加班的人</h1> <p class="typing" id="typing"></p> <button class="btn" id="startBtn" type="button">点我</button> </main> <!-- type="module" 自带 defer 语义,DOM 解析完才执行 --> <script type="module" src="./js/main.js"></script> <noscript><p class="noscript">请开启 JavaScript 后重新打开这个页面。</p></noscript> </body> </html>几个参数值得单独说。viewport-fit=cover只在有env(safe-area-inset-*)配合时才有意义,光加这一句不会有任何效果。user-scalable=no我一般不写,虽然它能防止用户在双指缩放时把布局搞乱,但代价是牺牲了可访问性,视力不好的长辈恰恰最需要放大看那几行字。<noscript>那段是后悔药,万一对方用的浏览器禁用了脚本,至少能看到一句提示,不是一个纯白页面。
2.3 CSS 自定义属性:把「浪漫配色」变成可调参数
配色是这类页面被改得最多的地方。把色值写死在选择器里,换一次主题就要动十几个地方;抽成自定义属性之后,改主题等于改变量块,甚至可以由 JavaScript 在运行时覆写。
/* main.css 顶部:所有可调参数集中在这里 */ :root { --bg-deep: #0b0714; /* 背景底色,偏深紫比纯黑更耐看 */ --bg-glow: #2a1240; /* 背景径向渐变的亮部 */ --heart-core: #ff4d6d; /* 心形主色 */ --heart-glow: #ff9ebb; /* 粒子拖尾与光晕 */ --text-main: #f5eefc; --text-sub: rgba(245, 238, 252, 0.72); --fade-duration: 1.2s; /* 文案淡入时长 */ --float-duration: 6s; /* 装饰元素漂浮周期 */ --content-max: 640px; /* 文案区最大宽度 */ } body { margin: 0; min-height: 100vh; min-height: 100dvh; /* 移动端地址栏收起时更稳定 */ background: radial-gradient(circle at 50% 40%, var(--bg-glow) 0%, var(--bg-deep) 62%); color: var(--text-main); font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; overflow: hidden; /* 粒子飞出视口时不产生滚动条 */ } .content { position: relative; z-index: 1; /* 压在 Canvas 之上 */ max-width: var(--content-max); margin: 0 auto; padding: 18vh 24px 0; text-align: center; }min-height: 100vh后面紧跟一行100dvh是刻意的降级写法:支持动态视口的浏览器会用后一行覆盖前一行,不支持的会忽略它,行为退回到100vh。这个顺序不能反,反了就等于没写。
注意:CSS 变量只在 CSS 里自动生效,Canvas 里的颜色不会自己跟着变。要读出来得走
getComputedStyle(document.documentElement).getPropertyValue('--heart-core'),并且记得trim(),因为取出来的字符串常常带前导空格,直接塞给ctx.fillStyle会静默失败——不报错,只是画出来是黑的。
运行时换主题的思路就是反过来覆写:
// 运行时切换主题:只动变量,不动任何选择器 export function applyTheme(theme) { const root = document.documentElement; Object.entries(theme).forEach(([key, value]) => { root.style.setProperty(key, value); }); // 通知 Canvas 重新读取颜色 window.dispatchEvent(new CustomEvent('theme:change')); } // 调用示例 applyTheme({ '--heart-core': '#7ad7ff', '--heart-glow': '#bfe9ff', });heart.js里监听theme:change事件,重新执行一次取色逻辑即可。这里用自定义事件而不是直接调用函数,是为了不让颜色模块反向依赖渲染模块,后面想加一个「跟随系统深浅色自动切换」的功能时,只要再派发一次事件,不用改渲染代码。
3. 动效怎么落地:Canvas 粒子爱心与 CSS 动画的分工
页面能不能让人「哇」一声,九成取决于心形那一段。这一章讲清楚三件事:心形的点是怎么算出来的、粒子为什么要缓动而不是直接跳过去、以及在没有 Canvas 或用户开了减弱动效时怎么优雅退场。搞明白这三件事,改出来的东西才不会变成「能动但很廉价」的效果。
3.1 心形参数方程与采样点生成
心形不是随手拿贝塞尔曲线描出来的,它有一条经典的参数方程,在极坐标和直角坐标下都有对应形式。这份代码用的是下面这一组:
// heart.js —— 心形轮廓采样 export function buildHeartPoints(count, scale, cx, cy) { const points = []; for (let i = 0; i < count; i++) { const t = (i / count) * Math.PI * 2; 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); points.push({ x: cx + x * scale, // Canvas 的 y 轴向下,这里必须取反,否则心是倒的 y: cy - y * scale, }); } return points; }逻辑说明:这个方程在t ∈ [0, 2π]上闭合成一条心形曲线,x的理论范围约[-16, 16],y约[-17, 12]。所以缩放系数scale取Math.min(width, height) / 42左右,心形刚好能落在视口中央偏上一点的位置,四周留白足够放文案。取 42 而不是 32,是因为要给粒子拖尾和呼吸缩放留余量。
参数说明:count决定轮廓的细腻程度。低于 200 时曲线会有明显的折角,尤其心尖那个位置;800 到 1200 之间肉眼已经分辨不出差异,再往上只是白白消耗帧时间。我一般取 900,然后按屏幕宽度做一档降级——视口宽度小于 480 时降到 500。
只有轮廓是不够的,看起来像一根线。要变成实心的,得往里填点。做法是在半径方向做随机插值:把每个轮廓点乘以一个[0, 1]区间的随机数,向中心收缩。
// 用轮廓点向内收缩,生成填充粒子 export function fillHeart(edgePoints, density = 0.55, cx, cy) { const inner = []; for (const p of edgePoints) { if (Math.random() > density) continue; // sqrt 让点更靠近边缘分布,避免中间糊成一坨 const r = Math.sqrt(Math.random()); inner.push({ x: cx + (p.x - cx) * r, y: cy + (p.y - cy) * r, }); } return inner; }这里用Math.sqrt(Math.random())而不是直接用Math.random(),是因为均匀随机数在半径方向上会让点往中心聚集,圆心附近会糊成一团。取平方根之后分布会向外扩,边缘密度更高,心形轮廓更清晰。这个细节不做,画出来的心会有种「中间一块实心色块」的廉价感。
3.2 粒子系统:缓动、requestAnimationFrame 与帧率无关
粒子最容易翻车的地方是「用固定增量位移」。写成p.x += (p.tx - p.x) * 0.08看着没问题,但在 120Hz 屏幕上跑得比 60Hz 快一倍,同一份代码在两台手机上观感完全不同。正确做法是拿两次requestAnimationFrame之间的时间差做归一化。
// heart.js —— 粒子与主循环 const BASE_K = 0.08; // 基础缓动系数 const REF_INTERVAL = 16.67; // 60Hz 下的单帧时长 class Particle { constructor(target) { this.tx = target.x; this.ty = target.y; // 初始位置从一个随机的小圆环上散开,形成聚合效果 const a = Math.random() * Math.PI * 2; const d = 220 + Math.random() * 320; this.x = target.x + Math.cos(a) * d; this.y = target.y + Math.sin(a) * d; this.size = 0.8 + Math.random() * 1.8; this.alpha = 0.35 + Math.random() * 0.65; } update(dt) { // 把固定系数换算成与帧率无关的等价阻尼 const k = 1 - Math.pow(1 - BASE_K, dt / REF_INTERVAL); this.x += (this.tx - this.x) * k; this.y += (this.ty - this.y) * k; } } export function createLoop(canvas, particles, color) { const ctx = canvas.getContext('2d'); let last = performance.now(); function frame(now) { const dt = Math.min(now - last, 40); // 切标签页回来时 dt 会很大,必须夹住 last = now; ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); ctx.fillStyle = color; for (const p of particles) { p.update(dt); ctx.globalAlpha = p.alpha; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; requestAnimationFrame(frame); } requestAnimationFrame(frame); }Math.min(now - last, 40)这行是血泪经验。用户切到微信回个消息再切回来,now - last可能是好几万毫秒,代进缓动公式后k直接逼近 1,所有粒子会在一帧内瞬移到目标位置,聚合动画彻底失效。夹到 40ms 相当于「最多按两帧半的时长推进」,回来时动画是接着走而不是跳过去。
顺带说拖尾。想要粒子留残影,别用clearRect,换成半透明覆盖:
// 用带透明度的矩形覆盖代替清屏,形成拖尾 ctx.fillStyle = 'rgba(11, 7, 20, 0.22)'; ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);透明度越低拖尾越长,0.22大概对应七八帧的余晖。这个值超过0.5基本看不出拖尾,低于0.1会糊成一片。还要注意这行必须在画粒子之前执行,且颜色要用不透明的背景色,不能用transparent,否则越叠越黑。
3.3 CSS 侧动效与降级:prefers-reduced-motion
不是所有动效都值得用 Canvas。大标题的呼吸缩放、按钮的悬停位移、装饰光点的漂浮,这些用 CSS 更省事,也不占用主线程。
/* 标题呼吸:用 transform 而非 font-size,避免触发布局重排 */ .title { font-size: clamp(22px, 6vw, 36px); letter-spacing: 0.08em; animation: breathe var(--float-duration) ease-in-out infinite; } @keyframes breathe { 0%, 100% { transform: scale(1); opacity: 0.92; } 50% { transform: scale(1.04); opacity: 1; } } /* 尊重系统的减弱动效设置:关掉循环动画,保留静态呈现 */ @media (prefers-reduced-motion: reduce) { .title, .glow { animation: none !important; } .content > * { transition-duration: 0.01ms !important; } }clamp(22px, 6vw, 36px)这组参数在 360px 到 1024px 的视口上都能落在可读区间,比写两套媒体查询省事。animation用transform和opacity是硬要求,改font-size或margin会触发每帧重排,在低端机上直接掉帧。
prefers-reduced-motion这段容易被跳过,但它其实是整个页面里唯一一处「照顾到对方真实状态」的代码。有些人晕动症比较敏感,满屏粒子加呼吸缩放会让人不适。检测到减弱动效时,除了关 CSS 动画,JavaScript 那边也应该降低粒子数或干脆只画静态心形——用window.matchMedia('(prefers-reduced-motion: reduce)').matches读一次,把结果传进初始化函数就行。
4. 交互与文案:打字机效果、按钮状态机与移动端适配
前三章解决的是「看得见」,这一章解决的是「点得动」。表白页的交互链路其实很短:进页面 → 文字逐字出现 → 点一下按钮 → 触发后续动画或音频。链路短不代表好写,定时器没清干净、按钮连点触发两次、手机上音频被拦,这些都是真实发生过的问题。
4.1 打字机效果的定时器写法与中止处理
打字机的经典写法是setInterval里累加innerHTML。这个写法有两个坑:一是往innerHTML里追加会让浏览器反复解析 HTML,遇到<之类的字符还会被当成标签;二是setInterval一旦忘了clearInterval,用户中途关掉弹层,定时器还在后台跑。
换成async加await的写法,中止逻辑会干净很多:
// main.js —— 可中止的打字机 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function typeWriter(el, text, options = {}) { const { speed = 90, signal } = options; el.textContent = ''; for (const ch of text) { if (signal?.aborted) return; // 外部中止时立刻退出,不留尾巴 // 用 textContent 逐字拼接,避免 HTML 注入与重复解析 el.textContent += ch; // 标点处多停一拍,节奏更像真人在敲 const pause = /[,。!?、]/.test(ch) ? speed * 4 : speed; await sleep(pause); } } // 调用方 const controller = new AbortController(); typeWriter(document.getElementById('typing'), '今天不想说工作的事,只想说……', { speed: 90, signal: controller.signal, }); // 用户点了跳过,就把它掐掉 document.getElementById('startBtn').addEventListener('click', () => { controller.abort(); });用for...of遍历字符串而不是split(''),是因为split('')会把 emoji 和部分中文字符切成两半,出现乱码方块。for...of按码点迭代,能正确处理这些字符——文案里放个爱心 emoji 的时候就靠这个。
AbortController是这里的关键。相比自己维护一个let stopped = false的布尔量,它的好处是可以派生出多路信号,后面想同时中止打字机和粒子动画,传同一个signal就行,不用在十几个函数之间传标志位。
4.2 按钮状态机:从「点一下」到完整流程
按钮最容易出的 bug 是连点。用户手快点了三下,如果每次点击都启动一段动画,就会出现三段动画叠在一起、音频叠着播、文案被覆盖。解决办法不是disabled一禁了之(那样视觉上会突然变灰,很难看),而是引入一个极简状态机。
// main.js —— 三态按钮 const STATE = { IDLE: 'idle', // 等待点击 RUNNING: 'running', // 动画播放中,忽略点击 DONE: 'done', // 播完了,按钮变成重播 }; export function createButton(el, onFirstClick, onReplay) { let state = STATE.IDLE; el.addEventListener('click', async () => { if (state === STATE.RUNNING) return; // 关键:挡住连点 if (state === STATE.DONE) { state = STATE.RUNNING; el.disabled = true; await onReplay(); state = STATE.DONE; el.disabled = false; return; } state = STATE.RUNNING; el.disabled = true; el.textContent = '正在准备…'; try { await onFirstClick(); state = STATE.DONE; el.textContent = '再看一次'; } catch (err) { // 失败要能回到可点状态,否则按钮永久卡死 state = STATE.IDLE; el.textContent = '再试一次'; console.error('[button] 首屏动画启动失败', err); } finally { el.disabled = false; } }); }try/catch/finally这段不是凑数。onFirstClick里如果包含音频播放,play()返回的是一个可能被拒绝的 Promise,浏览器在用户手势不明确或者音频文件 404 的时候会 reject。不接住的话,state会永远停在RUNNING,按钮变成一个点不动的死按钮,用户只会觉得「坏了」。
4.3 移动端与性能:dpr、100vh 与触摸事件
这段代码在桌面浏览器上跑得挺好,一到手机上大概率会糊。原因是 Canvas 的 CSS 尺寸和它的位图尺寸是两个概念,不处理设备像素比,等于用一倍图去填三倍屏。
// main.js —— Canvas 尺寸与 DPR 处理 export function resizeCanvas(canvas, ctx) { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); // 用 setTransform 把绘图坐标系缩回 CSS 像素,后续代码不用再乘 dpr ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } // 视口变化时重算,注意防抖,否则移动端地址栏收起会疯狂触发 let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const canvas = document.getElementById('stage'); resizeCanvas(canvas, canvas.getContext('2d')); }, 150); });Math.min(dpr, 2)这个上限是性能保护。有些安卓机的devicePixelRatio能到 3.5,按真实值渲染意味着像素数量是逻辑尺寸的十二倍,粒子一多直接掉到个位数帧率。封顶 2 之后,三倍屏上肉眼几乎看不出差别,帧率能回来一大截。
触摸事件方面,pointer-events: none已经解决了 Canvas 吞点击的问题。剩下的一个细节是touch-action:如果页面里有可以拖动的元素,不加touch-action: none的话,手指滑动会先触发页面滚动,元素跟不动。
.stage, .draggable { touch-action: none; /* 阻止默认滚动接管手势 */ -webkit-tap-highlight-color: transparent; /* 去掉移动端点击时的高亮方块 */ }-webkit-tap-highlight-color那行纯属观感问题,但在一个讲究氛围的页面上,点一下冒出一个灰方块确实出戏。这行代码不影响任何功能,纯粹是「加过才知道」的那类。
5. 避坑与排查:五个真实翻车现场
前面几章的代码拿过去基本能跑,但「能跑」和「发给对方后不出事」之间隔着一段距离。下面这五个问题是我在这种页面上反复遇到的,按现象、原因、解决三段记下来,遇到时能少绕些弯。
5.1 打开一片纯白,控制台却没什么红字
现象:本地双击index.html,页面全白,只有背景色,文案和粒子都不见。控制台可能只有一行Access to script at 'file:///...' has been blocked by CORS policy,也可能什么都没有。
原因:用了<script type="module">,模块脚本在file://协议下受同源策略限制,浏览器直接拒绝加载。这不是代码写错,是协议本身不支持。
解决:本地起一个静态服务器,不要双击打开。
# Python 3 自带,不用装任何东西 python3 -m http.server 5173 # 然后访问 http://localhost:5173发给对方时也一样,直接发文件夹里的index.html会重现这个问题。要么部署到静态托管,要么把三个 JS 文件合并成一个普通<script>(去掉type="module"和import/export),代价是失去模块化。
5.2 电脑上丝滑,手机上卡成幻灯片,还发烫
现象:桌面浏览器稳定 60 帧,同一份代码在手机上明显掉帧,手机背面温度上升很快。
原因通常有三个叠加:粒子数量按桌面视口算的没降级、每帧都在新建对象、Canvas 尺寸没有按 dpr 封顶。最隐蔽的是第二个——在frame函数里ctx.beginPath()之后每次都new Path2D(),或者每帧particles.map()生成新数组,垃圾回收会被高频触发。
解决:按设备做三档降级,并复用对象。
// 按视口宽度决定粒子预算,移动端砍到三分之一 function getParticleBudget() { const w = window.innerWidth; if (w < 480) return { edge: 400, fill: 0.35 }; if (w < 1024) return { edge: 700, fill: 0.45 }; return { edge: 900, fill: 0.55 }; }改完之后用 Chrome DevTools 的 Performance 面板录十秒,看 Framing 那一栏有没有红色的长任务。如果主线程占用仍然高,把拖尾的fillRect也去掉,改成每帧全清,能再省一点。
5.3 音频在电脑上自动播,在手机上死活不响
现象:桌面打开页面音乐就起来了,手机上点了按钮还是没声音,控制台提示NotAllowedError: play() failed because the user didn't interact with the document first。
原因:移动端浏览器的自动播放策略比桌面严格得多,音频必须在明确的用户手势回调里启动,而且部分内置浏览器还要求手势的调用栈不能断在await之后。
解决:把play()放在点击事件的同步代码路径里,不要经过await。
btn.addEventListener('click', () => { const audio = document.getElementById('bgm'); // 同步调用,不要先 await 别的东西再播 const p = audio.play(); if (p) p.catch((err) => console.warn('[audio] 播放被拦截', err)); // 播放之后再去做其他异步的事 startAnimation(); });如果确实需要在动画开始后才播,那就把音频播放提前到点击的第一行。另外记得给audio加preload="auto",不然点下去才开始下载,第一秒是静音。
5.4 微信里打开是空白,或者样式全乱
现象:自己在浏览器里测得好好的,发给对方,对方在聊天窗口里点开,白屏或者布局错位。
原因:内置浏览器的内核版本往往落后于系统浏览器,100dvh、AbortController、部分 CSS 新特性可能不支持。还有一个高频原因是相对路径——部署到二级目录后,./css/main.css能解析对,但写成/css/main.css就会 404。
解决:所有资源统一用相对路径./;对AbortController这类 API 做一次存在性判断,没有就退化成布尔标志。
// 优雅降级:不支持 AbortController 时用一个简单标志替代 const canAbort = typeof AbortController !== 'undefined'; const controller = canAbort ? new AbortController() : { signal: { aborted: false } };布局上把100dvh保留但确保100vh在前,gap之类的属性给出margin兜底。测的时候别只用桌面 Chrome 的移动模拟,真机扫一次二维码能省掉大量猜测。
5.5 改完文案,动画时序全乱了
现象:把三行短句换成一段长文,打字机还在跑,后面的按钮已经弹出来了,或者文字还在逐字出现就被下一段覆盖。
原因:时序写死成了setTimeout(3000),文案一变长,三秒根本打不完。这种写法本质上是把「等待」和「完成」这两件事混在了一起。
解决:全部改成await串行,让每一步等上一步真正结束。
// 用 await 串起来,时长随内容自适应 async function runSequence() { await typeWriter(line1El, config.line1, { speed: 90, signal }); await sleep(600); await typeWriter(line2El, config.line2, { speed: 90, signal }); await sleep(400); btnEl.classList.add('is-visible'); }这样换多长的文案都不会错位,唯一的代价是整体时长不可预测——但约会这种事,本来也不该卡着秒表。
6. 进阶玩法:把表白页做成可配置的一次性模板
做到这里页面已经能用了,但每次换个对象就得重新翻一遍代码,改文案、改日期、改配色,改到第三次就想重写。我的做法是把所有会变的东西抽到一个config.js里,主逻辑一个字符都不动。
6.1 把可变项全部收进配置对象
// config.js —— 换人换内容只改这个文件 export const CONFIG = { name: '小 A', lines: [ '认识你的第 428 天,', '我把想说的话,写成了一个会动的小页面。', '不擅长表达,但这件事我想认真做一次。', ], theme: { '--heart-core': '#ff4d6d', '--heart-glow': '#ff9ebb', '--bg-glow': '#2a1240', }, typingSpeed: 90, // 每字间隔,毫秒 particleBudget: 'auto', // auto 按视口降级,也可写死数字 bgm: './assets/bgm.mp3', bgmVolume: 0.35, };main.js只从CONFIG读值,不出现任何硬编码文案。这样交付的时候,对方想自己改内容也只需要认一个文件。bgmVolume我一般压在0.3到0.4之间,手机外放时这个音量刚好能听清又不至于吓人一跳,写1.0的人在真机上试过一次就会改。
6.2 上线前的自检清单
发出去之前我会照着下面这张表走一遍,五分钟能省掉后面几小时的尴尬。
| 检查项 | 怎么查 | 不合格的表现 |
|---|---|---|
| 模块加载 | 用本地服务器打开,看控制台 | file://报 CORS |
| 移动端布局 | 真机打开,横竖屏各转一次 | 文案被裁、按钮点不到 |
| 音频播放 | 静音模式下点按钮,再关静音 | 全程无声或报 NotAllowedError |
| 帧率 | DevTools Performance 录 10 秒 | Framing 出现长红条 |
| 降级 | 系统开启「减弱动效」后再看 | 粒子仍在满屏飞 |
| 资源路径 | 部署到二级目录后访问 | 样式或图片 404 |
| 文案长度 | 把lines换成两倍长度 | 动画错位、文字被截断 |
| 无障碍 | 用读屏软件听一遍 | 念出无意义的 Canvas 描述 |
表里最后两项最容易被跳过,也最容易变成「发出去之后才被发现」的问题。文案长度那条尤其如此——测试时写的都是短句,真到用的时候总有人想多说两句。
6.3 一个可以顺手加上的小技巧
如果想让页面显得更「认真」,可以在加载完成后做一个极短的延迟再开始动画,比如await sleep(300)。这三百毫秒会让页面先安安静静地展示背景和标题,再开始逐字出现,观感上比一打开就噼里啪啦要稳得多。代价是用户可能以为页面卡住了,所以延迟别超过五百毫秒。
从那以后我每次交付这类页面,都会先在真机上走一遍那张表,尤其是「换成长文案」「关掉声音」「开减弱动效」这三条,一条不落地跑完才敢发链接。浪漫这件事情,翻车一次就够记很久了。希望帮到你。
本文还有配套的精品资源,点击获取