☰
纯静态情人节表白网页:Canvas粒子爱心、打字机与移动端避坑
2026/10/29 11:42:28 网站建设 项目流程

简介:一份围绕情人节与表白场景打造的成品网页包,面向希望用现成页面向女友表达心意的同学,也适合作为HTML、CSS与JavaScript入门练手项目。页面涵盖首页、相册、心情记录与历程展示等模块,采用Bootstrap响应式布局,配合爱心图标、气泡与照片素材,并通过jQuery实现鼠标爱心、点击动画等交互,日期与图片位置均留有个性化替换空间。压缩包共43个文件,以html页面、css样式、js脚本为主,另含jpg、png、gif图片、字体文件与map映射,整体约1.08MB,结构清晰,可直接在本地打开预览。已有9240人学习下载,读者能获得一套可运行的浪漫主题页面与素材组织范例,并据此替换成自己的照片和纪念日期,快速做出专属表白页。

1. 一份能直接双击打开的表白单页:它替你省掉了哪些工程活

情人节前一周被朋友拉住救场的情况,我遇到过不止一次:预算零、时间一个晚上、要求“打开就像拆礼物”。这类送给女朋友的精美网页,每年的表白女朋友 网页需求都会在情人节前后集中冒出来一批,真正卡住人的从来不是文案写得够不够甜,而是打开白屏、手机上一片错位、音乐一声不吭。这份资源给的是一个纯静态单页:HTML + CSS + 原生 JavaScript,没有构建工具、不依赖任何框架,双击就能预览,丢到任意静态托管上就能分享。它把“讲一句话给人听”拆成四层视觉节奏——Canvas 粒子爱心、逐字浮现的文案、照片时间轴、最后一屏的提问按钮。适合没空研究前端工程、但又不愿意交付一个半成品的人。

这里先说明一个前提:这四层节奏属于设计选择,并非硬性规定。你也可以只保留粒子爱心和最后一句文案,把页面压缩到两百行以内,效果同样成立,而且加载更快。后面几章我会把每一层单独拆开讲清楚,你按需要取用就行。


2. 骨架与节奏:把“四屏”拆成可维护的 DOM 结构

很多人第一反应是“这种页面不就是堆 CSS 动画吗”,真动手就会发现,难点全在结构。四屏内容如果全塞进一个div里,改一句文案要重新调位置,换一张照片要重算高度,加一个动效可能把别的地方顶歪。我一般会先把 DOM 的层级定死,再往里面填视觉。

为什么不做成一张长滚动页?移动端滚动会带出浏览器自己的回弹和地址栏收起动画,这两个东西你控制不了,节奏会被打散。四屏定高、按需切换,观感反而更稳定,代码也更好写。至于为什么不上任何单页框架——这个量级的页面不需要路由、不需要状态管理、不需要构建,引入框架只会让你多一个打包步骤和一堆调试成本。

2.1 目录结构与资源命名

先看目录。文件不多,但每一项都要落在固定的位置上,后面排查问题的时候才不会到处翻。

love-site/ ├── index.html # 唯一入口 ├── css/ │ ├── reset.css # 归一化,禁用默认 margin │ └── main.css # 全部视觉样式 ├── js/ │ ├── heart.js # Canvas 粒子爱心 │ ├── typewriter.js # 文案逐字浮现 │ └── app.js # 场景切换与交互 └── assets/ ├── img/ # p01.jpg ~ p06.jpg ├── music/ # bgm.mp3 / bgm.m4a └── font/ # 字体子集 .woff2

命名上只有一条必须遵守的规定:全小写、无空格、无中文、序号补零。原因是本地开发用的是什么系统无所谓,但绝大多数静态托管跑在区分大小写的文件系统上。本地写IMG/P1.JPG能打开,传上去就是 404,而且浏览器不会报错,只给你一片白。序号补零是为了排序稳定,p1和p10在字符串排序里会挨在一起,p01和p10不会。

还有一类容易漏掉的情况:assets/img/下面放了几张图,但index.html里只引用了其中几张,多出来的那张是压缩前的原图,单张五六兆。上传后首屏没人访问它,但它占着仓库体积和部署时间。交付前用一条命令把目录里的实际文件复核一遍:

