ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

零依赖单页礼物网页:CSS变量、Canvas动画与移动端交付避坑

零依赖单页礼物网页:CSS变量、Canvas动画与移动端交付避坑

简介:面向情人节与表白场景的精美网页成品,适合具备基础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 五段式结构:封面、时间轴、照片墙、数据、信

五屏分别承担一个情绪任务:封面给惊喜(名字、日期、一句话),时间轴给回忆(5~8 个节点,多了就流水账),照片墙给视觉冲击,数据屏给一个具体的数字(在一起多少天),最后的信给安静下来的落点,音乐按钮也放在这一屏。不建议做成多页路由,一条链接发出去、她点开就能一路滑到底,这种体验最稳,也不用处理任何路由和刷新丢失状态的问题。

<!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/main.css"> </head> <body> <canvas id="bg" aria-hidden="true"></canvas> <main> <section class="screen" id="cover">// 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.25~0.4 之间比较稳,设 0.9 的话在手机上永远触发不了。

2.3 素材预处理:图片、字体、音频的体积红线

这一屏的观感八成来自素材质量,两成来自代码。但手机流量和首屏时间是硬的,我给自己的红线是首屏总下载量不超过 1.5MB。

素材目标格式关键参数体积目标说明
照片墙大图WebP质量 75~80,长边 1080单张 < 150KB再大手机上肉眼看不出差别
时间轴缩略图WebP长边 480单张 < 40KB展示尺寸小,别直接复用大图
中文字体WOFF2只保留页面用到的字< 80KB全量中文字体一般 5MB 以上
背景音乐MP3/AAC128kbps,30~60 秒< 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.4~2.0 a: Math.random() * 0.6 + 0.2, // 透明度 0.2~0.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 做进场,不要监听 scroll

scroll事件里读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。这两种情况界面表现一样,文案上写“再点一次试试”比写“播放失败”体面得多。另外音频文件务必preload="none",她如果不点,这 3MB 就不该被下载。

5. 避坑排查:手机上一打开就翻车的五个现场

桌面浏览器和真机之间的差距,比想象中大得多。下面五条都是我在交付前后真实遇到过的,按“现象 → 原因 → 解决”写,遇到同款可以直接对号入座。

5.1 本地双击正常,发出去对方打开一片白

现象:在自己电脑上双击index.html,一切正常;上传到托管服务后把链接发过去,对方打开只有背景色,文字和照片全都没有。

原因多半是两个。一是用了<script type="module">或者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()做光晕,模糊半径越大越吃 GPU;Canvas 按 3 倍像素比全屏渲染;动画里改了width、top这类会触发重排的属性。

解决:will-change在transitionend里撤掉(第 4 章的观察器已经做了);光晕改成静态的radial-gradient背景图,视觉几乎一样但不耗 GPU;Canvas 的maxDPR封顶 2;动画只碰transform和opacity,绝不碰尺寸和位置属性。

5.5 想截图保存,结果选中了一整段文字

现象:她长按照片想保存,弹出了存储菜单,同时整屏文字被蓝色高亮选中,截图很不好看。

原因:默认user-select: text,长按会触发选择;图片没设draggable="false",拖拽时还会出现半透明残影。

解决:装饰性元素(标题、角落小字、图注)设user-select: none,正文段落保留可选中——万一她想复制那句话;图片加draggable="false",需要保存的图不要设pointer-events: none,否则长按菜单出不来。

.cover__name, .screen__title, .grid figcaption { -webkit-user-select: none; user-select: none; }

6. 交付前跑一遍:六项验收和一个 ?debug=1 开关

页面写完只是半成品,真正决定成败的是交付前那二十分钟。我现在固定跑一张六项清单,全部过一遍才发链接:安卓和 iPhone 各开一次,第一屏无白边;断开网络重开,本地文件能完整显示(说明没有隐藏的运行时请求);把所有图片换成弱网限速加载一次,看有没有布局跳动;点一次音乐按钮,能响能停;滑到底部再滑回顶,动画不重播;最后让她本人用她的手机打开一次——机型差异是清单覆盖不到的,这一步不能省。

省时间靠的是一个小开关:地址栏加?debug=1才出现的调试面板,正常访问完全看不到,交付前用它在真机上实时看帧率和天数。

// 只在 ?debug=1 时挂调试面板,正常访问零影响 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的变量里,剩下的时间留给挑照片和写字——那才是她真正会看的部分。希望帮到你。

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

返回列表