做这种表白向的小项目,我最看重的不是代码写得有多花哨,而是对方打开页面的那几秒里,能不能真的“哇”一下。去年我帮朋友赶制过一份纪念日礼物:用 JS 和 HTML 写了一个爱心照片墙,打开就是一张由几十张合影组成的爱心,每张照片轻轻旋转、边缘留白,鼠标一碰还会放大。没有后端、没有框架、没有复杂构建流程,就是一个能直接双击运行的 HTML 文件,但效果比很多花了大价钱做的电子相册都自然。
当时发在朋友圈里,评论区至少二十个人问“这个怎么做的”。后来我又陆陆续续改了三版,踩了不少坑,也总结出一些能让效果更上一层楼的小技巧。所以今天把整个思路和能直接跑的代码都放出来,想做表白页、纪念日页、毕业合影页的,都可以直接拿去改。
1. 这个项目到底在做一件什么事
1.1 场景动机:为什么照片墙比单纯送礼更打动人
先说动机。一个能让对方记住的礼物,往往不在于贵,而在于“你花了心思”。照片墙恰好是“心思”最直观的载体:它需要你收集照片、挑选合影、回忆相处片段,再把这些东西变成一个有视觉冲击力的画面。相比买一束花,这种手工感是没办法批量复制的。
我见过很多类似项目做成“照片九宫格”或者“幻灯片轮播”,但说实话,九宫格太像工作汇报,轮播又少了“整体感”。爱心照片墙的妙处在于,照片一多,自动形成一种“拼贴艺术”的观感,你不需要刻意排版,形状本身就提供了构图。你只是在用心形的数学曲线排列照片,但视觉效果一眼就能击中情绪。
这个方案适合的场景很固定:
- 恋爱纪念日、求婚现场、婚礼大屏暖场
- 生日惊喜页面,配合背景音乐和留言文字
- 毕业季同窗合影合集,几个人共用一个页面
- 朋友聚会后的活动返图,做成有温度的分享链接
尤其适合那种“想表达,但不知道说什么”的人。照片墙本身就是语言,你不需要写大段文案。
1.2 技术选型:为什么只用 HTML 和 JS,不上框架
这可能是很多人最好奇的第一件事:做一个这么“炫”的效果,是不是得上 React、Vue,或者用 Canvas?
我的答案很直接:不用。核心原因有三个。
第一,项目规模根本不值得引入框架。照片墙的公式计算一次就结束了,剩下的全是 CSS 交互。一个纯静态 HTML 文件能解决的问题,引入框架等于给火柴盒装航空发动机。而且框架需要构建、需要 npm install、需要维护依赖,对大多数非专业前端的人来说,这套流程本身就是门槛。
第二,DOM 方式更适合做交互。如果用 Canvas 画照片墙,后续的点击弹窗、悬停放大、图片懒加载全都得自己模拟,工作量翻倍。几十个 DOM 节点对浏览器来说完全不是负担,但交互体验能直接复用 CSS 的 transition 和 z-index,省一大截事。
第三,可移植性强。最后发给别人时,你可以直接发一个 .html 文件,对方无论用什么设备打开都能看,不需要装环境、不需要跑服务。如果你是帮着朋友做惊喜,对方打开文件的瞬间如果还要等白屏、等加载,那浪漫气氛就全毁了。
我第一版就犯过“杀鸡用牛刀”的错,用了个脚手架把项目搞得很庞大,最后发现 90% 的代码都是空转。后来全部重写成单文件版,效果一模一样,维护还轻松。
2. 爱心形状的数学原理与照片布局
2.1 那个经典爱心曲线方程
爱心照片墙最核心的环节,是让照片“排成一个心形”。这个心形不是拿设计软件画的,而是用数学公式算出来的。
这里用的是一个非常经典的爱心参数方程:
- x = 16 * sin³(t)
- y = 13 * cos(t) - 5 * cos(2t) - 2 * cos(3t) - cos(4t)
当 t 从 0 走到 2π 时,公式会描出一个完整的爱心轮廓。t 可以理解为“角度参数”,每取一个 t 值,就得到一组对应的 x、y 坐标,也就是一个“点”。
第一次看这个公式的人可能会觉得抽象,但它实际做的工作很简单:给你一个时间点 t,它告诉你爱心轮廓上那个位置在哪里。你把很多个 t 值对应的坐标点算出来,然后在每个坐标上放一张照片,照片就自然排成了爱心形。
为什么要用这个公式而不是直接手写坐标?因为手写坐标没办法应付“照片数量变化”。你放 30 张照片时是一种分布,换到 80 张照片时,只需要改变采样数量就能自动均匀铺满,这个灵活性是手画给不了的。
2.2 参数点怎么变成页面上真实的照片坐标
公式算出来的 x、y 是一个“数学坐标”,范围大概在 -17 到 17 之间。浏览器页面上用的是像素坐标,所以还得做一次坐标变换。
我的做法很朴素:拿到页面上爱心墙容器的宽高,算出中心点,然后以中心点为原点,把公式算出来的数学坐标乘以一个缩放系数,变成像素偏移。
关键代码可以这么写:
function getHeartPosition(t) { 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); return { x, y }; } function getPixelPosition(t, centerX, centerY, scale) { const point = getHeartPosition(t); return { x: centerX + point.x * scale, y: centerY - point.y * scale }; }这里有一个新手容易忽略的坑:屏幕坐标系的 Y 轴是向下的,而数学坐标系的 Y 轴是向上的。如果不做处理,爱心会上下颠倒。我用了centerY - point.y * scale,就是为了把数学里的“上方”映射到屏幕的“上方”。
缩放系数 scale 的取值也要结合照片尺寸来定。如果你每张照片是 80px 宽,那至少要保证爱心左右最边缘的照片不会被裁掉一半。通常我会用:
const scale = Math.min(wallWidth, wallHeight) / 40;这个 40 是怎么来的呢?因为心形在 x 方向的范围大约是 -16 到 16,跨度 32,留出 4 个单位作为照片的半宽余量,取 40 刚好比较稳。具体项目里还要微调,照片越大,除数就要越大。
2.3 均匀分布的重要性:弧长重采样
直接平均取 t 值,也就是让 t 分别等于 0、2π/n、4π/n……这样写起来很简单,但会出现一个问题:照片并不是均匀分布在爱心轮廓上的。
原因在于这个参数方程的“速度”不均匀。有些地方 t 变化一点点,坐标却移动很多;有些地方 t 变了一大段,坐标只挪了一小步。直接按 t 等分的话,照片会出现上半部分挤在一起、下半部分稀稀拉拉的情况,爱心形状会明显“歪掉”。
解决办法是做一个“弧长重采样”。思路不复杂:先把爱心曲线按非常细的步长生成几千个点,算出相邻点之间的距离,得到整条曲线的弧长分布,再按“总弧长除以照片总数”的步长去重新取点。这样在曲线下降快的地方自动多取点,慢的地方少取点,最终照片看起来就均匀了。
很多人第一次做这个项目不会意识到这个问题,出来的爱心总觉得哪里怪怪的,但又说不上来。其实就是分布不均匀闹的。
3. 完整代码实现与关键细节
3.1 页面骨架和样式设计
我会把整个项目做成一个 HTML 文件,页面结构非常清晰:顶部一句引导语,中间放照片墙容器,底部一句落款。真正干活的是 JS,HTML 和 CSS 只为展示服务。
先看 HTML 部分:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>我们的爱心照片墙</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { min-height: 100vh; background: linear-gradient(160deg, #fef2f4 0%, #ffe0e6 50%, #f7c9d2 100%); font-family: "PingFang SC", "Microsoft YaHei", sans-serif; overflow-x: hidden; } .page { max-width: 960px; margin: 0 auto; padding: 40px 16px 60px; text-align: center; } .page h1 { font-size: 28px; color: #c2385a; margin-bottom: 10px; letter-spacing: 2px; } .page .subhead { font-size: 14px; color: #c76b81; margin-bottom: 30px; } .photo-wall { position: relative; width: 100%; height: 70vh; min-height: 480px; margin: 0 auto 24px; } .photo-item { position: absolute; left: 0; top: 0; transform: translate(-50%, -50%); cursor: pointer; transition: transform 0.35s ease, box-shadow 0.35s ease, z-index 0s; } .photo-item img { width: 90px; height: 90px; object-fit: cover; border-radius: 12px; border: 3px solid #fff; box-shadow: 0 6px 18px rgba(190, 60, 90, 0.22); display: block; background: #f4d5dc; } .photo-item:hover { transform: translate(-50%, -50%) scale(1.2) rotate(0deg); box-shadow: 0 12px 28px rgba(190, 60, 90, 0.35); z-index: 99; } .bottom-text { font-size: 15px; color: #a03a53; letter-spacing: 1px; margin-top: 16px; } .lightbox { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); display: none; align-items: center; justify-content: center; z-index: 999; cursor: pointer; } .lightbox img { max-width: 80vw; max-height: 80vh; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); border: 4px solid #fff; } .lightbox.show { display: flex; } </style> </head> <body> <div class="page"> <h1>遇见你之后,每天都值得纪念</h1> <p class="subhead">轻轻移动鼠标,回忆会自动放大</p> <div id="photoWall" class="photo-wall"></div> <p class="bottom-text">这些照片加起来,也只装得下我们故事的万分之一。</p> </div> <div id="lightbox" class="lightbox"> <img src="" alt="大头照"> </div> <script> // 核心 JS 在下一节 </script> </body> </html>样式上我做了几个优化:照片统一使用 object-fit: cover,不管原图是横屏还是竖屏,都会被裁剪成正方形显示,不会把布局撑破。边框用了白色,是为了模拟拍立得相纸的感觉,视觉上比纯照片更“有温度”。hover 时放大并且把旋转角度归零,是为了让用户注意力集中在当前照片上,这个小细节我后面展开讲。
3.2 JS 核心逻辑:从照片数组到爱心墙
核心 JS 分四步走:准备照片、计算坐标、创建 DOM、绑定交互。
第一步,准备照片。我写了一个占位图生成函数,用 Canvas 画一张渐变色的圆图,方便你在没有真实照片时也能直接预览效果。之后把占位图换成真实照片很容易,只要把图片的 URL 或 blob 数据填进数组就行。
const photos = []; const count = 48; function makePlaceholder(i) { const canvas = document.createElement('canvas'); canvas.width = 180; canvas.height = 180; const ctx = canvas.getContext('2d'); const hue = (i * 15) % 360; const gradient = ctx.createLinearGradient(0, 0, 180, 180); gradient.addColorStop(0, `hsl(${hue}, 70%, 85%)`); gradient.addColorStop(1, `hsl(${hue + 40}, 60%, 65%)`); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 180, 180); ctx.fillStyle = '#fff'; ctx.font = 'bold 32px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(i + 1, 90, 90); return canvas.toDataURL('image/png'); } for (let i = 0; i < count; i++) { photos.push(makePlaceholder(i)); }第二步,计算坐标。这里做了弧长重采样,保证照片在爱心轮廓上尽量均匀分布:
function heartX(t) { return 16 * Math.pow(Math.sin(t), 3); } function heartY(t) { return 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); } function getUniformHeartPoints(n) { const poly = []; const step = 0.01; for (let t = 0; t <= Math.PI * 2; t += step) { const x = heartX(t); const y = heartY(t); if (poly.length === 0) { poly.push({ x, y, dist: 0 }); } else { const prev = poly[poly.length - 1]; const dx = x - prev.x; const dy = y - prev.y; const seg = Math.sqrt(dx * dx + dy * dy); poly.push({ x, y, dist: prev.dist + seg }); } } const totalLen = poly[poly.length - 1].dist; const targetLen = totalLen / n; const points = []; let polyIndex = 0; for (let i = 0; i < n; i++) { const target = i * targetLen; while (polyIndex < poly.length - 2 && poly[polyIndex + 1].dist < target) { polyIndex++; } const p1 = poly[polyIndex]; const p2 = poly[polyIndex + 1]; const segLen = p2.dist - p1.dist; const frac = segLen > 0 ? (target - p1.dist) / segLen : 0; points.push({ x: p1.x + (p2.x - p1.x) * frac, y: p1.y + (p2.y - p1.y) * frac }); } return points; }第三步,创建 DOM 节点并摆放。这一步是整个项目的视觉成型关键:
const wall = document.getElementById('photoWall'); const points = getUniformHeartPoints(photos.length); const wallW = wall.clientWidth; const wallH = wall.clientHeight; const centerX = wallW / 2; const centerY = wallH / 2; const scale = Math.min(wallW, wallH) / 40; photos.forEach((photo, index) => { const p = points[index]; const px = centerX + p.x * scale; const py = centerY - p.y * scale; const rotateAngle = (Math.random() * 30 - 15).toFixed(1); const item = document.createElement('div'); item.className = 'photo-item'; item.style.left = px + 'px'; item.style.top = py + 'px'; item.style.transform = `translate(-50%, -50%) rotate(${rotateAngle}deg)`; item.style.zIndex = Math.floor(Math.random() * 10); const img = document.createElement('img'); img.src = photo; img.alt = '爱心照片'; img.loading = 'lazy'; item.appendChild(img); wall.appendChild(item); });这里有个细节,translate(-50%, -50%)是必须的。否则每张照片的左上角会被当成它的锚点,整个爱心会朝右下角偏移。先 translate 再 rotate,才能保证旋转中心在照片中心,不然照片会像钟摆一样乱晃。
第四步,绑定交互。悬停放大已经在 CSS 里处理了,JS 只需要负责点击查看大图:
const lightbox = document.getElementById('lightbox'); const lightboxImg = lightbox.querySelector('img'); wall.addEventListener('click', (event) => { const item = event.target.closest('.photo-item'); if (!item) return; const img = item.querySelector('img'); lightboxImg.src = img.src; lightbox.classList.add('show'); }); lightbox.addEventListener('click', () => { lightbox.classList.remove('show'); });这四步做完,一个可用的爱心照片墙就成型了。整个文件不到 200 行,直接双击就能跑。
3.3 交互细节:旋转、悬停与层级控制
纯静态的爱心墙其实已经能看了,但交互细节才是决定“惊艳感”的分水岭。
第一是随机旋转角度。所有照片都以中心为轴,随机在 -15 度到 15 度之间旋转。这个范围的选择有讲究:太小了看起来像对齐的网格,太大了像随手乱扔的废纸堆。15 度以内的偏差,会营造出一种手工拼接的亲切感,又不会真的显得杂乱。
第二是悬停时旋转归零。当鼠标移动到某张照片上时,让它的旋转角度恢复到 0 度,同时放大 1.2 倍。这个动作会让人觉得“这张照片自己转正了在等你”。实际视觉体验非常有意思,比单纯的 scale 效果更灵动。
第三是层级控制。每张照片的 z-index 在创建时是随机的,这样照片互相会有一点点遮挡,形成层叠效果。但 hover 时必须把当前照片的 z-index 抬到最高,否则可能被邻边照片压住一部分,放大效果会被截一半。
第四是点击查看大图。移动端上 hover 是不生效的,点击进入灯箱就特别重要。照片缩略图只有 90px,很多细节根本看不清,点击变大了之后,看照片的人会有一种“从回忆里拉出来一张”的感觉。
这四个细节叠加起来,才让整个项目从“花架子”变成了“有感情的东西”。
4. 实操阶段最容易踩的坑
4.1 爱心形状变形:多半是坐标变换的锅
我调试第一版时遇到过最诡异的问题:爱心在电脑上正常,发到手机上一打开,右侧被截掉一大块,整个心形像被咬了一口。
排查后发现两个原因。一是固定用宽高 960px 作为容器的尺寸,但手机视口只有 375px 宽,坐标系中心点不在屏幕中心,右边自然超出去了。二是缩放系数直接用 wallWidth / 34,没有考虑照片本身也有宽度。
正确的做法是动态读取容器尺寸,并且把照片宽度计入缩放边界。我最终用的方案是让容器高度等于视口高度的 70%,宽度拉满,再根据 Math.min(宽, 高) 计算缩放系数。这样无论手机竖屏还是桌面宽屏,爱心都能完整显示。
4.2 照片多到一定程度会卡顿
DOM 节点数量少的时候,这个问题不痛不痒。但如果你放了 120 张照片,每张照片又有 box-shadow 和 transition,低端手机打开时就会明显掉帧,尤其是 hover 动画频繁触发的时候。
我实测下来,90px 的照片墙,60 张以内是完全没问题的;超过 100 张,最好做两个优化:
第一,减少阴影扩散范围。改成 box-shadow 颜色更深、模糊半径更小,视觉影响不大,但 GPU 压力小很多。第二,把 hover 的 transition 时长从 0.35s 加到 0.25s 以内,减少动画帧数。第三,如果照片数量实在太多,建议把 hover 放大改为鼠标移入后再延迟 100ms 触发,避免连续扫过照片时产生动画风暴。
还有一个偏门技巧:如果只是想让页面看起来照片很多,可以放一部分“装饰卡”。比如在爱心的大轮廓间隙里放几张带文字的小卡牌,写一些“D-12”“第一次旅行”“吵架又和好”之类的简短注释。这样既能填充稀疏区域,又能增加叙事感。
4.3 图片加载与内存泄漏
这个问题主要是移动端。现在手机拍的照片动不动三四兆,直接作为网页图片嵌入,那个 HTML 文件会非常巨大,加载缓慢,而且浏览器渲染时内存占用感人。
有个实用的处理思路:如果照片数量在 30 张以内,可以直接用压缩后的图片,尺寸控制在 400x400 到 600x600 即可。超过 30 张,至少用一个简单的懒加载策略,比如 img 的 loading="lazy" 属性,让视口附近的图片按需加载。
我自己更常用的方案是:照片墙的 90px 缩略图用一张小图,点击后查看大图时再加载高分辨率图片。缩略图确保秒开,大图允许慢慢加载。这样体验是最好的,因为看小图的人根本不需要那么高的清晰度。
4.4 最终展示找不到“主角照片”
这是贼有趣的一个坑,技术之外的事。如果你把照片平均分布在爱心上,所有照片看起来一样重要,反而失去了视觉焦点。第一次看的人会扫一圈,但目光停留不下来。
后来我调整了策略:在爱心正中间放一张最大的主照片,尺寸是其他照片的三倍,周围照片围绕它排列。这相当于给画面设置了一个“主角”,看的人会先锁定一个视觉中心,再去浏览其他细节,观感瞬间就不一样了。
实现起来也简单:算出中心点坐标后,手动 append 一个大号的 .photo-item,并把 z-index 设得比周围都高。其余照片的生成逻辑不用动。
4.5 字体和中文文案的坑
如果你在手机上打开页面,系统字体大概率是 MiUI 或 HarmonyOS 的默认字体,和你电脑上看到的 PingFang SC 不一样。排版可能有一点点差异,但不影响大局。
需要留意的是不要在页面里写太多长文案。照片墙的核心是照片,文字的职责是“点睛”。我试过,如果标题超过 14 个字,视线就会被拉扯到文字上,照片的冲击力反而弱了。标题、副标题、落款,三段就够了。真想把更多话说出来,可以把长文案放在点击主照片后的弹层或留言区。
5. 一点个人经验与后续扩展
5.1 从“照片墙”到“小仪式”:加一个倒计时结尾
做完第一版后,总觉得少了点高潮。后来突发奇想,在页面上加了一个“倒计时按钮”:使用者点击一个写着“点我”的爱心按钮后,整个照片墙中间会缓缓出现一张两人的主合影,底下再浮现一行手写字。
这个设计其实就是利用爱心中间的空白区域。爱心曲线的底部尖角往上,中心地带是天然留白,不放东西总觉得空,放一个按钮就刚好。
实现逻辑同样是绝对定位,按钮放在 cneterX 和 centerY 的位置,点击后换成大照片加渐显动画。这个功能的成败全在文案上:按钮文字不要写“查看主图”,写“还有一句话,只对你说”之类的,预期值会完全不同。
5.2 还能怎样延展这个项目
做一次这个项目之后,你会发现自己对“用公式生成布局”这件事有了条件反射。同样的思路,几乎可以迁移到任何形状上:五角星、圆形、文字轮廓,甚至某个景点的轮廓曲线。
我后来就把参数方程换成五角星公式,做了一次小组团建合影墙。每一张照片都在星星轮廓上,效果和爱心完全不同,但核心代码只改了十几行。所以这次学会的不是“做一个爱心墙”,而是“用参数曲线生成视觉布局”这个通用技能。
如果想再进阶,可以考虑在爱心墙下加一段 30 到 60 秒的自动轮播:页面打开后,心形照片按顺序逐一闪亮一下,最后停留在正中央的主照片上。这个表现力比静态页面的冲击力更强,适合在婚礼或者生日现场的投影大屏上播放。
5.3 我的最后一点建议
做这种有情绪的项目,技术只是骨架,内容才是血肉。花一个下午把代码调通,并不难;难的是认真选出每张照片、想清楚每一句文案。同一个模板,放随手截的聊天记录图和放精心挑选的合影,效果天差地别。
我自己的习惯是,照片墙里的每一张图片,都要能回答一个问题:它代表了我们之间的哪一段故事?如果能答上来,这个页面就不只是代码,而是你自己亲手做出来的回忆展。
真遇到喜欢的人,别只会发一条“我喜欢你”的消息。把你们的回忆做成这个页面,点开的一瞬间,什么都替你说完了。