# 在项目根目录执行,检查引用了但磁盘上不存在的资源 grep -oE '(src|href)="[^"]+"' index.html \ | sed -E 's/.*="([^"]+)"/\1/' \ | grep -vE '^(https?:|#|data:)' \ | while read -r f; do [ -f "$f" ] || echo "缺失: $f"; done

这条命令的作用是:把 HTML 里所有相对路径引用提取出来,去掉外链、锚点和内联数据,然后逐个检查文件是否存在。参数上没什么可调的,唯一要注意的是它只扫index.html,如果你的 CSS 里也有url()引用,得另外走一遍。我习惯在每次打包前跑一次,比打开浏览器按 F12 一个个看快得多。

2.2 视口单位与安全区:100dvh 接管

移动端最容易翻车的地方就在视口高度。历史做法是height: 100vh,但vh按“大视口”计算——也就是地址栏收起时的那个高度。结果就是页面打开时内容被地址栏盖住一截,用户一划又跳一下。现代浏览器给了新的单位:dvh是动态视口高度,随地址栏收放实时变化;svh是小视口;lvh是大视口。这里要的是dvh。

:root { /* 兜底:不认识 dvh 的老浏览器退回到 vh */ --stage-h: 100vh; } @supports (height: 100dvh) { :root { --stage-h: 100dvh; } } .stage { height: var(--stage-h); /* 底部安全区:全面屏手势条会盖住内容 */ padding-bottom: env(safe-area-inset-bottom, 0px); box-sizing: border-box; overflow: hidden; }

逻辑说明:先用--stage-h声明一个100vh的默认值,再用@supports检测浏览器是否支持dvh,支持就覆盖掉。这样在不支持的设备上不会因为一条无效声明把整段高度丢掉。env(safe-area-inset-bottom)需要在<meta name="viewport">里带上viewport-fit=cover才会返回非零值,否则永远是 0,这一点如果漏了,全面屏机型上最后一屏的按钮会被手势条压住。

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

另一个常见做法是用 JavaScript 读取window.innerHeight再写入一个--vh变量,我可以明确说:能不用就不用。它在首屏渲染时拿到的值和最终值经常对不上,会出现一次可见的跳动,而dvh是浏览器原生计算的,没有这个问题。

2.3 场景切换的状态机与 class 驱动

四屏之间的切换,我强烈建议做成一个极简的状态机,而不是散落在各处的if判断。样式靠 class 驱动,JavaScript 只负责改 class 和抛事件。

// js/app.js const scenes = [...document.querySelectorAll('.scene')]; let current = 0; function goTo(index) { if (index < 0 || index >= scenes.length || index === current) return; const from = scenes[current]; const to = scenes[index]; from.classList.remove('is-active'); from.setAttribute('aria-hidden', 'true'); to.classList.add('is-active'); to.removeAttribute('aria-hidden'); current = index; // 抛事件而不是直接调用:粒子、音频、打字机各自监听,互不依赖 document.dispatchEvent(new CustomEvent('scene:change', { detail: { index, from: from.dataset.name, to: to.dataset.name } })); } document.getElementById('start-btn').addEventListener('click', () => goTo(1));

逻辑说明:goTo只做三件事——移除旧场景的激活态、赋给新场景、广播一次变化。第三个参数里的from/to取自>.scene { position: absolute; inset: 0; opacity: 0; visibility: hidden; transition: opacity 600ms ease, transform 600ms ease; transform: translateY(12px); } .scene.is-active { opacity: 1; visibility: visible; transform: none; }

这里用visibility而不是display: none,是为了让过渡动画还能跑;用opacity做淡入是因为它只触发合成层,不触发重排,中低端机上也扛得住。transform: translateY(12px)那点位移是给切换一个方向感,数值别太大,超过 24px 在短屏手机上会明显露出下一屏的边。

