
简介一份围绕情人节与表白场景打造的成品网页包面向希望用现成页面向女友表达心意的同学也适合作为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 nameviewport里带上viewport-fitcover才会返回非零值否则永远是 0这一点如果漏了全面屏机型上最后一屏的按钮会被手势条压住。meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover另一个常见做法是用 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() { // 设备像素比上限锁 23 倍屏像素数是 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 idbgm preloadnone loop playsinline source srcassets/music/bgm.m4a typeaudio/mp4 source srcassets/music/bgm.mp3 typeaudio/mpeg /audiopreloadnone保证首屏不拉音频文件几兆的音频拖慢首屏非常不划算。两个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 }是必须的——现代浏览器默认把触摸事件标成 passivepassive 监听器里的preventDefault()会被忽略不加这个参数的话手机上是“先触发一次点击再跑”会出现视觉残影。MAX_DODGE这个上限很容易被忽略但它其实是保护性的第六次之后按钮不再跑用户点得到交互才有终点。否则你收获的是一段尴尬的沉默。4.3 照片墙与懒加载的取舍照片是这类页面体积的大头。手机直出的一张照片三五兆很正常六张就是二十兆往上首屏加载可以直接判死刑。压缩目标我一般定在长边 1600、质量 78 左右单张落到 200300KB肉眼在手机上看不出差别。img srcassets/img/p01.jpg alt width900 height1200 loadinglazy decodingasync三个属性的作用分别是width/height给浏览器一个占位比例避免图片加载完成后把下方内容顶下去这就是布局偏移loadinglazy让不在视口内的图片延后加载decodingasync让解码不阻塞渲染。但loadinglazy在这里有个陷阱如果照片全在同一屏里横向排布浏览器判定它们都在视口内lazy 等于没开。真正能省的是那些在后面的场景里的图而那些图本来就还没显示效果有限。所以更实际的做法是手动控制加载时机——等用户点完“开启”再开始预载具体写法放在最后一章。格式上如果目标设备比较新可以出 WebP同画质下比 JPEG 小 25% 左右。但别只放 WebP用picture做降级picture source srcsetassets/img/p01.webp typeimage/webp img srcassets/img/p01.jpg alt width900 height1200 decodingasync /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 走流量把上面这张表从头走一遍哪怕前一晚已经测过三回。因为翻车从来不是发生在功能上而是发生在“我以为那台设备不会有问题”的地方。希望帮到你也祝那句“愿意吗”能等到一个好答案。本文还有配套的精品资源点击获取