简介:面向情人节与表白场景的静态网页作品集,包含首页、相册、时光历程与留言等多个页面,适合具备HTML、CSS基础、希望用代码表达心意的开发者参考使用。整套页面以Bootstrap栅格与组件搭建骨架,配合自定义样式表完成粉色系的浪漫配色,并借助jQuery实现鼠标爱心、图片轮播等轻量交互;日期文本与人物照片均可按需替换为自己的内容,便于二次定制。压缩包共43个文件,约1.08MB,以4个html页面、7个css样式、8个js脚本为主,另有5张jpg与4张png配图、1个gif动效及图标字体等资源,覆盖页面结构、视觉样式与交互逻辑三部分,结构紧凑、依赖清晰。目前已有9240人学习下载,可直接运行查看效果,也可作为前端入门者练习布局、动画与响应式适配的完整小项目,从中体会情感化设计的实现思路。
1. 从一张贺卡到一套可交付网页:情人节表白页真正要解决的问题
情人节前一周,A同学想做一个网页送给女朋友,需求听起来很简单:好看、能打开、能放照片。真正动手才会发现难的地方在于「在对方的手机上能不能稳定、体面地跑起来」——她可能拿着一台用了三四年的安卓机,地铁里网速只有几百 K,微信内置浏览器还会把自动播放和部分动画行为吞掉。于是这份表白女朋友的网页方案,最后落在体积预算、动画降级和素材压缩这些工程问题上,而不是堆多少特效。这篇内容面向两类人:学生拿它当一次完整的前端课程设计或毕设前端部分,从业者拿它当一份可以照着搭、照着改的交付方案。整套页面的目标很克制——打开快、画面稳、内容可改、出错有退路。
2. 需求拆解与技术选型:为什么这类页面不该上框架
先摆正一件事:这类页面的访问量大概率只有个位数到几十次,生命周期是几周到一个情人节,使用者是手机端单一用户。拿 React 或 Vue 搭一套 SPA 不是不行,但构建产物、路由懒加载、首屏 hydration 的收益在这个场景里几乎为零,反而把「改一句文案要重新 build」的成本引进来。我的判断是:能用一个 HTML 文件加几个静态资源跑通的事,不要引入工具链。下面把场景、选型和数据结构三件事拆开说。
2.1 使用场景与目标人群拆解
把使用场景拆细,需求自然就出来了。第一,打开入口几乎一定是聊天软件里点链接,内置浏览器的内核版本参差不齐,对backdrop-filter、aspect-ratio这些属性的支持差异明显;第二,观看姿势是竖屏手持,单指滑动,视线停留时间短,一屏内必须给到情绪点;第三,网络环境不可控,4G 弱信号下首屏如果超过三秒,页面被关掉的概率非常高;第四,内容是要改的,日期、照片、每一段话都会反复调整,甚至上线后还会临时加一句。
由此推出一组硬约束:首屏资源(HTML + 关键 CSS + 首图)控制在 500KB 以内;所有动画在低端机上必须能一键降级;文案与素材必须与结构代码分离,改内容不改样式和逻辑;页面在没有 JS 的极端情况下至少能读出主要内容。这几条约束会成为后面每一章判断「这个方案行不行」的尺子,也是课程设计答辩时最能讲清楚的一段设计依据。
2.2 技术选型:原生三件套打底,动画只挑必要的
结构用 HTML 语义标签,样式用原生 CSS(自定义属性 + Grid/Flex),交互用原生 ES Module,不打包、不转译,浏览器直接跑。动画优先用 CSS 的transition和transform,只有两类东西交给 Canvas 或 JS:一是需要逐帧计算的粒子背景,二是需要按字符节奏输出的打字机文本。所有滚动触发的显隐统一交给IntersectionObserver,不用 scroll 事件监听——这是这类页面最容易翻车的地方之一,后面第 5 章会专门讲。
| 方案 | 首屏体积 | 改文案成本 | 低端机表现 | 本项目是否采用 |
|---|---|---|---|---|
| 原生 HTML/CSS/JS | 约 80–150KB | 改 JSON 即可 | 好,可控 | 采用 |
| Vue/React + 构建 | 150KB 起(gzip 后) | 需重新构建 | 一般 | 不采用 |
| 纯 CSS 动画库全套引入 | 200KB+ | 需读文档 | 差,动画排队 | 不采用 |
| 可视化建站工具导出 | 视工具而定 | 依赖平台 | 不可控 | 不采用 |
选型确定后,动手前先立一个体积预算表,把「不能超」写死在文档里:HTML+CSS 不超过 40KB,JS 不超过 30KB,首屏图片不超过 300KB,字体不引入中文字体文件(用系统字体栈),背景音乐压到 128kbps 以内并且默认不预加载。预算一旦定下,后面每加一个特效都要问一句「它值不值这几十 KB 和这几帧」。
注意:中文字体文件动辄几 MB,是这类页面最常见的第一大体积来源。标题的书法感用 SVG 描边或者干脆用图片解决,正文一律走系统字体栈。
2.3 内容配置化:把文案、日期、照片从代码里抽出来
这一步是整个方案里性价比最高的设计。所有可变内容收进一个data.js,导出一个普通对象;渲染层只负责把对象映射成 DOM。好处很直接:改文案不碰样式,换照片不改结构,未来想接一个后台或者做个简单的表单生成器,替换数据源就行,渲染逻辑一行不用动。
// data.js —— 全站唯一的内容源,改东西只动这个文件 export const PAGE_DATA = { meta: { title: "写给你的第 520 天", startDate: "2024-09-01", // 用于自动计算「在一起多少天」 bgm: "assets/bgm.mp3", // 建议 128kbps 以内,且不预加载 }, hero: { line1: "从那天起,", line2: "我的日历上多了一个纪念日", avatar: "assets/hero.webp", }, timeline: [ { date: "2024-09-01", title: "第一次见面", desc: "你穿了件白色的外套", img: "assets/t01.webp" }, { date: "2024-12-24", title: "第一次一起过节", desc: "那家店的红茶拿铁你喝了两杯", img: "assets/t02.webp" }, ], letter: [ "有些话当面说不出口,就写在这里。", "认识你的这些日子,我学会了一件很笨的事:把想说的话当天说完。", ], gallery: [ { src: "assets/g01.webp", alt: "海边的傍晚", ratio: "3/4" }, { src: "assets/g02.webp", alt: "你笑的时候", ratio: "1/1" }, ], };这份数据结构分三层:meta是页面级配置(标题、起始日期、音频路径),hero和letter是纯文案,timeline和gallery是带媒体的列表。把列表单独抽出来,渲染时用map生成节点,新增一条记录只是多写一个对象,不需要动 HTML。
参数上有两个细节值得留意。startDate用YYYY-MM-DD字符串存而不是时间戳,是为了可读性和可维护性,计算天数时在渲染层做一次new Date()转换;ratio字段是给相册用的宽高比,它会写进 CSS 的aspect-ratio属性,让图片在下载完成之前就把位置占住,避免加载过程中页面剧烈跳动。这两个设计看起来琐碎,实际是「页面稳不稳」的分水岭。
3. 总体结构设计:六个模块与一条纵向滚动动线
结构设计要回答的是「用户从打开到关掉,会依次看到什么」。这套方案做成单页纵向滚动,共六个内容段,每段承担一个明确的情绪任务,段与段之间用统一的进场动画串起来。不做多页路由,因为任何一次页面跳转都意味着一次白屏等待,而这类页面最怕的就是等待。下面从动线、目录和滚动驱动机制三块展开。
3.1 单页动线:从开场到结尾的六段节奏
六段分别是:首屏(标题与照片,制造第一印象)、天数计数器(把抽象的时间变成具体数字,情绪第一次抬升)、时间轴(用四到六条记录讲清两个人的过程)、相册(视觉密度最高的部分,停留时间也最长)、情书正文(打字机逐字输出,节奏放慢)、结尾(一句话加一个按钮,情绪收口)。这个顺序不是随便排的,它遵循「快—慢—快—慢—停」的节奏原则:首屏要在一秒内给到画面,天数计数紧跟其后制造小惊喜,时间轴和相册是两个信息密度高峰,情书把速度降下来,最后停在一句短句上。
模块职责与实现方式对应关系如下表,这张表在课程设计文档里可以直接当「系统功能模块图」的说明文字使用。
| 模块 | 情绪任务 | 主要技术手段 | 依赖资源 |
|---|---|---|---|
| 首屏 Hero | 一秒抓住注意力 | CSS 关键帧 + 首图预加载 | 1 张主图 |
| 天数计数器 | 制造具体感 | 日期计算 + 数字滚动 | 无 |
| 时间轴 | 讲清过程 | Grid 布局 + IntersectionObserver | 4–6 张小图 |
| 相册 | 视觉密度高峰 | loading="lazy"+aspect-ratio占位 | 6–12 张图 |
| 情书 | 放慢节奏 | 打字机逐字输出 | 纯文本 |
| 结尾 | 收口 | 静态排版 + 一个小按钮 | 无 |
3.2 目录结构与文件组织
目录结构按职责分层,不按文件类型乱堆。assets下再按用途分img、audio、icon三个子目录,命名统一用「用途-序号」的形式,方便在data.js里引用,也方便交付时别人一眼看懂哪些文件可以删。
love-page/ ├── index.html 页面骨架,只有结构不含内容 ├── data.js 全部文案与素材路径(内容源) ├── styles/ │ ├── base.css 变量、字体栈、重置样式 │ ├── layout.css 栅格、间距、响应式断点 │ └── motion.css 全部动画与过渡,可整份注释掉做降级 ├── scripts/ │ ├── main.js 入口,按顺序初始化各模块 │ ├── render.js 把 PAGE_DATA 渲染成 DOM │ ├── reveal.js 滚动进场统一调度 │ ├── typewriter.js 打字机 │ └── hearts.js Canvas 粒子背景 └── assets/ ├── img/ 全部 WebP,单张不超过 120KB ├── audio/bgm.mp3 128kbps 以内 └── icon/ 播放/静音按钮等小图标这个结构的核心是把「内容 / 样式 / 行为 / 素材」四件事彻底分开。motion.css单独成文件是有意为之:低端机降级时,只需要在入口判断设备能力,然后给<html>加一个no-motion类,CSS 里一条规则就能把全部动画关掉,不需要改 JS。render.js单独成文件,意味着渲染逻辑可以复用到第二个、第三个类似页面上,这也是为什么这套方案能被当成模板反复改。
提示:交付给别人时,把
data.js顶部的注释写清楚每个字段的含义和图片建议尺寸,比写一份 README 更有效,因为改内容的人一定会先打开这个文件。
3.3 滚动驱动:用 IntersectionObserver 当整页的节拍器
整页所有「滚动到位才出现」的效果,共用同一个观察器实例,而不是每个模块各建一个。共用实例的好处是回调集中、便于统一控制「只播一次」还是「每次进视口都播」,也避免了十几个观察器同时工作带来的额外开销。
// reveal.js —— 全站共用的进场调度器 const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; entry.target.classList.add("is-in"); // 具体动画交给 CSS if (entry.target.dataset.once !== "false") { io.unobserve(entry.target); // 默认只播一次,回滚不重播 } } }, { root: null, // 以视口为参照,整页滚动时不需要改 rootMargin: "0px 0px -12% 0px", // 元素露出 12% 再触发,避免刚露头就闪 threshold: 0.15, // 与 rootMargin 配合,兼顾长元素和短元素 } ); export function observeAll(selector = "[data-reveal]") { document.querySelectorAll(selector).forEach((el) => io.observe(el)); }rootMargin底部取负值是关键参数:它让触发线比视口底边高出一截,元素「快要进入」而不是「已经进入」时开始动画,视觉上更自然;如果设成0,元素刚露出一个像素就开始动,用户几乎看不到动画过程。threshold: 0.15保证高度较大的元素(比如整段相册)不会因为始终达不到 100% 可见而永远不触发。
与之配套的 CSS 只负责一件事:把位移和透明度做成过渡,is-in类出现时归位。位移量控制在 24px 以内,时长 600ms 左右,缓动用cubic-bezier(.22,.61,.36,1),这个曲线起步快、收尾慢,读起来像「东西自己停下来」而不是被弹出去。超过 800ms 的过渡在手机端会让人产生卡顿的错觉,这一点在答辩演示时尤其明显。
4. 关键模块实现:首屏动画、时间轴、相册与粒子背景的落地细节
这一章是整份方案里最需要「照着改」的部分。每个模块给出可直接运行的代码片段,并在其后说明参数怎么调、什么条件下该关掉、失败时看哪里。顺序按用户实际看到的先后排列,也正好是从轻到重:先讲纯 CSS 能解决的首屏,再讲需要 JS 的打字机和相册,最后讲开销最大的 Canvas 粒子。
4.1 首屏:加载完成后的一次性入场动画
首屏最忌讳的是「动画跑完了图还没来」,屏幕上是半张白底加一个标题,非常尴尬。做法是把入场动画的启动条件绑在首图的load事件上,而不是DOMContentLoaded。首图加载完成再让整组元素依次升起,用户看到的第一帧就是完整画面。
/* motion.css —— 首屏入场,只依赖 transform/opacity,走合成层不触发重排 */ .hero [data-reveal] { opacity: 0; transform: translate3d(0, 28px, 0); transition: opacity 0.7s var(--ease-out), transform 0.7s var(--ease-out); transition-delay: calc(var(--i, 0) * 90ms); /* 用序号错开,形成依次升起 */ } .hero [data-reveal].is-in { opacity: 1; transform: translate3d(0, 0, 0); } /* 降级开关:给 html 加上 no-motion 即可全站关闭动效 */ .no-motion [data-reveal] { opacity: 1 !important; transform: none !important; transition: none !important; } .hero { min-height: 100vh; min-height: 100svh; /* 新版内核用 svh,避开地址栏高度计算问题 */ }--i这个自定义属性由 HTML 上的内联样式给出(style="--i:1"),比在 JS 里一个个算延时更干净,元素增删都不用改逻辑。translate3d而不是translateY是为了明确走合成层,避免某些内核对二维 transform 的优化不足。100svh这一行是给移动端准备的兜底:vh在移动浏览器里按「地址栏收起时」的高度计算,会导致首屏内容被顶掉一截,这是第 5 章要展开的翻车点之一。
4.2 打字机情书:把「慢」做成体验
情书段落用逐字输出,节奏是这段的全部意义。实现上有两个细节不能省:用Array.from拆字符,保证 emoji 和生僻字不被劈成两半;遇到标点额外停顿,模拟人写字时的犹豫。
// typewriter.js —— 逐字输出,标点处多停一拍 export function typewriter(el, text, { speed = 85, pause = 340, onDone } = {}) { const chars = Array.from(text); // 按码点拆分,避免 emoji 被截断 let i = 0; el.textContent = ""; const tick = () => { if (i >= chars.length) { onDone?.(); return; } const ch = chars[i++]; el.textContent += ch; // 标点后长停,逗号短停,其余按基础速度 const extra = /[。!?…]/.test(ch) ? pause : /[,、,]/.test(ch) ? pause * 0.5 : 0; setTimeout(tick, speed + extra); }; tick(); }speed建议 70–100ms,低于 60 会显得急促,高于 130 会让用户等得不耐烦;pause取 300–400ms,是标点停顿的基准值。这段文本总字数要控制,中文 120 字左右对应 12–15 秒,刚好是一屏停下来读完的时长,超过 200 字绝大多数人会直接下滑跳过。
必须给「跳过」留退路。判断方式很简单:一旦IntersectionObserver检测到用户已经滑出这一屏,就调用onDone直接把整段文字填满,避免用户回头时看到一段永远没写完的字。这是个小细节,但在实际使用中出现的频率很高。
注意:
setTimeout递归在页面切到后台时会被浏览器节流,回来时可能出现文字一下子全冒出来。如果不希望这样,可以在visibilitychange里暂停计时,但大多数送礼场景没必要做到这个程度。
4.3 时间轴:滚动到位才亮起的节点
时间轴用 Grid 做,桌面端左右交替,移动端统一靠左带一条竖线。关键不在布局而在「节点亮起」的时机——每个节点自己观察自己,进入视口时给竖线上的圆点加高亮,同时把卡片升起。
<!-- index.html 中时间轴的单个节点,由 render.js 按 data.timeline 生成 --> <li class="tl-item"><figure class="shot" style="--ratio: 3/4"> <img src="assets/g01.webp" alt="海边的傍晚" width="900" height="1200" loading="lazy" decoding="async"> </figure>.shot { aspect-ratio: var(--ratio); /* 下载完成前就把位置占住,避免页面跳动 */ overflow: hidden; border-radius: var(--radius); background: var(--bubble); /* 占位底色,比白块好看 */ } .shot img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.6s var(--ease-out); } .shot:hover img { transform: scale(1.03); }loading="lazy"让首屏之外的图片延迟加载,decoding="async"让解码不阻塞主线程。占位底色用一点点粉白而不是纯白,加载时的观感差别很大。object-fit: cover保证不同尺寸的图在同一套栅格里不会被拉变形,代价是会裁掉一部分边缘,所以导出时主体物尽量居中。
栅格建议用 CSS Grid 的grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)),再让个别图片跨两列做出节奏变化。这种不规则排布在手机上比整齐的两列更有编辑感,也不会因为某个断点没算好而出现半张图的空白。
4.5 Canvas 粒子背景:数量、DPR 与降级开关
粒子背景是整页最容易被低估的性能黑洞。台式机上跑 200 个粒子毫无压力,几年前的低端安卓机上 60 个就掉帧。实现时把三件事做对:按屏幕宽度决定粒子数量、DPR 上限锁在 2、用参数方程画心形并按需重绘。
// hearts.js —— 飘落心形粒子,带 DPR 适配与数量降级 const canvas = document.querySelector("#bg-hearts"); const ctx = canvas.getContext("2d", { alpha: true }); let W = 0, H = 0; const isSmall = window.matchMedia("(max-width: 768px)").matches; const COUNT = isSmall ? 16 : 32; // 小屏直接减半 function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); // 超过 2 收益极低 W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.floor(W * dpr); canvas.height = Math.floor(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素画 } // 心形参数方程,size 控制大小,rot 控制轻微旋转 function drawHeart(cx, cy, size, rot, alpha) { const s = size / 32; ctx.beginPath(); for (let t = 0; t <= Math.PI * 2; t += 0.12) { const x = 16 * Math.sin(t) ** 3; const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); const px = cx + (x * Math.cos(rot) - y * Math.sin(rot)) * s; const py = cy + (x * Math.sin(rot) + y * Math.cos(rot)) * s; t === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); } ctx.closePath(); ctx.globalAlpha = alpha; ctx.fill(); } const parts = Array.from({ length: COUNT }, () => ({ x: Math.random() * 1, // 用 0~1 的比例存位置,resize 时无需换算 y: Math.random() * 1, size: 8 + Math.random() * 14, vy: 0.0006 + Math.random() * 0.0012, rot: Math.random() * Math.PI * 2, alpha: 0.18 + Math.random() * 0.3, })); let raf = 0; function loop() { ctx.clearRect(0, 0, W, H); ctx.fillStyle = "rgba(232, 84, 111, 0.75)"; for (const p of parts) { p.y += p.vy; if (p.y > 1.1) { p.y = -0.1; p.x = Math.random(); } // 出屏后从顶部重新出现 drawHeart(p.x * W, p.y * H, p.size, p.rot, p.alpha); } raf = requestAnimationFrame(loop); } export function initHearts() { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // 尊重系统设置 resize(); window.addEventListener("resize", resize, { passive: true }); loop(); // 页面不可见时停掉,省电也省得切回来时时间步长乱掉 document.addEventListener("visibilitychange", () => { if (document.hidden) cancelAnimationFrame(raf); else loop(); }); }粒子位置用 0–1 的归一化比例存储,而不是绝对像素,这样窗口尺寸变化时不需要重新分配,也不会出现粒子全部挤在左上角的情况。COUNT在移动端减半是最划算的一刀,视觉上看不出明显差别,帧率却能稳住。prefers-reduced-motion那一行是给系统开了「减少动态效果」的用户准备的,这类人里有一部分是对闪烁敏感的,直接不渲染是负责的做法。
降级开关放在入口统一判断:用navigator.hardwareConcurrency和屏幕尺寸做一个粗判,低于阈值就给<html>加no-motion类并跳过initHearts()。这个判断不需要多准确,宁可少开一点特效,也不要让人看到一张卡成幻灯片的页面。
4.6 背景音乐:手势触发与静音开关
背景音乐是氛围的一半,也是最容易被浏览器拦下的部分。所有现代浏览器都禁止无交互自动播放,play()返回的 Promise 会被 reject。正确做法是监听第一次用户手势,同时提供一个显式的静音按钮。
// audio.js —— 首次交互后播放,失败不影响页面其他功能 const bgm = document.querySelector("#bgm"); let started = false; function tryPlay() { if (started) return; bgm.volume = 0.35; // 0.3~0.4 之间钢琴和人声都不刺耳 bgm.play() .then(() => { started = true; }) .catch(() => { /* 静默失败,等下一次手势再试 */ }); } ["pointerdown", "touchstart", "keydown"].forEach((evt) => window.addEventListener(evt, tryPlay, { passive: true }) );volume定在 0.35 左右是经验值:调到 1 在手机外放上会破音,调到 0.1 又听不见。监听的事件不要加once: true,因为第一次play()有可能仍然失败(比如某些内核在touchstart阶段还不算有效手势),保留监听器让它在后续手势里继续尝试,成本几乎为零。
音乐文件务必用自己确认过授权的素材,或者干脆用免版权音乐的短片段,不要从任意来源直接取用。另外音频元素加上preload="none",避免它抢占首屏带宽,这是很多人忽略的一笔流量。
5. 避坑与排查:移动端适配、性能与素材的五个高频翻车点
这一章列的是我在类似页面上真实踩过的坑,每一条都按「现象 → 原因 → 解决」写。它们有个共同特点:在开发机的桌面浏览器里完全看不出来,只有换到别人的手机上才会暴露。答辩或者交付之前,这五条应该逐条过一遍。
5.1 首屏内容被地址栏顶掉一截
现象:在移动浏览器里打开,首屏底部的按钮被地址栏遮住,往下滑一点才对得上;换到另一台手机上又好了。
原因:用了min-height: 100vh。移动浏览器的vh是按「地址栏收起时」的视口高度计算的,地址栏展开时实际可视高度小于这个值,于是内容溢出。不同浏览器的地址栏行为不一致,所以表现忽好忽坏,属于典型的玄学问题。
解决:改成min-height: 100svh,并在上一行保留100vh作为老内核的兜底(后写的声明会覆盖前面的,不支持的浏览器自动忽略)。如果整块区域必须是纯装饰性的背景,用position: fixed; inset: 0也可以规避,但要注意它不会随内容滚动。
5.2 背景音乐一点声音都没有
现象:明明写了audio.play(),对方打开就是静音,控制台也没有明显报错,或者只有一个被折叠的警告。
原因:自动播放策略。没有用户手势的情况下,带声音的媒体播放会被直接拒绝,play()返回一个 rejected 的 Promise,如果没写catch,这个错误在控制台里很容易被其他日志淹没。
解决:按 4.6 的写法,把播放挂到第一次pointerdown/touchstart上,并且必须写.catch(),同时在页面固定位置放一个可见的播放/暂停按钮作为兜底。测试时必须用真实手机在真实浏览器里点一次,模拟器里的手势行为不可信。
5.3 滚动时页面一顿一顿的
现象:往下滑的时候明显掉帧,尤其是有粒子背景的那一屏;桌面浏览器开着开发者工具时反而看不出来。
原因:三类。第一,过渡动画用了top、left、width、height这类会触发重排的属性;第二,Canvas 每帧都在按设备像素比全量重绘,低端机带不动;第三,scroll事件里直接读getBoundingClientRect()之类的布局属性,触发强制同步布局。
解决:动画只用transform和opacity;Canvas 按 4.5 的做法锁 DPR、减数量、页面不可见时cancelAnimationFrame;滚动相关一律换成IntersectionObserver,不要在scroll回调里做任何测量。改完用手机实测,不要只看桌面表现。
5.4 打开就是一片白,等好几秒才出画面
现象:点开链接,屏幕空白两三秒,然后整页突然出现。
原因:首图是几百 KB 甚至上 MB 的 JPG,且没有预加载;CSS 阻塞渲染;或者引入了体积很大的字体文件。这三者任意一个都能让首屏延迟超过两秒。
解决:首图压到 WebP 且控制在 300KB 以内,并用<link rel="preload" as="image">把它提到最早加载;正文不引入中文字体文件,标题特殊字体用 SVG 或图片;motion.css用media属性或者干脆内联关键部分,减少一个阻塞请求。用浏览器的网络面板模拟「慢速 3G」测一遍,这个视角下的表现才是对方真正看到的。
5.5 照片被裁得只剩半张脸
现象:相册里某几张图主体被切掉了,或者整张图被拉得变形。
原因:栅格用了固定宽高,图片又被object-fit: cover裁切,构图偏上的照片就会丢掉头部;另一种情况是导出时图片本身就变形了。
解决:导出前统一按目标比例裁一次(相册建议 3:4 或 1:1),不要指望 CSS 裁;object-position可以设成center 30%这类值把焦点上移;alt文字里写清画面主体,万一张力不够也还有文字兜底。这个坑最容易被忽略,因为开发者本地放的往往是风景图,换成人物照片就会非常明显。
6. 验收与演示:怎么确认这套网页在别人的手机上也不崩
验收这一步,最靠不住的做法就是「在我自己的电脑上看挺好看的」。真正要验的是三个外部条件:别人的设备、别人的网络、别人的浏览器。我的习惯是找三台设备——一台老旧安卓、一台当前主流机型、一台平板或桌面浏览器,再配一次网络限速,按下面这张表逐项过。
| 检查项 | 判定标准 | 检查方式 |
|---|---|---|
| 首屏可见时间 | 慢速 3G 下 3 秒内出现完整首屏 | 网络面板限速 + 手机计时 |
| 首屏资源体积 | HTML+CSS+JS+首图 < 500KB | 网络面板总量 |
| 无横向滚动 | 任意宽度下scrollWidth <= clientWidth | 控制台执行检查 |
| 动画降级 | 加no-motion类后仍有完整内容 | 手动改 DOM 验证 |
| 音乐可控 | 有可见静音按钮,点击后能停 | 真机点击 |
| 图片不跳版 | 滚动过程中无大幅位移 | 目视 + Performance 面板 |
| 竖屏 320px 可读 | 无文字贴边、无单字换行 | 响应式模式调至 320px |
其中「无横向滚动」这一条可以用一段很短的脚本在控制台里直接跑,比肉眼扫快得多:
// 在手机端或响应式模式下执行,返回所有导致横向溢出的元素 [...document.querySelectorAll("*")] .filter((el) => el.getBoundingClientRect().right > document.documentElement.clientWidth + 1) .forEach((el) => console.warn("溢出:", el));这段代码遍历全部元素,把右边缘超出视口宽度的节点打出来。绝大多数横向滚动都是某个width: 100vw的元素加上滚动条宽度,或者一张图片写死了像素宽度造成的,看一眼就知道改哪里。
演示环节如果用于答辩,我建议讲三段就够:先说约束(弱网、旧机、内置浏览器),再说取舍(不上框架、不上中文字体、Canvas 数量减半),最后现场打开手机投屏滑一遍。评委记住的往往不是用了多少特效,而是「这个人知道自己为什么这么做」。
说一个我自己的教训。早期做这类页面,我总是在交付前一晚才用手机打开看一眼,结果每次都在最后关头发现首屏被顶掉或者音乐不响,然后手忙脚乱改一通。从那以后我每次动手写第一行 CSS 之前,都强制先在真机上打开一个空白页跑一遍 320px 的截图,把设备、网络、浏览器三件事在开工时就摆到台面上。这个习惯让我少熬了很多夜,也让交付出去的东西真的能被对方顺畅地看完。
希望帮到你。
本文还有配套的精品资源,点击获取