aria-hidden那一行不只是给读屏用户看的,它也影响浏览器对元素的焦点管理——被隐藏的屏幕上如果有按钮,不加这个属性的话,键盘 Tab 键会跳到看不见的按钮上。这个细节在本地调试时发现不了,真机接蓝牙键盘才会暴露。我第一版就是漏了这个,后来在那台旧平板上试出来的,改起来只要一行,但不知道原因的话能查半天。


3. Canvas 爱心粒子与打字机:动画层怎么写才不掉帧

视觉层是这类页面的门面,也是最容易把性能吃干净的地方。我见过太多版本在电脑上看行云流水,换到中端安卓机上就是幻灯片。原因基本都出在两处:Canvas 没有按设备像素比裁,以及每帧都在创建新对象。

3.1 心形参数方程取点与缩放

心形轮廓用的是那套经典参数方程,不用记,能看懂每一项的作用就够了。

// js/heart.js function heartPoint(t) { // t ∈ [0, 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); // Canvas 的 y 轴向下,取反之后心尖朝下 return { x, y: -y }; }

逻辑说明:这条方程给出的横坐标范围是[-16, 16],纵坐标大概落在[-17, 12]区间,也就是说它的“自然宽度”约 32 个单位。这意味着你在使用时必须先乘一个缩放系数,把单位坐标映射到像素坐标,否则画出来只有巴掌大。

具体的系数按容器最短边来计算最稳妥:

const scale = Math.min(W, H) / 40; // 40 而不是 32,留出边距防止被裁

分母取 40 是个经验值:方程实际跨度约 32,除以 32 会让心形顶到画布边缘,粒子在边缘处被裁掉一半,看起来像毛边。取 40 相当于留了约 20% 的余量。

如果你想让轮廓不那么“规整”,可以在取点之后加一点随机抖动,形成星尘感:

const jitter = 2.5; // 像素,别超过 4 p.tx += (Math.random() - 0.5) * jitter; p.ty += (Math.random() - 0.5) * jitter;

抖动值超过 4 之后,心形的辨识度会开始下降,尤其是粒子数量少的时候,看起来就是一坨噪点,不再是心形。

3.2 粒子汇聚的缓动、DPR 与移动端降级

粒子从随机位置飞向目标点,用弹簧模型比线性插值自然得多。核心就是三行:累加速度、加阻尼、位移。

