简介:这是一套基于HTML、CSS与JavaScript编写的同学录与毕业照展示站点源码,适合学校班级、校友会及个人纪念网页制作场景,也可作为前端学习者练习综合页面开发与资源组织的参考项目。压缩包共84个文件,约19.08MB,核心为14个HTML页面、9个JavaScript脚本与3张CSS样式表,配合大量JPG/PNG毕业合照、GIF动图及SVG矢量图标,构成可直接预览的完整前端工程。项目目录按blog、Gallery、archives、tags、assets等模块划分,并内置APlayer音乐播放器、Live2D看板娘、本地搜索、404自定义页、域名CNAME与search.xml等扩展配置,便于二次开发时替换照片与文案。借助此源码可快速搭建一本可交互浏览的线上同学录,留存班级合影与毕业记忆。目前已有245人学习或下载,适合具备基础HTML/CSS/JS知识并希望了解完整站点搭建流程的读者。
1. 基于 HTML、JavaScript、CSS 的 wuliangxiao 同学录与毕业照:先弄清楚这套源码在解决什么
毕业季整理同学录,最尴尬的是:Word 表格不好看、问卷星导出不自由、小程序过了授权就失效。基于 HTML、JavaScript、CSS 的 wuliangxiao 同学录与毕业照设计源码,走的是另一条路——用纯前端静态网页把同学信息、毕业照、寄语做成一本可点击的电子纪念册。它不需要数据库,不需要跑后端服务,所有数据写在前端文件里,双击 index.html 就能打开,部署到任意静态托管平台也能直接用。对班级组织者来说,改一行 JavaScript 数组就能加人;对前端初学者来说,这份源码是一个练手项目;对正在做毕设的同学来说,它还能摊上一个“交互媒体设计与实现”的壳子。这篇笔记就按标题把它拆开,讲清楚怎么落地,以及哪些地方最容易翻车。
2. 选型与整体结构:为什么同学录适合做成纯静态页面,以及源码文件怎么摆
2.1 纯 HTML/CSS/JS 的好处:零部署成本、双击即用、好备份
同学录和普通网站最大的区别是:使用周期短、数据量小、访问人数有限,往往就是全班几十人。这种场景下上 React/Vue 再加个后端接口,属于杀鸡用牛刀。纯静态方案有一个不可替代的优点:没有任何运行时依赖,用户拿到整个文件夹后,连不上网也能打开,浏览器直接解析 HTML、CSS 和 JavaScript 就能渲染。你不用担心服务器过期、数据库丢失,也不怕十年后打开时出现“证书过期”或接口失效。
我一般会把这类项目分成“数据、结构、样式、行为”四件事。数据放 JavaScript 数组,结构放 HTML 标签,样式放 CSS 文件,行为放独立的事件和渲染函数。这样即使一个完全不懂编程的同学,也能对照注释改 data.js 里的姓名和照片。另一个隐藏好处是备份容易:整个项目就是一个普通文件夹,拷贝到 U 盘、压缩成 zip 发出去,都不会破坏可用性。相比那些依赖账号体系的在线同学录,静态页面反而更像一份“能长期保存的数字纪念品”。
当然,纯静态也有边界。如果你需要收集同学填写的资料、上传照片,就绕不开后端;如果你希望访问者只能凭邀请链接打开,也需要一个鉴权服务。但作为“毕业时做一份纪念品”这件事,静态方案不仅够用,而且是最稳妥的。做之前想清楚三件事:谁来改数据?要不要部署到公网?照片原图要不要压缩?想清楚再动手,后面就不会反复返工。
2.2 项目文件划分:index.html、style.css、data.js、main.js
上一段说的四件事,落实到目录上就是四个核心文件。下面是一个常见的结构,也是我认为比较好维护的基线:
wuliangxiao/ ├── index.html # 页面骨架:头部、班级信息、照片墙容器 ├── style.css # 全部样式:布局、字体、卡片、交互动效 ├── data.js # 同学数据:姓名、昵称、照片、寄语、星座等 ├── main.js # 渲染逻辑:生成卡片、事件绑定、搜索过滤 └── images/ ├── class-photo.jpg # 班级大合照 └── members/ # 单个同学的头像/毕业照index.html负责把另外两个 JavaScript 文件和样式表引进来,顺序很讲究:data.js必须排在main.js前面,因为main.js里的渲染代码要读取data.js定义的数组,浏览器从上到下执行,顺序反了会直接报错。images文件夹和index.html保持平级,这样浏览器里写相对路径images/members/xxx.jpg就能访问。切忌把图片路径写成C:/Users/...或/Users/...这种绝对路径,否则一换电脑就原形毕露,这算是最常见的一个坑。
每个文件里放什么,可以按“是否常改”来划分:data.js是最常改的,加人、换照片、改寄语都在这里动;index.html是骨架,班级名称、日期、整体区块布局在这里定;style.css负责外观,字体、配色、间距、动效都在这里调;main.js是行为,如果不改交互逻辑,通常不会碰。如果你后面想把同学录扩展成“相册 + 留言 + 通讯录”三个页面,可以拆出album.js、message.js,但不要在起步阶段引入打包器或框架,不然同学们想帮忙改数据还得先学 Node.js。
2.3 数据与展示分离:把同学名录抽成 JavaScript 数组
同学录的核心数据是“人”。如果把每个人的信息一条条写死在 HTML 里,改一个人名要翻半天标签,而且照片墙、名单页、寄语页没法复用同一份数据。常见做法是定义一个数组,每个对象代表一个同学:
// data.js const wuliangxiaoClassMates = [ { id: 1, name: "张伟", nickname: "小伟", photo: "images/members/zhangwei.jpg", major: "计算机科学与技术", phone: "138****1234", email: "zhangwei@example.com", motto: "少熬夜,多写代码", blessing: "祝你以后头发依然茂盛", group: "计算机系" }, { id: 2, name: "李娜", nickname: "娜娜", photo: "", // 空字符串代表没照片,渲染时用默认头像 major: "视觉传达设计", phone: "139****5678", email: "lina@example.com", motto: "设计改变生活", blessing: "愿你被世界温柔以待", group: "艺术系" } ];这段代码里,photo字段是图片的相对路径;如果暂时没收到照片,就留空字符串,渲染逻辑里用typeof item.photo === "string" && item.photo.length > 0来判断是显示真实照片还是占位头像。group字段用于按班级或小组过滤,后面搜索功能会用到。注意,电话号码这类隐私信息,如果要在网页里公开,建议保留部分中间位数用星号代替,或者只放在本地不部署到公网。数据量是几十人时,这个数组足够;如果超过几百人,才需要考虑从外部 JSON 加载或加上分页。
为了让数据更稳定,我习惯在data.js底部加两行“校验辅助代码”:
// data.js 底部 const getSafePhoto = (item) => typeof item.photo === "string" && item.photo.trim() !== "" ? item.photo : "images/members/placeholder.png";为什么要单独抽这个函数?因为同学提供照片时常常不按规矩来:有人发的是相对路径,有人发的是网络 URL,有人干脆没发。getSafePhoto统一处理了“空字符串、undefined、非字符串”这几种情况,渲染时直接调用即可,避免每次在主逻辑里重复写判断。类似的,如果某个人的blessing字段缺失,也可以提供一个默认文案。数据与展示分离的核心就是:main.js不关心某个字段到底有没有值,只关心从哪个辅助函数拿数据。
3. 同学录列表实现:HTML 骨架、CSS 卡片、JavaScript 渲染与检索
3.1 HTML 骨架:语义化标签与承上启下的容器
写完数据,下一步是把页面结构搭起来。一个干净、语义化的 HTML 骨架直接影响后续 JavaScript 渲染的难度。下面是一个可以用的index.html核心片段:
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>wuliangxiao 同学录与毕业照</title> <link rel="stylesheet" href="style.css"> </head> <body> <header class="hero"> <h1>wuliangxiao 班级纪念册</h1> <p class="subtitle">那些年,我们一起走过的青春</p> </header> <main> <section class="class-photo-section"> <img class="class-photo" src="images/class-photo.jpg" alt="班级合照"> </section> <section class="members-section"> <div class="toolbar"> <input type="search" id="searchInput" placeholder="输入姓名或昵称搜索"> <select id="groupFilter"> <option value="">全部小组</option> <option value="计算机系">计算机系</option> <option value="艺术系">艺术系</option> </select> </div> <div id="membersGrid" class="members-grid"> <!-- JavaScript 渲染出来的卡片会放在这里 --> </div> </section> </main> <script src="data.js"></script> <script src="main.js"></script> </body> </html>这里有几个关键点:<meta charset="utf-8">必须放在 head 最前面,保证中文不乱码;viewport声明让手机端按照设备宽度渲染,否则后面适配会吃大亏;membersGrid是一个空容器,专门留给main.js往里面塞卡片。searchInput和groupFilter是两个交互控件,一个做文本搜索,一个做分组过滤,它们都通过id暴露给 JavaScript,不要用样式类名去绑定事件,那样两者容易耦合。
<script>放在</body>前是最保险的做法,因为脚本执行时 DOM 已经解析完毕,不需要再监听DOMContentLoaded。如果你习惯把脚本放在 head 里,就必须给 script 加上defer属性,否则main.js一执行就会因为找不到membersGrid而报错,这也是新人最容易遇到的白屏原因之一。
3.2 CSS 卡片布局:从“豆腐块”到“高级感”的关键参数
同学录的视觉重心是照片和姓名,卡片布局能不能立住,取决于三个参数:间距、圆角、阴影。下面这段style.css是一个可以直接套用的基线:
/* style.css */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; background: #f5f7fa; color: #333; line-height: 1.6; } .hero { text-align: center; padding: 48px 20px; background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%); color: #fff; } .members-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; padding: 32px 20px; } .member-card { background: #fff; border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); padding: 20px; transition: transform 0.3s ease, box-shadow 0.3s ease; display: flex; flex-direction: column; align-items: center; text-align: center; } .member-card:hover { transform: translateY(-6px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); } .avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; margin-bottom: 12px; }这段样式里,font-family沿着系统字体栈写,不依赖任何外部字体,离线也能正常显示;grid-template-columns的repeat(auto-fill, minmax(220px, 1fr))是响应式的核心,窗口窄的时候每列变少,窗口宽的时候自动加列,不用写媒体查询就能适配大部分屏幕。.avatar的object-fit: cover保证了不管原始照片是横的还是竖的,最终都裁成正方形且不变形。这比直接设置width和height强制拉伸要靠谱得多,也是很多新手把照片搞成“人变宽”的根源。
卡片里要支持“图片和文字一行”的需求,例如将姓名和昵称并排显示,可以用flex配合align-items: baseline:
.name-row { display: flex; justify-content: center; align-items: baseline; gap: 8px; margin-bottom: 4px; } .name-row h3 { font-size: 18px; font-weight: 600; } .name-row span { font-size: 13px; color: #999; }这里值得注意的是align-items: baseline,它让大字号的名字和小字号的昵称按文字基线对齐,而不是垂直居中,视觉上更协调。如果希望系统支持“点击卡片翻转看寄语”,flex-direction: column的卡片结构更方便在背面再放一套内容。
3.3 JavaScript 渲染:创建元素、追加节点、空状态处理
现在到了核心渲染逻辑。main.js的任务是:读取wuliangxiaoClassMates数组,生成一组.member-card卡片,挂载到#membersGrid下面。手写 DOM 创建虽然啰嗦,但在这个场景下可读性最好,不依赖框架也能看清每一步:
// main.js const grid = document.getElementById("membersGrid"); const searchInput = document.getElementById("searchInput"); const groupFilter = document.getElementById("groupFilter"); function renderMembers(list) { grid.innerHTML = ""; // 清空旧内容 if (list.length === 0) { grid.innerHTML = `<p class="empty-tip">没有找到匹配的同学,换个关键词试试吧。</p>`; return; } const fragment = document.createDocumentFragment(); list.forEach((item) => { const card = document.createElement("article"); card.className = "member-card"; card.dataset.id = item.id; const avatar = document.createElement("img"); avatar.className = "avatar"; avatar.src = getSafePhoto(item); avatar.alt = `${item.name} 的头像`; const nameRow = document.createElement("div"); nameRow.className = "name-row"; const name = document.createElement("h3"); name.textContent = item.name; const nickname = document.createElement("span"); nickname.textContent = item.nickname ? `(${item.nickname})` : ""; nameRow.appendChild(name); nameRow.appendChild(nickname); const motto = document.createElement("p"); motto.textContent = item.motto || "这个人很懒,什么都没留下"; card.appendChild(avatar); card.appendChild(nameRow); card.appendChild(motto); fragment.appendChild(card); }); grid.appendChild(fragment); } // 首次渲染 renderMembers(wuliangxiaoClassMates);逻辑说明:grid.innerHTML = ""是每次渲染前清理旧数据,避免重复追加;空列表时直接输出提示文案,这比让页面留一大块空白要友好。使用document.createDocumentFragment()是一个性能习惯,它把多个 DOM 节点先在内存中拼好,再一次性挂到页面上,减少浏览器反复重排。card.dataset.id = item.id会把 id 存在>// main.js 下半部分 function filterMembers() { const keyword = searchInput.value.trim().toLowerCase(); const group = groupFilter.value; const filtered = wuliangxiaoClassMates.filter((item) => { const matchGroup = group === "" || item.group === group; const matchKeyword = keyword === "" || item.name.toLowerCase().includes(keyword) || (item.nickname && item.nickname.toLowerCase().includes(keyword)); return matchGroup && matchKeyword; }); renderMembers(filtered); } searchInput.addEventListener("input", filterMembers); groupFilter.addEventListener("change", filterMembers);
这里用到了Array.prototype.filter,返回一个新数组,不改动原始数据。toLowerCase()把关键词和姓名都转成小写,配合includes()实现“不区分大小写”的模糊匹配。搜索字段只允许空字符串或正常字符串,如果用户在搜索框里输入了特殊字符,用includes也会把它当普通文本处理,不会像正则那样误报语法错误。如果想增加“按城市”“按星座”过滤,只需要在data.js里加字段,并在这个函数里增加一个select条件即可。注意,每次过滤后一定要调用renderMembers(filtered),否则列表不会刷新。
还要考虑一个边界:当用户删掉搜索词时,input事件也会触发,filterMembers会重新执行,所以不需要单独绑定“清空按钮”。如果你希望支持“拼音首字母搜索”,那要另外维护一个拼音映射表,超出本文范围,但基本思路还是filter。
4. 毕业照墙与交互:照片展示、翻转效果、涟漪光圈
4.1 照片墙布局:CSS 弹性与网格的取舍
毕业照墙和前面的成员卡片不同,它更强调“拼贴感”和“氛围感”。如果全部用规则的正方形网格,视觉会显得死板;但完全手写定位又很难维护。常见做法是保留 CSS Grid 做基础卡片,再针对几张重点照片设置跨列或跨行,形成呼应。下面这段 CSS 实现了 3 列的照片墙:
.photo-wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 20px; } .photo-wall .photo-item { position: relative; overflow: hidden; border-radius: 12px; aspect-ratio: 1 / 1; /* 宽高比 1:1 */ } .photo-wall .photo-item img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease; } .photo-wall .photo-item.wide { grid-column: span 2; /* 横跨两列 */ }aspect-ratio: 1 / 1是控制卡片高度的现代方式,比“padding-top 百分比”的老写法更直观。.wide类可以让某张班级合影占据两列宽度,增加视觉节奏。object-fit: cover在这里再次被调用,确保图片填满区域的同时不拉伸变形。如果你想做“不等高瀑布流”,CSS Grid 的grid-auto-rows: 10px配合grid-row: span 20也能实现,但需要根据图片比例手动控制 span 的值,维护成本略高。针对同学录这种内容相对统一的场景,我建议先用等比网格,后期再调整。
“图片和文字一行”在照片墙里同样常见,比如在照片底部覆盖一行姓名和拍摄日期。可以用绝对定位加半透明渐变底:
.photo-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px 12px 8px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); color: #fff; display: flex; justify-content: space-between; align-items: center; font-size: 14px; }4.2 点击翻卡看到寄语:CSS 3D 变换与事件委托
前面成员卡片展示的是姓名和口头禅,毕业寄语放在第二面会更有仪式感。翻卡效果本质上是一个 CSStransform动画:容器正面和背面叠在一起,鼠标点击时整体旋转 180 度。核心 CSS 如下:
.member-card { perspective: 1000px; } .card-inner { position: relative; width: 100%; height: 100%; transition: transform 0.6s ease; transform-style: preserve-3d; } .member-card.flipped .card-inner { transform: rotateY(180deg); } .card-front, .card-back { position: absolute; top: 0; left: 0; width: 100%; height: 100%; backface-visibility: hidden; } .card-back { transform: rotateY(180deg); display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; }注意隐藏的坑:.card-front和.card-back必须同时设置backface-visibility: hidden,否则翻过去之后会看到镜像内容。.card-inner的height: 100%需要父级.member-card有明确高度,所以卡片里不要随意用flex: 1撑高不确定的元素。如果没有设置高度,翻转时前后两面高度不一致,会出现诡异的错位。
JavaScript 端用事件委托绑定,而不是给每个卡片单独加监听器。因为同学录同学数量可能几十上百,逐个绑定会让代码冗余且不利于动态渲染出来的新卡片:
// main.js 事件委托 document.querySelector(".members-grid").addEventListener("click", (event) => { const card = event.target.closest(".member-card"); if (!card) return; card.classList.toggle("flipped"); });这里event.target.closest(".member-card")能从用户点击的图片或文字节点一路向上找到卡片元素,哪怕点击的是<img>也可以触发。classList.toggle负责切换flipped类,再次点击会翻回正面。这里的事件委托只绑定了一次,后续即使重新渲染整个网格,也不用重新绑定,这是一个非常典型的“动态内容事件处理”做法。
4.3 滚动入场与涟漪光圈:用 IntersectionObserver 实现仪式感
让照片在滚动时淡入,是最能提升纪念册质感的动效之一。不要用傻等几秒的定时器,直接用IntersectionObserver观察卡片是否进入视口,进入后添加一个类,触发 CSS 过渡:
// 滚动入场 const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add("in-view"); observer.unobserve(entry.target); // 只触发一次 } }); }, { threshold: 0.2 }); document.querySelectorAll(".member-card").forEach((card) => { observer.observe(card); });对应的 CSS:
.member-card { opacity: 0; transform: translateY(24px); transition: opacity 0.6s ease, transform 0.6s ease; } .member-card.in-view { opacity: 1; transform: translateY(0); }这里threshold: 0.2表示元素有 20% 进入视口时触发,数值越大,滚动越久才出现动画。observer.unobserve(card)是性能关键,让这个卡片只入场一次,避免用户反复上下滚动时元素闪烁。如果你想让动画有错落感,可以在渲染时给每个卡片设置transition-delay,但注意不要太大,超过 0.3 秒会让人等得不耐烦。
“涟漪光圈扩散”的热词可以落地成点击头像后的选中反馈。思路是在点击位置生成一个圆形 span,然后通过 CSS 动画把它的scale从 0 放大到 3,同时逐渐透明:
.ripple { position: absolute; border-radius: 50%; background: rgba(255, 255, 255, 0.5); width: 20px; height: 20px; transform: scale(0); animation: ripple 0.5s ease-out forwards; pointer-events: none; } @keyframes ripple { to { transform: scale(3); opacity: 0; } }在点击事件里获取坐标并插入 span,动画结束后移除即可:
grid.addEventListener("click", (event) => { const rect = grid.getBoundingClientRect(); const ripple = document.createElement("span"); ripple.className = "ripple"; ripple.style.left = `${event.clientX - rect.left - 10}px`; ripple.style.top = `${event.clientY - rect.top - 10}px`; grid.appendChild(ripple); setTimeout(() => ripple.remove(), 500); });这段代码中,- 10是为了让半径 10px 的圆中心对准点击点。pointer-events: none是必须的,否则涟漪会挡住卡片点击事件。setTimeout移除节点是为了避免内存里堆积无用的 span。
5. 避坑与排查:路径、编码、DOM 加载、移动端适配与图片变形
5.1 首页能打开,但照片全部裂开
现象:本地双击index.html,文字和数据都在,但所有人照片区域都显示一张破碎图片,控制台出现很多 404。
原因:最常见的是图片路径写成了绝对路径,例如C:\Users\me\Desktop\wuliangxiao\images\xxx.jpg或/Users/me/...。这种路径在你自己电脑上能打开,但换一台机器、挪动文件夹位置后,浏览器找不到文件。另一个原因是没有把images文件夹跟index.html放在同一层,导致相对路径失效。
解决:统一使用相对路径,并且index.html与images目录平级。例如页面在wuliangxiao/index.html,照片放在wuliangxiao/images/members/zhangwei.jpg,那么在data.js里写images/members/zhangwei.jpg。检查路径时,直接在浏览器地址栏访问http://localhost/wuliangxiao/images/members/zhangwei.jpg看是否能出现图片。如果文件放在子目录里,记得多加一层../。
5.2 中文文字变成乱码或问号
现象:浏览器打开页面,标题和同学姓名全部变成æµè¯或???,但同一个文件用编辑器打开是正常的。
原因:index.html没有声明字符集,或者编辑器默认保存成了 GBK 而不是 UTF-8。HTML5 规范要求先解析meta charset才能正确解码后续文本。一旦编码不一致,页面就按错误的方式渲染中文。
解决:在<head>第一行写<meta charset="utf-8">,并确保编辑器保存时选择“UTF-8 无 BOM”。同时data.js也要以 UTF-8 保存,因为main.js读取的是同一份数据。如果你用的是 Windows 记事本,建议换用 VS Code 或 Notepad++,它们保存为 UTF-8 更可控。一个自测方法:把页面里的中文复制到剪贴板再粘到文档里,如果乱码就说明编码没对齐。
5.3 页面空白,控制台报 Cannot read properties of null
现象:双击打开页面后,整个 body 部分什么都没有,按 F12 打开控制台,能看到类似Cannot read properties of null (reading 'appendChild')的红色报错。
原因:JavaScript 在 HTML 还没解析完时就执行了。如果<script>放在<head>中且没有defer属性,浏览器会立刻执行脚本,此时#membersGrid还没有创建,getElementById返回null。另一个常见原因是在renderMembers里把网格 ID 写错,比如 HTML 里是membersGrid,JS 里写成members-grid。
解决:把<script>放到</body>前面,或者给 script 加defer。如果你把main.js写在<head>里,改成:
<script src="data.js" defer></script> <script src="main.js" defer></script>同时检查main.js里的getElementById字符串与 HTML 中id属性完全一致。实践中最快定位方法是:在脚本第一行console.log(document.getElementById("membersGrid")),输出null就说明 DOM 没跟上。
5.4 手机上布局错乱,字体忽大忽小
现象:电脑端一切正常,手机浏览器打开后卡片挤到屏幕边缘,页面很宽可以左右滑动,字体也大小不一。
原因:缺少 viewport meta 标签,移动端浏览器会按 980px 的默认视口渲染,再缩小适配手机屏幕。同时,如果 CSS 里用了固定像素宽度,如width: 400px,在小屏上就会溢出。
解决:在 head 中加入<meta name="viewport" content="width=device-width, initial-scale=1.0">,并且布局里尽量使用相对单位。前面用的minmax(220px, 1fr)已经能自动适配。还有另一个容易忽略的问题:图片尺寸过大,比如 5000px 宽的照片直接放进去,移动端下载很慢。建议在images/members里先压缩成 800px 以下的 JPG,既能加快打开速度,也减少手机内存压力。压缩后图片体积通常可以降到 200KB 以内,视觉上几乎没有差别。
5.5 有些照片被拉伸,人脸比例看着不对
现象:同一张卡片里,有的照片正常,有的脸被拉成了椭圆,尤其是竖幅照片。
原因:.avatar或.photo-item img没有设置object-fit: cover,或者容器本身没有固定宽高,图片直接按原始比例撑开了容器。很多人会写width: 120px; height: auto;,这样高度不受控,卡片高度参差不齐;还有人写width: 120px; height: 120px;但不加object-fit,于是图片被强制拉伸填满 120x120,变形就出现了。
解决:在能确定容器宽高的前提下,加一行object-fit: cover:
.avatar { width: 120px; height: 120px; object-fit: cover; }如果希望所有头像都能完整显示而不裁切,则不用固定高度,改用width: 120px; height: auto;,并接受卡片高度不统一。对同学录而言,裁切边缘通常不影响识别,我更推荐cover,它能让整个网格看起来整齐。注意,普通<img>标签的object-fit在浏览器中已经普遍支持,但 IE 不支持,如果班级里还有用老电脑的,可以做一层background-image + background-size: cover兜底。
6. 进阶与交付:让同学录变成可离线分发、可记住浏览位置的一页应用
6.1 用 localStorage 记住最后滚动位置
同学录内容不算短,同学翻到一半关掉页面,下次打开想回到原位置,可以用localStorage记一下。滚动即时保存,并在页面加载后恢复:
window.addEventListener("scroll", () => { localStorage.setItem("wuliangxiao_scroll", window.scrollY); }); window.addEventListener("DOMContentLoaded", () => { const saved = localStorage.getItem("wuliangxiao_scroll"); if (saved) { window.scrollTo(0, parseInt(saved, 10)); } });这里parseInt第二步传 10 是必须的,因为拿到的值是字符串,不指定十进制可能被解析成其他进制。这个功能有一个边界:本地双击打开时,浏览器可能会限制localStorage的访问,所以要先简单判断是否能用。不能用到也没关系,自动降级为“从顶部开始”即可。
6.2 单文件化与离线发送
很多人把做好的同学录发到班级群,直接发一个 zip,但有些同学不会解压。常见做法是把所有 CSS 和 JavaScript 内联到index.html里,再把图片转成 base64,最终形成一个单文件。这样双击一个.html文件就能看,发给微信或邮件都方便。
不过 base64 会让体积增加约 33%,几十张照片会生成非常长的文本。我会只把班级大合照转成 base64,成员头像依旧放在 images 文件夹里。一个折中方案是:保留data.js和images文件夹,在交付说明里写清楚“两个文件不能分开”。如果你有条件,也可以用工具把整个文件夹打成 zip,并在群公告里写清解压后点哪个文件,这比追求单文件更省事。
6.3 部署到公网的最低成本路径
如果希望同学在外地也能通过网址访问,而不只是收压缩包,可以找支持静态托管的平台。常规流程是:将wuliangxiao文件夹推到代码托管仓库,然后开启该仓库的静态 Pages 服务,生成一个公网地址。整个项目没有后端,不需要买服务器。部署前记得检查三件事:图片路径是否相对路径、隐私字段是否已脱敏、index.html是否已经声明 UTF-8。还有一个容易被忽略的点:静态托管平台通常只把仓库根目录或指定目录作为站点根,如果index.html在子目录,要在设置里切换。
我去年给一个毕业班做纪念册时,就是因为在data.js里写了一个网络图片地址,结果平台环境访问那个域名时被拦截,整个头像区域一片空白。我当时的第一反应是改了图片链接,但很快意识到这些“外链”根本不受我控制。后来我把所有照片下载到本地,重新压缩、统一命名,再部署,问题才彻底消失。从那以后,我对同学录项目只坚持一条原则:能不放外链就不放外链,所有资源能进本地文件夹就绝不远程引用。这样无论你把它发给谁、部署到哪个平台,行为都可预测。希望这份踩坑经验能帮你在做 wuliangxiao 同学录时少折腾几次,一次把静态页面做扎实。
本文还有配套的精品资源,点击获取