
简介面向情人节与表白场景的精美网页成品适合具备基础HTML、CSS与JavaScript能力、希望快速完成个性化告白页面的开发者与学生。整包共43个文件约1.08MB包含4个HTML页面、7个CSS样式表、8个JavaScript脚本以及JPG、PNG、GIF、SVG等图片素材和字体图标文件页面结构、相册展示、鼠标爱心特效、响应式布局与视觉样式均可按需替换。已有9242人学习说明这类情感主题页面模板具有较高参考价值。读者可直接获得多页展示与相册模块按说明替换成自己的日期和照片调整粉色、红色等浪漫配色与心形元素并借助现成的前端依赖快速搭建、预览与二次修改既节省从零写页面的时间也便于学习页面组织、样式定制和简单交互实现。1. 送人的网页验收标准和做作品是两套做一个送给女朋友的精美网页最难的地方在于三个月后她换了手机、在聊天软件里点开那个链接时页面还能不能正常显示动画还能不能动音乐还能不能响而不是动画怎么写。这类“一次性礼物页”我做过几个也见过几个失败案例交付当天很漂亮一周后图片全部显示不出来背景音乐怎么点都没反应iPhone 上第一屏底部永远露一条白边。所以这里不谈情怀只谈怎么把“精美”做成可以正常交付、能够复现的东西一个零依赖的单页一套用 CSS 变量管理的视觉参数一份上线前的自检清单。会写 HTML/CSS、想让页面拿得出手的人可以照着走完全没摆过 div 的也能跟完只是中间几段原理建议多读一遍。2. 把“回忆”拆成五屏单页结构与时序表怎么定送人的页面和组织信息不是一回事它不需要导航、不需要搜索、不需要“关于我们”它需要的是一条从惊喜到安静的情绪曲线。所以结构第一样式第二。我一般先把内容拍平成五屏再给每一屏定“什么时候动、动多久”最后才动配色。这一步做扎实后面改风格只是换几个变量不用重写。2.1 五段式结构封面、时间轴、照片墙、数据、信五屏分别承担一个情绪任务封面给惊喜名字、日期、一句话时间轴给回忆58 个节点多了就流水账照片墙给视觉冲击数据屏给一个具体的数字在一起多少天最后的信给安静下来的落点音乐按钮也放在这一屏。不建议做成多页路由一条链接发出去、她点开就能一路滑到底这种体验最稳也不用处理任何路由和刷新丢失状态的问题。!DOCTYPE html html langzh-CN head meta charsetUTF-8 !-- viewport-fitcover 是刘海屏能用安全区变量的前提 -- meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover title给你的/title link relstylesheet href./css/main.css /head body canvas idbg aria-hiddentrue/canvas main section classscreen idcover>// js/config.js —— 所有可调的东西集中在这改内容不用翻 CSS // 用 var 挂到 window 上是为了让后面两个脚本都能直接读到且不需要构建 var CONFIG { startDate: 2021-09-12, // 只存年月日不要存带时区的时间串 reveal: { threshold: 0.25, // 元素露出 25% 才触发入场 rootMargin: 0px 0px -10% 0px, // 视口底部提前 10% 就触发滑到底不突兀 stagger: 80 // 同屏元素依次延迟的毫秒数 }, typewriter: { speed: 55, startDelay: 400 }, particles: { count: 60, maxDPR: 2 }, bgmVolume: 0.6 };rootMargin的负值是“提前触发”正值是“延后触发”很多人写反了结果元素滑到屏幕中间才动。threshold对长元素比如整块时间轴不要设太高0.250.4 之间比较稳设 0.9 的话在手机上永远触发不了。2.3 素材预处理图片、字体、音频的体积红线这一屏的观感八成来自素材质量两成来自代码。但手机流量和首屏时间是硬的我给自己的红线是首屏总下载量不超过 1.5MB。素材目标格式关键参数体积目标说明照片墙大图WebP质量 7580长边 1080单张 150KB再大手机上肉眼看不出差别时间轴缩略图WebP长边 480单张 40KB展示尺寸小别直接复用大图中文字体WOFF2只保留页面用到的字 80KB全量中文字体一般 5MB 以上背景音乐MP3/AAC128kbps3060 秒 3MB循环播放不用整首图标内联 SVG—计入 HTML别引图标字体省一次请求字体是最容易被忽略的一项。中文全量字体动辄几 MB而她能看到的字其实只有页面上那几百个。做法是先统计再交给字体子集化工具输出 WOFF2# 把页面里出现的字符去重、拼成一行作为子集工具的输入 # 注意要连 JS 里的文案文件一起统计不然逐字显示的正文会缺字 cat index.html js/*.js | grep -o . | sort -u | tr -d \n ./build/chars.txt wc -c ./build/chars.txt # 几百字节就对了说明字表没被污染统计完把chars.txt喂给任意支持 WOFF2 的子集工具几 MB 会掉到几十 KB。这一步有个坑如果信的正文是写在 JS 里的统计时必须把 JS 也cat进去否则运行到一半字变成方块而且这个现象只在某些设备上出现非常玄学。3. 视觉层不靠堆特效CSS 变量、Canvas 星空和逐字入场“精美”这件事八成的人第一反应是加特效粒子、光晕、玻璃拟态、3D 翻转全堆上。结果就是手机上滑两下就开始掉帧、发烫。我的判断标准很简单回到首页时那一秒能不能看清字滑动的时候手指跟不跟手。做到这两点剩下的预算全给配色和排版效果反而高级。这一章的三块内容——变量化的样式、一块克制的 Canvas 背景、一个不卡的逐字组件——基本就够了。3.1 用 CSS 变量把主题色和节奏参数化不要把颜色写死在十几个选择器里。改一次风格要全文件搜索替换是典型的返工来源。全部提到:root改三行就换一套气质。:root { /* 主题只改这三行整套页面换风格 */ --c-bg: #0b1020; --c-accent: #ff7a9c; --c-text: #e8eaf2; /* 节奏所有动画时长都从这里取方便整体调快调慢 */ --dur-fast: 240ms; --dur-base: 520ms; --dur-slow: 900ms; --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* 间距和字号用 clamp小屏不挤大屏不散 */ --gap: clamp(16px, 4vw, 40px); --fs-title: clamp(24px, 6vw, 40px); /* 安全区刘海屏和底部横条都靠它 */ --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); } /* 入场动画只定义一次靠 class 切换状态 */ [data-reveal] { opacity: 0; transform: translate3d(0, 24px, 0); transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); transition-delay: var(--reveal-delay, 0ms); will-change: opacity, transform; } [data-reveal].is-in { opacity: 1; transform: none; }translate3d而不是translateY是为了让浏览器直接把它丢进合成层动画期间不触发重排。will-change是一剂猛药加上能让动画更顺但一个元素一层几十个元素同时加移动端的显存会被吃光表现就是页面越来越卡。所以它在动画结束后必须撤掉这一点后面第 4 章的观察器里会处理。--reveal-delay用行内样式写CSS 只管读这样错落感由 JS 计算样式表依然干净。3.2 Canvas 星空数量按面积折算像素比封顶 2全屏飘星星是这类页面最常见的背景。写成 CSS 的话几十个元素各跑一条animation手机上必翻车用一块 Canvas 画只要不无限提高分辨率代价很低。// js/bg.js —— 星空背景数量按屏幕面积折算像素比封顶 (function () { const canvas document.getElementById(bg); const ctx canvas.getContext(2d); const CFG window.CONFIG.particles; let stars []; let rafId 0; let running false; function resize() { const dpr Math.min(window.devicePixelRatio || 1, CFG.maxDPR); const w canvas.clientWidth; const h canvas.clientHeight; // 画布物理像素 CSS 像素 × dpr再把坐标系缩放回来 canvas.width Math.round(w * dpr); canvas.height Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 面积折算手机屏幕小星星自然少不用维护两套配置 const count Math.min(CFG.count, Math.round((w * h) / 12000)); stars Array.from({ length: count }, () ({ x: Math.random() * w, y: Math.random() * h, r: Math.random() * 1.6 0.4, // 半径 0.42.0 a: Math.random() * 0.6 0.2, // 透明度 0.20.8 vy: Math.random() * 0.15 0.03 // 慢速上浮 })); } function frame() { const w canvas.clientWidth; const h canvas.clientHeight; ctx.clearRect(0, 0, w, h); ctx.fillStyle #fff; for (const s of stars) { s.y - s.vy; if (s.y -2) { s.y h 2; s.x Math.random() * w; } // 出界回收 ctx.globalAlpha s.a; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha 1; rafId requestAnimationFrame(frame); } function start() { if (!running) { running true; rafId requestAnimationFrame(frame); } } function stop() { running false; cancelAnimationFrame(rafId); } window.addEventListener(resize, resize, { passive: true }); // 切到后台就停帧省电回来再开也不会在后台偷偷跑 document.addEventListener(visibilitychange, () { document.hidden ? stop() : start(); }); resize(); // 系统开了减少动态效果就不启动只留静态背景色 if (!window.matchMedia((prefers-reduced-motion: reduce)).matches) start(); })();/12000这个除数可以直接调除数越大星越稀。桌面 1440×900 大概算出 108 颗但被count: 60压住了手机 390×844 算出 27 颗观感刚好。maxDPR: 2是关键旗舰机devicePixelRatio能到 3全屏 Canvas 按 3 倍渲染意味着 9 倍像素填充中端机直接掉到 30 帧以下。visibilitychange那段也不是可选项不加的话她锁屏之后星星还在后台跑半天下来电量掉得莫名其妙这种问题极其难查。3.3 逐字显示别用一个字一个 setTimeout信的正文用打字机效果最合适但实现方式差别很大。用递归setTimeout每字一个定时器二百个字就是二百个排队任务慢设备上会明显滞后甚至卡顿。用setInterval单循环反而稳因为每次只做一次slice。// 逐字显示系统开启减少动态效果时直接整段出现 function typewriter(el, text, speed, onDone) { if (window.matchMedia((prefers-reduced-motion: reduce)).matches) { el.textContent text; onDone onDone(); return; } let i 0; el.textContent ; const timer setInterval(() { i 1; el.textContent text.slice(0, i); if (i text.length) { clearInterval(timer); onDone onDone(); } }, speed); } // 用法等这一屏进入视口后再开始别在页面加载时就跑完 document.getElementById(letter).addEventListener(reveal, () { typewriter( document.getElementById(letterBody), 写给她的话……, window.CONFIG.typewriter.speed ); }, { once: true });speed: 55是每字 55 毫秒中文阅读速度下感觉是“有人在写”比 30ms 的机械感舒服。字数超过 300 时建议改成requestAnimationFrame 时间戳分片每次渲染按已过时间算该显示几个字这样即使掉帧也不会越来越慢。另外prefers-reduced-motion一定要尊重有人对持续动画敏感这个设置开着的时候把所有位移和逐字全部关掉只保留淡入是基本的体面。4. 交互层三块难点滚动触发、纪念日计算、背景音乐视觉是面子交互是里子。这一章三个东西看起来都不复杂但每一个都有能让页面“看起来坏了”的细节滚动监听写错就卡日期算错就少一天音乐写错就永远不响还没提示。这三个我都翻过下面按能直接抄的程度写。4.1 用 IntersectionObserver 做进场不要监听 scrollscroll事件里读getBoundingClientRect()是经典性能杀手滚动过程中每一帧都强制同步布局手机上直接掉帧。IntersectionObserver是浏览器原生提供的“元素进入视口”通知回调在渲染之后批量执行代价低一个数量级。// js/main.js片段一—— 一个观察器管所有>// 只比较日历日时分秒全部丢弃所以不会出现差一天 function daysSince(startStr, now) { const d now || new Date(); const parts startStr.split(-).map(Number); if (parts.length ! 3 || parts.some(Number.isNaN)) return 0; // Date.UTC 生成的是纯时间戳不受本地时区影响只做日期差 const day0 Date.UTC(parts[0], parts[1] - 1, parts[2]); const day1 Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); // 用 Math.round 兜住夏令时导致的 ±1 小时误差 return Math.max(0, Math.round((day1 - day0) / 86400000)); } // 渲染时做个短滚动从当前值的 80% 滚到目标值比直接跳数字有仪式感 function renderDays() { const el document.getElementById(days); const target daysSince(window.CONFIG.startDate); const from Math.floor(target * 0.8); const dur 900; const t0 performance.now(); (function step(t) { const p Math.min(1, (t - t0) / dur); el.textContent Math.round(from (target - from) * p); if (p 1) requestAnimationFrame(step); })(t0); }startDate存成2021-09-12这种纯日期字符串不要存new Date().toString()的结果——那串东西带时区名不同浏览器解析结果不一样属于黑匣子级的问题。Math.round而不是Math.floor是为了兜住某些地区夏令时切换那天只有 23 小时的情况否则那一天的差值会向下取整少一天。这个数字建议只在数据屏进入视口时算一次别放进requestAnimationFrame循环里每秒算。4.3 背景音乐自动播放会被拦按钮文案要能兜住失败这里有几条明确的规则要遵守。浏览器普遍禁止无用户手势的音频自动播放进入页面就audio.play()会抛出一个NotAllowedError控制台一片红而且按钮状态会错乱。正确做法是把播放权交给用户同时按钮本身做得像页面的一部分不像一个报错恢复控件。// js/main.js片段二—— 音乐开关play() 必须写在 click 回调里 (function () { const btn document.getElementById(musicBtn); const bgm document.getElementById(bgm); if (!btn || !bgm) return; btn.addEventListener(click, async () { if (!bgm.paused) { bgm.pause(); btn.textContent 播放音乐; btn.classList.remove(is-playing); return; } try { bgm.volume window.CONFIG.bgmVolume; // 直接写在手势回调里哪怕前面有 await浏览器仍认作同一次用户激活 await bgm.play(); btn.textContent 暂停音乐; btn.classList.add(is-playing); } catch (err) { // 系统静音、解码失败、格式不支持都会走到这里别让按钮卡死在播放 btn.textContent 再点一次试试; } }); })();volume: 0.6别设 1突然满音量的背景音乐在办公室或地铁里是灾难。catch分支不是摆设iOS 把静音开关拨下来时play()会正常 resolve 但完全没有声音用户点三次都没反应某些安卓机不支持mp3时抛的是NotSupportedError。这两种情况界面表现一样文案上写“再点一次试试”比写“播放失败”体面得多。另外音频文件务必preloadnone她如果不点这 3MB 就不该被下载。5. 避坑排查手机上一打开就翻车的五个现场桌面浏览器和真机之间的差距比想象中大得多。下面五条都是我在交付前后真实遇到过的按“现象 → 原因 → 解决”写遇到同款可以直接对号入座。5.1 本地双击正常发出去对方打开一片白现象在自己电脑上双击index.html一切正常上传到托管服务后把链接发过去对方打开只有背景色文字和照片全都没有。原因多半是两个。一是用了script typemodule或者fetch(./data.json)file://协议下没有同源限制所以本地能跑换成http(s)之后模块加载和 fetch 都要过同源策略路径或 MIME 一不对就直接失败而且失败是静默的页面只是不动。二是路径写成了/css/main.css这种绝对路径部署在子目录比如/gift/时全部 404。解决全部改回普通script路径统一用./开头需要的数据直接内联成 JS 常量不做运行时请求。上线后自己先用开发者工具的 Network 面板看一遍红色的一律修掉再交付。这条属于血泪经验我第一次交付就是这个原因白屏。5.2 iPhone 上第一屏底部永远露一条缝现象占满整屏的封面在 iPhone 上底部露出一条背景色滚动时内容还会被地址栏压住一截。原因移动端的100vh取的是“地址栏收起时”的视口高度实际可视区域更小差值就是那条缝另外刘海屏底部有一条 home indicator 的安全区内容会被压在上面。解决用svh并保留vh兜底同时补安全区内边距。.screen { min-height: 100vh; /* 老浏览器兜底不认识下一行时用这个 */ min-height: 100svh; /* 小视口地址栏展开时的真实可见高度 */ padding-top: calc(24px env(safe-area-inset-top, 0px)); padding-bottom: calc(24px env(safe-area-inset-bottom, 0px)); }safe-area-inset-*的第二个参数是兜底值非刘海屏上取到 0不会多出空白。5.3 在聊天软件里点开是白屏或者提示“用浏览器打开”现象直接把链接发过去她点开是一片白右上角才有“在浏览器中打开”的入口。原因内置 WebView 的内核版本普遍偏旧可能不认识:has()、容器查询、Array.prototype.at()这类新语法一旦解析失败整个脚本就不执行了。另一种情况是某些内置环境对自动播放、localStorage有限制脚本里一抛异常就中断后续所有逻辑。解决语法上主动降级不用:has()、不用顶层await关键文案全部写死在 HTML 里JS 只做增强这样即使脚本挂了字还在、图还在只是没动画。另外在页面底部放一行不起眼的提示“如果显示不正常点右上角用浏览器打开”成本一行字能救回一次交付。5.4 滚动一顿一顿几分钟后手机发烫现象桌面上丝滑手机上滑动明显掉帧用一会儿机身发热。原因will-change加在所有元素上没撤每个元素占一个合成层大面积filter: blur()做光晕模糊半径越大越吃 GPUCanvas 按 3 倍像素比全屏渲染动画里改了width、top这类会触发重排的属性。解决will-change在transitionend里撤掉第 4 章的观察器已经做了光晕改成静态的radial-gradient背景图视觉几乎一样但不耗 GPUCanvas 的maxDPR封顶 2动画只碰transform和opacity绝不碰尺寸和位置属性。5.5 想截图保存结果选中了一整段文字现象她长按照片想保存弹出了存储菜单同时整屏文字被蓝色高亮选中截图很不好看。原因默认user-select: text长按会触发选择图片没设draggablefalse拖拽时还会出现半透明残影。解决装饰性元素标题、角落小字、图注设user-select: none正文段落保留可选中——万一她想复制那句话图片加draggablefalse需要保存的图不要设pointer-events: none否则长按菜单出不来。.cover__name, .screen__title, .grid figcaption { -webkit-user-select: none; user-select: none; }6. 交付前跑一遍六项验收和一个 ?debug1 开关页面写完只是半成品真正决定成败的是交付前那二十分钟。我现在固定跑一张六项清单全部过一遍才发链接安卓和 iPhone 各开一次第一屏无白边断开网络重开本地文件能完整显示说明没有隐藏的运行时请求把所有图片换成弱网限速加载一次看有没有布局跳动点一次音乐按钮能响能停滑到底部再滑回顶动画不重播最后让她本人用她的手机打开一次——机型差异是清单覆盖不到的这一步不能省。省时间靠的是一个小开关地址栏加?debug1才出现的调试面板正常访问完全看不到交付前用它在真机上实时看帧率和天数。// 只在 ?debug1 时挂调试面板正常访问零影响 if (new URLSearchParams(location.search).get(debug) 1) { const box document.createElement(pre); box.style.cssText position:fixed;left:8px;bottom:8px;z-index:99;margin:0;padding:6px 8px; font:12px/1.4 ui-monospace,monospace;color:#8f8;background:#000a;border-radius:6px; document.body.appendChild(box); let last performance.now(); let frames 0; let fps 0; (function tick(t) { frames 1; if (t - last 1000) { fps frames; frames 0; last t; } box.textContent fps fps | dpr (window.devicePixelRatio || 1) | days daysSince(window.CONFIG.startDate); requestAnimationFrame(tick); })(last); }真机上 fps 长期低于 45就去查第 5.4 条那几项基本一抓一个准。dpr显示出来是为了确认maxDPR有没有生效——有些机型浏览器上报的值和实际渲染不一致看到数字比预期大就得回头调。最后说一句我自己的习惯这类页面我从来不做“最终版”只做“能发的版本”。因为需求永远在变今天想加一屏明天想换配色如果一开始就把颜色、文案、日期全写死在 HTML 里第三次修改就会开始出错。把内容集中在config.js、把样式集中在:root的变量里剩下的时间留给挑照片和写字——那才是她真正会看的部分。希望帮到你。本文还有配套的精品资源点击获取