const canvas = document.getElementById('heart-canvas'); const ctx = canvas.getContext('2d'); let W = 0, H = 0, dpr = 1; let particles = []; let rafId = null; const CONF = { count: window.innerWidth < 768 ? 260 : 620, // 移动端直接减量 radius: 0.8, // 粒子半径,像素 ease: 0.045, // 弹性系数,越小越飘 damp: 0.86, // 阻尼,越大越容易过冲 spread: 0.9 // 初始散布范围,相对画布 }; function resize() { // 设备像素比上限锁 2:3 倍屏像素数是 9 倍,填充率扛不住 dpr = Math.min(window.devicePixelRatio || 1, 2); const rect = canvas.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后所有绘制都按 CSS 像素算 build(); } function build() { const scale = Math.min(W, H) / 40; particles = Array.from({ length: CONF.count }, (_, i) => { const t = (i / CONF.count) * Math.PI * 2; const p = heartPoint(t); return { tx: W / 2 + p.x * scale, ty: H / 2 + p.y * scale, x: W / 2 + (Math.random() - 0.5) * W * CONF.spread, y: H / 2 + (Math.random() - 0.5) * H * CONF.spread, vx: 0, vy: 0 }; }); } function frame() { ctx.clearRect(0, 0, W, H); ctx.fillStyle = 'rgba(255, 120, 150, 0.9)'; for (let i = 0; i < particles.length; i++) { const p = particles[i]; p.vx = (p.vx + (p.tx - p.x) * CONF.ease) * CONF.damp; p.vy = (p.vy + (p.ty - p.y) * CONF.ease) * CONF.damp; p.x += p.vx; p.y += p.vy; ctx.beginPath(); ctx.arc(p.x, p.y, CONF.radius, 0, Math.PI * 2); ctx.fill(); } rafId = requestAnimationFrame(frame); }

逻辑说明:p.vx那一行是整段的核心。(p.tx - p.x) * ease是朝向目标方向的加速度,* damp是每帧削减速度。如果去掉阻尼,粒子会冲到目标点之后再反弹,来回振荡;如果把damp设得太小(比如 0.6),粒子飞过去就停住,像被吸尘器吸进去,没有“聚拢”的呼吸感。

参数怎么调,我给一张对照表:

参数常用值调大之后调小之后
count移动 260 / 桌面 620更细腻,性能压力线性上升轮廓断裂,像撒了一把豆子
ease0.045聚拢更快,但显得急躁飘得太久,用户等不及
damp0.86速度衰减慢,容易过冲振荡移动发涩,像掉帧
radius0.8单点变粗,适合大屏小屏上会看不清
dpr上限2高分屏更锐利,帧率下降边缘发虚

还有一段别忘了:页面切到后台时requestAnimationFrame会被浏览器降频甚至暂停,但切回来的时候如果状态没处理,可能出现两帧叠加导致的跳变。加一个可见性监听,把动画彻底停掉再重启:

document.addEventListener('visibilitychange', () => { if (document.hidden) { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } else if (!rafId) { rafId = requestAnimationFrame(frame); } });

这段代码看起来只是省电,其实还解决了一个真实问题:用户在聊天窗口和页面之间来回切的时候,如果不取消,某些机型会把积压的回调一次性执行,粒子瞬间“闪现”到终点。这是血泪经验,第一次遇到会以为是缓动参数写错了。

3.3 打字机效果的字符切分与可中断设计

文案逐字浮现看着简单,坑在两个地方:字符怎么切,以及能不能中途打断。

// js/typewriter.js function createTypewriter(el, { speed = 90 } = {}) { let timer = null; let running = false; function stop() { running = false; if (timer) { clearInterval(timer); timer = null; } } function play(text) { stop(); running = true; // 关键:用 Array.from 按码点切分 // text.split('') 会把 emoji 这类代理对劈成两个无效字符 const chars = Array.from(text); let i = 0; el.textContent = ''; el.classList.add('is-typing'); return new Promise((resolve) => { timer = setInterval(() => { if (!running) return; el.textContent += chars[i++]; if (i >= chars.length) { stop(); el.classList.remove('is-typing'); resolve(); } }, speed); }); } return { play, stop }; }

逻辑说明:Array.from和split('')的区别在这类页面上特别致命。女朋友的名字里如果有生僻字,或者文案里想放一个爱心表情,split('')会把它拆成两个孤立的码元,打出来就是两个方块。Array.from按码点拆分,一个字符就是一个字符。

stop()在处理函数的第一行就被调用,是为了让连续多段文案能平滑衔接——上一段还没打完就切场景,不取消旧的定时器,两段文字会同时往同一个textContent上追加,出来是乱序的。

speed单位是毫秒每字,90 是我的默认值,配合一般句子长度大约两到三秒打完。想让标点处停顿一下,把setInterval换成递归setTimeout,遇到,。!?时把延迟乘 3,这一改观感立刻不一样,但代码会多十几行,看你愿不愿意花这个成本。

CSS 上还有一个小细节,打字过程中光标闪烁:

.typing-cursor::after { content: ''; display: inline-block; width: 2px; height: 1em; margin-left: 4px; background: currentColor; animation: blink 1s steps(2, start) infinite; vertical-align: -0.15em; } @keyframes blink { to { visibility: hidden; } }

steps(2, start)让它硬切换而不是渐隐,看起来才像光标。vertical-align那个负值是为了让光标和中文基线对齐,不加的话会明显偏上。


4. 交互与音频:绕开自动播放限制的三个落点

这一章讲三件事:怎么让背景音乐响起来、怎么让“不”按钮有趣又不失控、照片怎么加载才不拖垮首屏。三件的共同点是——本地测试全过,真机上全挂。

4.1 首屏“点击开启”的手势链路

背景音乐能不能响,取决于play()是不是在用户手势的调用栈里被调用。这不是新规则,但每年都有人栽。

const audio = document.getElementById('bgm'); const startBtn = document.getElementById('start-btn'); const hint = document.querySelector('.sound-hint'); startBtn.addEventListener('click', async () => { try { await audio.play(); // 必须在 click 回调里同步触发 audio.loop = true; audio.volume = 0.6; // 0.6 足够,再大盖过环境音 } catch (err) { // 不要 throw,静默降级:显示一个手动的音乐开关 console.warn('[bgm] 播放被拒绝:', err.name); hint.hidden = false; } goTo(1); });

逻辑说明:play()返回 Promise,移动端浏览器在判定不满足手势条件时会 reject 而不是抛异常。所以捕获之后不能什么都不做,得给用户一个补救入口——那个hint就是一个小喇叭图标,点一下重新尝试播放。没有这个兜底,某些省电模式下用户就是听不到声音,还以为你没做。

标签上还有两个属性值得写:

<audio id="bgm" preload="none" loop playsinline> <source src="assets/music/bgm.m4a" type="audio/mp4"> <source src="assets/music/bgm.mp3" type="audio/mpeg"> </audio>

preload="none"保证首屏不拉音频文件,几兆的音频拖慢首屏非常不划算。两个<source>是格式保底,浏览器会挑第一个能解的。playsinline在部分移动浏览器上能防止音频触发全屏接管。

还有一个真机上才暴露的问题:用户锁屏或者切到别的应用,音频会被系统挂起,切回来之后不会自动续播。加一段恢复逻辑:

document.addEventListener('visibilitychange', () => { if (!document.hidden && audio.paused) { audio.play().catch(() => { /* 这里被拒绝是正常的,不处理 */ }); } });

注意这里必须catch,因为切回来的时候不一定还在手势上下文里,失败是预期行为,不 catch 会在控制台留一堆红色报错,看着闹心。

4.2 “不”按钮的逃跑坐标与边界钳制

“你愿意做我女朋友吗”下面两个按钮,其中那个“不”会跑。这个交互的趣味性全在“跑得掉但抓不着”,一旦它跑出屏幕或者卡在角落,体验立刻变成 bug。

const noBtn = document.getElementById('no-btn'); let dodge = 0; const MAX_DODGE = 6; function escape(e) { e.preventDefault(); // 手机上不拦会出现一次点击两次触发 if (dodge >= MAX_DODGE) return; // 逃跑次数上限,别让它彻底消失 dodge++; const pad = 12; const w = noBtn.offsetWidth; const h = noBtn.offsetHeight; // 用 fixed 定位 + left/top 直接落到视口内的随机位置 const x = pad + Math.random() * Math.max(window.innerWidth - w - pad * 2, 0); const y = pad + Math.random() * Math.max(window.innerHeight - h - pad * 2, 0); noBtn.style.position = 'fixed'; noBtn.style.left = `${x}px`; noBtn.style.top = `${y}px`; noBtn.style.transform = 'none'; // 清掉可能残留的位移 } noBtn.addEventListener('mouseenter', escape); noBtn.addEventListener('touchstart', escape, { passive: false });

逻辑说明:坐标计算的本质是先把按钮尺寸和边距从视口尺寸里减掉,再在剩余区间取随机值。Math.max(..., 0)是防止在极窄的屏幕上减去之后变成负数,Math.random()乘负数会得到负坐标,按钮就飞到左边去了。

两个事件各有讲究。桌面端用mouseenter,鼠标一靠过去就跑;移动端没有 hover,只能用touchstart。这里的{ passive: false }是必须的——现代浏览器默认把触摸事件标成 passive,passive 监听器里的preventDefault()会被忽略,不加这个参数的话,手机上是“先触发一次点击,再跑”,会出现视觉残影。

MAX_DODGE这个上限很容易被忽略,但它其实是保护性的:第六次之后按钮不再跑,用户点得到,交互才有终点。否则你收获的是一段尴尬的沉默。

4.3 照片墙与懒加载的取舍

照片是这类页面体积的大头。手机直出的一张照片三五兆很正常,六张就是二十兆往上,首屏加载可以直接判死刑。压缩目标我一般定在长边 1600、质量 78 左右,单张落到 200~300KB,肉眼在手机上看不出差别。

<img src="assets/img/p01.jpg" alt="" width="900" height="1200" loading="lazy" decoding="async">

三个属性的作用分别是:width/height给浏览器一个占位比例,避免图片加载完成后把下方内容顶下去(这就是布局偏移);loading="lazy"让不在视口内的图片延后加载;decoding="async"让解码不阻塞渲染。

但loading="lazy"在这里有个陷阱:如果照片全在同一屏里横向排布,浏览器判定它们都在视口内,lazy 等于没开。真正能省的是那些在后面的场景里的图,而那些图本来就还没显示,效果有限。所以更实际的做法是手动控制加载时机——等用户点完“开启”再开始预载,具体写法放在最后一章。

格式上,如果目标设备比较新,可以出 WebP,同画质下比 JPEG 小 25% 左右。但别只放 WebP,用<picture>做降级:

<picture> <source srcset="assets/img/p01.webp" type="image/webp"> <img src="assets/img/p01.jpg" alt="" width="900" height="1200" decoding="async"> </picture>

多这一层标签,换来的是老设备上不会出现空白图。这个成本我认为值得付。


5. 上线避坑:五条真机翻车记录与逐条排查路径

下面这五条,全都不是本地能复现的。共同特征是:你在自己电脑上无论如何都跑得好好的,发给对方,对方回一句“打不开”。每一条我都按现象、原因、解决三段写。

5.1 本机双击能开,分享出去一片白

现象:index.html双击打开一切正常,压缩成包发给对方,或者传到托管上,打开是纯白页,没有任何报错提示。

原因:绝大多数是路径问题,细分三种。一是大小写不一致,本地文件系统不敏感,上线就 404。二是用了绝对路径/css/main.css,而页面部署在子目录下,实际应该在/love/css/main.css。三是引用了本地存在但没一起打包的文件,比如字体、某张图。

解决:把所有引用改成以./开头的相对路径,然后不要用双击验证,在项目根目录起一个本地服务器复核:

# 在项目根目录执行 python3 -m http.server 8000 # 然后浏览器访问 http://localhost:8000

走 HTTP 协议能暴露file://协议掩盖的问题,包括路径、跨域、字体加载。命令里的8000是端口,被占用就换一个。这一步花三十秒,能省掉后面半小时的来回沟通。

5.2 音乐不响,或者响了两秒就断

现象:点了“开启”按钮,页面切过去了,但没有声音。或者手机上一开始有声音,几秒后自动停。

原因:前者是play()不在用户手势的调用栈内被调用——比如你写了个setTimeout(() => audio.play(), 300),看着是点了按钮之后触发,实际上已经脱离了手势上下文,直接被拒。后者是音频元素没有设loop,或者系统在切后台时挂起了播放。

解决:play()必须写在click回调的同步执行路径里;loop属性或audio.loop = true必须设上;再补一段visibilitychange的恢复逻辑。排查的时候看控制台,被拒的那次会有一条NotAllowedError,非常明确。

注意:音频文件必须通过 HTTP 或 HTTPS 打开才可靠,file://协议下部分浏览器会直接把音频源判定为不可用。

5.3 手机上被地址栏“顶”出滚动条

现象:页面上方露出半截内容,或者轻微上下抖动;横屏时更明显,四屏内容错位。

原因:用了100vh,而vh是按大视口算的,地址栏一出现就盖住底部。或者某个元素撑出了 1px,导致出现滚动条,滚动条本身又改变了可视宽度。

解决:换成100dvh并配@supports兜底,父容器加overflow: hidden。检查是否真的溢出,用一行代码就能看出来:

// 在控制台执行,两个值不相等说明有内容溢出 console.log(document.documentElement.scrollHeight, window.innerHeight);

溢出常见于那些用了100% + padding但没设box-sizing: border-box的元素。这类问题在桌面端因为窗口宽裕,一般不显形。

5.4 生僻字、表情显示成方块

现象:文案某个字变成一个空心方块,或者两个方块紧挨着。

原因:两种。一是你用了自定义字体,子集里没有收录这个字,浏览器回退到系统字体时又刚好没有。二是在 JavaScript 里用split('')切字符,把表情这类占两个码元的字符劈成了两半。

解决:字体子集化时把页面里所有文案的字都放进去,同时在font-family末尾留系统字体兜底;字符切分统一改成Array.from。字体声明上补一句:

@font-face { font-family: 'Script'; src: url('./assets/font/subset.woff2') format('woff2'); font-display: swap; }

font-display: swap让字体没加载完时先用系统字体渲染,加载完再换,避免那两三秒的文字不可见。

5.5 一开始就卡,或者电量掉得飞快

现象:中低端安卓机上动画像翻页,一卡一卡的;或者在页面上待十分钟,手机明显发烫。

原因:Canvas 没有限制设备像素比,三倍屏上像素量是九倍;粒子数量没按屏幕尺寸降级;页面不可见时requestAnimationFrame还在跑。

解决:dpr锁 2、移动端把count砍到一半、visibilitychange里彻底取消动画。另外补一条对系统偏好的尊重:

@media (prefers-reduced-motion: reduce) { .scene { transition: none; } .typing-cursor::after { animation: none; } }

这条媒体查询命中时,说明用户在系统里开了“减弱动态效果”,把过渡和循环动画关掉,只留静态内容。它不影响绝大多数人,但对方如果因为晕动症开了这个开关,你的页面会变成一场折磨。


6. 交付前的真机校验:12 项清单与一个首屏提速技巧

到了这一步,页面在你自己电脑上应该是完美状态。但这类页面的交付对象只有一个,对方用的是哪台手机、什么网络、什么浏览器版本,你全都不知道。所以最后这一轮不是“再看一眼”,而是按清单逐条走。

我一般会找两台设备:一台屏幕偏小的旧机型,一台正常尺寸的;网络上一半时间关掉 Wi-Fi 走流量。12 项我固定按这个顺序过:

序号校验项判定标准
1首次打开首屏耗时4G 下 3 秒内出现“开启”按钮
2点击开启后音频有声音,切后台再回来能续播
3四屏切换无白闪、无残影、无叠加
4打字机全文打完,末尾无缺字
5生僻字与表情无方块、无拆分
6粒子动画目测不低于 30 帧,无明显跳动
7照片展示不变形、不留灰块、顺序正确
8“不”按钮六次之后可点,不飞出屏幕
9横竖屏切换布局不塌,按钮不被遮挡
10底部安全区最后一屏按钮不被手势条压住
11无网络重开不出现长时间白屏
12页面静置 10 分钟机身无明显发热

第 12 项最容易被跳过,但它是最能反映性能问题的:发热说明有东西在空转。

至于首屏提速,最有效的一招不是压图片,而是把首屏变成零图片。第一屏只要一个按钮和一句文案,照片和粒子全部延后到用户点击之后再启动。具体做法是用new Image()预载,加载完成后才开始那个场景:

function preloadImages(list) { return Promise.all(list.map((src) => new Promise((resolve) => { const img = new Image(); // onerror 也要 resolve:一张图 404 不能把整串卡死 img.onload = img.onerror = () => resolve(src); img.src = src; }))); } document.addEventListener('scene:change', async (e) => { if (e.detail.index !== 2) return; await preloadImages([ './assets/img/p01.jpg', './assets/img/p02.jpg', './assets/img/p03.jpg' ]); document.body.classList.add('photos-ready'); });

逻辑说明:Promise.all等全部完成才继续,onerror也调resolve是关键——只要有任意一张图挂了却 reject,整个await就会抛异常,后面的展示逻辑永远不执行。把失败也当成“完成”,是资源加载里最常见的一道保底。

这一招的效果很直接:首屏从“HTML + CSS + JS + 六张图”变成“HTML + CSS + 十几 KB 的 JS”,在网络差的情况下差别是好几秒。

从那以后我每次交付这类页面,都会在发出去之前拿一台旧机器、关掉 Wi-Fi 走流量,把上面这张表从头走一遍,哪怕前一晚已经测过三回。因为翻车从来不是发生在功能上,而是发生在“我以为那台设备不会有问题”的地方。

希望帮到你,也祝那句“愿意吗”能等到一个好答案。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询