纯HTML/CSS/JS实现动态图驱动的表白页
2026/9/14 5:12:25 网站建设 项目流程

简介:这是一份面向前端初学者与情感类网页开发者的HTML表白源码包,主打怀旧氛围与动态交互体验,适用于情人节、纪念日等场景的个性化网页制作。资源以HTML为核心,融合CSS3动画与JavaScript事件控制,实现图片轮播、文字渐显、背景切换等浪漫效果,并支持音频嵌入与响应式适配,便于在PC及移动端直接部署运行。压缩包为RAR格式,大小6.87MB,包含HTML主页面、配套CSS样式表、JS交互脚本及素材资源(如动态图、背景音乐等),文件结构简洁清晰,利于快速理解DOM操作、过渡动画与用户点击响应等核心知识点。目前已有2760人学习下载,开发者可直接部署预览(演示地址:http://www.lmtaolu.cn/biaobai/huiyi/),亦可基于源码二次定制主题、替换图文内容或拓展交互逻辑,是练手HTML/CSS/JS综合能力的优质轻量级项目范例。

1. 用纯 HTML/CSS/JS 实现可复用的“回忆版表白页”:不是模板搬运,而是动态图驱动的情感表达

你见过那种打开就自动播放老照片轮播、文字逐字浮现、背景音乐渐入、点击还能触发粒子特效的表白页吗?它不依赖任何后端服务,不调用外部 CDN(连 jQuery 都没引入),所有逻辑封装在单个 HTML 文件里——这就是“回忆版-表白源码html动态图”的真实形态。它不是静态截图拼接,而是用 CSS 动画 + JS 时间轴控制 + SVG/Canvas 动态图生成技术,把“回忆”这个抽象概念转化成可交互的视觉流:比如用<canvas>绘制手绘风格的心跳曲线,用@keyframes实现胶片划痕过渡,用requestAnimationFrame同步照片淡入与文字打字机效果。适合前端初学者照着改名字和照片路径,也适合有经验的开发者替换动画引擎或接入本地相册 API。核心不在“多炫”,而在“每帧都承载叙事逻辑”——第 3 张图出现时,背景色才从灰蓝转为暖粉;最后一句告白显示后,粒子才从屏幕中心炸开。全文无框架、无构建工具、无网络请求,双击即运行,微信内可直接分享。

2. 构建动态图底层:用 Canvas 绘制可编程的心跳曲线与粒子系统

2.1 为什么选 Canvas 而非 GIF 或 CSS 动画?

GIF 无法响应用户交互(如暂停/重放),且帧率固定难以匹配文字节奏;纯 CSS 动画在复杂路径(如心形轨迹)上控制精度低,且无法实时计算数据点。而 Canvas 提供像素级控制,配合requestAnimationFrame可实现毫秒级同步——这正是“回忆版”中“心跳曲线随文字进度起伏”“粒子爆炸位置锚定在鼠标点击点”的技术基础。我们不预渲染动画序列,而是每帧动态计算:心跳幅度由当前展示的回忆条目数决定,粒子数量由用户点击时长决定。

2.1.1 初始化 Canvas 与心跳曲线绘制逻辑
<canvas id="heartCanvas" width="400" height="200"></canvas>
// 获取上下文并设置初始状态 const canvas = document.getElementById('heartCanvas'); const ctx = canvas.getContext('2d'); let time = 0; const heartPoints = []; // 存储心跳路径点 // 心跳曲线生成函数:基于正弦波变形 function generateHeartPath() { const points = []; const scale = 80; // 基础振幅 const offsetX = canvas.width / 2; const offsetY = canvas.height / 2; for (let t = 0; t < Math.PI * 2; t += 0.05) { // 心形参数方程:x=16sin³t, y=13cos t-5cos(2t)-2cos(3t)-cos(4t) const x = offsetX + scale * (16 * Math.pow(Math.sin(t), 3)); const y = offsetY + scale * (13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)); points.push({ x, y }); } return points; } // 每帧绘制:平滑连接点并模拟“搏动”效果 function drawHeart() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); ctx.strokeStyle = '#ff6b6b'; ctx.lineWidth = 3; ctx.lineCap = 'round'; // 动态缩放:time 控制搏动频率 const pulseScale = 1 + 0.1 * Math.sin(time * 0.01); heartPoints.forEach((point, i) => { const scaledX = canvas.width / 2 + (point.x - canvas.width / 2) * pulseScale; const scaledY = canvas.height / 2 + (point.y - canvas.height / 2) * pulseScale; if (i === 0) { ctx.moveTo(scaledX, scaledY); } else { ctx.lineTo(scaledX, scaledY); } }); ctx.stroke(); time += 1; }

提示generateHeartPath()生成的是静态心形坐标点,drawHeart()在每帧中动态缩放这些点,形成“搏动”效果。time变量作为全局动画时钟,确保所有动态元素(心跳、粒子、文字)节奏一致。若需降低 CPU 占用,可在requestAnimationFrame回调中添加if (time % 2 === 0)跳过部分帧。

2.2 粒子系统:点击即生,物理模拟衰减

粒子不是随机飞散,而是遵循简易物理模型:初始速度向量由点击位置与画布中心决定,加速度模拟空气阻力,透明度随生命周期线性衰减。

2.2.1 粒子类定义与批量管理
class Particle { constructor(x, y) { this.x = x; this.y = y; this.size = Math.random() * 4 + 2; // 2-6px this.speedX = (Math.random() - 0.5) * 8; // -4~4 this.speedY = (Math.random() - 0.5) * 8; this.life = 1; // 0~1 this.decay = 0.015 + Math.random() * 0.005; // 衰减速率 } update() { this.x += this.speedX; this.y += this.speedY; this.speedX *= 0.97; // 空气阻力 this.speedY *= 0.97; this.life -= this.decay; } draw(ctx) { ctx.globalAlpha = this.life; ctx.fillStyle = `hsl(${Math.random() * 60 + 340}, 100%, 60%)`; // 粉紫渐变色 ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); } } // 管理粒子池:避免频繁 new/delete const particles = []; const maxParticles = 150; function createParticles(x, y) { const centerX = canvas.width / 2; const centerY = canvas.height / 2; const angle = Math.atan2(y - centerY, x - centerX); for (let i = 0; i < 30; i++) { const p = new Particle(x, y); // 初始速度指向中心反方向,增强“炸开”感 p.speedX = Math.cos(angle + (Math.random() - 0.5) * 0.5) * 6; p.speedY = Math.sin(angle + (Math.random() - 0.5) * 0.5) * 6; particles.push(p); } } // 动画主循环 function animate() { drawHeart(); // 先绘制心跳 // 更新并绘制粒子 for (let i = particles.length - 1; i >= 0; i--) { particles[i].update(); if (particles[i].life <= 0) { particles.splice(i, 1); } } ctx.globalAlpha = 1; particles.forEach(p => p.draw(ctx)); requestAnimationFrame(animate); }

注意:粒子数量限制在maxParticles是关键优化点。未做此限制时,连续点击会导致内存泄漏和帧率骤降。splice(i, 1)从末尾向前遍历删除,避免索引错位。颜色使用hsl()而非rgb(),便于通过色相值(340° 为品红)快速调整主题色。

3. 驱动回忆叙事:用 CSS 动画时间轴控制图文出场节奏

3.1 构建可配置的回忆事件表(JSON 驱动)

所有回忆条目(照片、文字、触发时间)不再硬编码在 HTML 中,而是集中管理于一个 JavaScript 对象。这样修改内容无需触碰 HTML 结构,且便于后续扩展为本地 JSON 文件读取。

const memories = [ { "id": "m1", "img": "photos/first_meet.jpg", "text": "第一次见面,你笑着递来一杯奶茶", "delay": 0, // 秒级延迟,相对于页面加载 "duration": 4000 // 显示时长(ms) }, { "id": "m2", "img": "photos/rainy_day.jpg", "text": "下雨天共撑一把伞,伞倾向我这边", "delay": 5000, "duration": 4000 }, { "id": "m3", "img": "photos/night_walk.jpg", "text": "深夜散步,路灯把影子拉得很长很长", "delay": 10000, "duration": 4000 } ];
3.1.1 动态注入 DOM 并绑定 CSS 动画类
function renderMemories() { const container = document.getElementById('memories-container'); memories.forEach((item, index) => { const div = document.createElement('div'); div.className = 'memory-item'; div.id = item.id; // 图片区域:设置懒加载占位 const imgDiv = document.createElement('div'); imgDiv.className = 'memory-img'; imgDiv.innerHTML = `<img src="${item.img}" alt="回忆 ${index + 1}" loading="lazy">`; // 文字区域:逐字打字效果 const textDiv = document.createElement('div'); textDiv.className = 'memory-text'; textDiv.textContent = item.text; div.appendChild(imgDiv); div.appendChild(textDiv); container.appendChild(div); // 关键:为每个条目添加带延迟的 CSS 动画类 setTimeout(() => { div.classList.add('fade-in-up'); // 4秒后自动移除,避免影响下一条目 setTimeout(() => div.classList.remove('fade-in-up'), item.duration); }, item.delay); }); }

3.2 CSS 动画时间轴:精确控制入场/退场时机

/* 定义基础动画 */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(30px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } /* 应用到回忆条目 */ .memory-item { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; pointer-events: none; /* 防止遮挡下方按钮 */ opacity: 0; } .memory-item.fade-in-up { animation: fadeInUp 0.6s ease-out forwards; pointer-events: auto; } /* 图片样式:圆角+阴影 */ .memory-img img { width: 80vw; max-width: 500px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 20px; } /* 打字机效果文字 */ .memory-text { font-size: 1.4rem; font-weight: 500; color: #333; text-align: center; max-width: 80%; line-height: 1.6; /* 使用伪元素实现打字效果 */ position: relative; overflow: hidden; white-space: nowrap; border-right: 3px solid #ff6b6b; animation: typing 3s steps(40, end), blink-caret 0.75s step-end infinite; } @keyframes typing { from { width: 0 } to { width: 100% } } @keyframes blink-caret { from, to { border-color: transparent } 50% { border-color: #ff6b6b } }

提示pointer-events: none是关键设计。回忆条目层叠在页面上,但默认不拦截鼠标事件,确保底部的“点击生成粒子”按钮仍可响应。仅当.fade-in-up类激活时,pointer-events: auto才生效,此时用户才能点击该条目触发特效。steps(40, end)中的40对应文字字符数,需根据实际文本长度调整,否则打字效果会卡顿。

4. 优化移动端体验与微信兼容性:响应式布局与 touch 事件适配

4.1 移动端优先的 CSS 布局重构

微信内置浏览器对vh单位支持不稳定,且 iOS Safari 存在 viewport 缩放问题。我们放弃100vh,改用min-height: 100%+flex布局,并针对小屏设备调整字体与间距。

/* 移动端基础重置 */ @media (max-width: 768px) { body { margin: 0; padding: 0; font-size: 16px; /* 防止 iOS 自动放大 */ } .container { min-height: 100vh; display: flex; flex-direction: column; } .header { padding: 15px 20px; } .header h1 { font-size: 1.8rem; margin: 0; } .memory-img img { width: 90vw; max-width: 400px; } .memory-text { font-size: 1.2rem; padding: 0 20px; } /* 修复 iOS Safari 滚动条闪烁 */ * { -webkit-overflow-scrolling: touch; } }
4.1.1 Touch 事件替代 click:解决微信点击延迟
// 替换所有 click 事件监听器为 touchstart document.addEventListener('touchstart', function(e) { if (e.target.closest('.trigger-particle')) { e.preventDefault(); // 阻止默认滚动行为 const rect = e.target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; createParticles(x, y); } }, { passive: false }); // 兼容 PC 端:保留 click 事件 document.addEventListener('click', function(e) { if (e.target.closest('.trigger-particle')) { const rect = e.target.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; createParticles(x, y); } });

注意{ passive: false }是关键。iOS Safari 默认将touchstart设为 passive,禁止在其中调用preventDefault()。此处需显式声明非 passive,才能阻止点击后的 300ms 延迟。但必须配合e.preventDefault()使用,否则页面可能无法滚动。

4.2 微信环境检测与字体 fallback

微信内置浏览器不支持某些现代字体特性,且font-display: swap行为异常。我们采用保守策略:优先使用系统字体栈,仅在支持时加载自定义字体。

<!-- 在 head 中 --> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700&display=block">
/* 字体回退链 */ body { font-family: 'Noto Serif SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; } /* 强制字体加载完成后再显示内容,避免 FOIT */ body::before { content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #fff; z-index: 9999; transition: opacity 0.3s; } @font-face { font-family: 'Noto Serif SC'; src: url('https://fonts.gstatic.com/s/...') format('woff2'); font-display: block; /* 确保加载完成前不渲染 */ } /* 加载完成后移除遮罩 */ body.font-loaded::before { opacity: 0; pointer-events: none; } /* JS 检测字体加载 */ document.fonts.load('1em "Noto Serif SC"').then(() => { document.body.classList.add('font-loaded'); }).catch(() => { // 加载失败,降级处理 document.body.style.fontFamily = '"PingFang SC", "Microsoft YaHei", sans-serif'; });

5. 进阶技巧:离线缓存照片与本地存储回忆进度

5.1 使用 Cache API 预缓存回忆图片(Service Worker)

即使用户首次访问,也能保证照片秒开。我们不依赖 manifest.json,而是用现代 Cache API 动态缓存。

// 在主 JS 中注册 Service Worker if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(reg => console.log('SW registered:', reg)) .catch(err => console.log('SW registration failed:', err)); }); }
// sw.js const CACHE_NAME = 'confession-v1'; const urlsToCache = [ '/', '/index.html', '/style.css', '/script.js' ]; // 预缓存回忆图片(从 memories 数组提取) self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Opened cache'); return cache.addAll(urlsToCache); }) ); }); // 拦截图片请求并返回缓存 self.addEventListener('fetch', event => { if (event.request.destination === 'image') { event.respondWith( caches.match(event.request) .then(response => { return response || fetch(event.request); }) ); } });

提示urlsToCache数组需手动维护,但回忆图片路径可从memories数组动态生成。实际部署时,建议在构建阶段生成sw.js,将图片 URL 注入其中,避免运行时读取全局变量带来的不确定性。

5.2 用 localStorage 记录用户观看进度

用户关闭页面后重新打开,应从上次中断处继续播放,而非从头开始。我们记录当前播放的回忆 ID 和已播放时长。

// 播放控制器 let currentMemoryIndex = 0; let playbackStartTime = Date.now(); function startPlayback() { const now = Date.now(); const elapsed = now - playbackStartTime; // 从 localStorage 恢复进度 const saved = localStorage.getItem('confession-progress'); if (saved) { const progress = JSON.parse(saved); currentMemoryIndex = progress.index; playbackStartTime = now - progress.elapsed; } // 启动定时器检查是否该切换条目 setInterval(() => { const totalElapsed = Date.now() - playbackStartTime; const current = memories[currentMemoryIndex]; if (totalElapsed > current.delay + current.duration) { // 切换到下一条目 currentMemoryIndex = (currentMemoryIndex + 1) % memories.length; localStorage.setItem('confession-progress', JSON.stringify({ index: currentMemoryIndex, elapsed: totalElapsed - current.delay - current.duration })); } }, 100); // 100ms 检查一次,平衡精度与性能 }
5.2.1 清除进度的实用按钮(防误操作)
<button id="reset-progress" class="reset-btn">重置播放进度</button>
document.getElementById('reset-progress').addEventListener('click', () => { localStorage.removeItem('confession-progress'); alert('播放进度已重置,将从第一条回忆开始'); location.reload(); });

注意setInterval的检查间隔设为100ms而非1ms,这是性能权衡。人眼无法分辨 100ms 级别的延迟,但能显著降低 CPU 占用。localStorage存储的是相对时间差(elapsed),而非绝对时间戳,避免时区或系统时间变更导致的错乱。

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

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

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

立即咨询