基于HTML/CSS/JS的wuliangxiao同学录与毕业照静态网页实践 简介这是一套基于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: zhangweiexample.com, motto: 少熬夜多写代码, blessing: 祝你以后头发依然茂盛, group: 计算机系 }, { id: 2, name: 李娜, nickname: 娜娜, photo: , // 空字符串代表没照片渲染时用默认头像 major: 视觉传达设计, phone: 139****5678, email: linaexample.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 langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlewuliangxiao 同学录与毕业照/title link relstylesheet hrefstyle.css /head body header classhero h1wuliangxiao 班级纪念册/h1 p classsubtitle那些年我们一起走过的青春/p /header main section classclass-photo-section img classclass-photo srcimages/class-photo.jpg alt班级合照 /section section classmembers-section div classtoolbar input typesearch idsearchInput placeholder输入姓名或昵称搜索 select idgroupFilter option value全部小组/option option value计算机系计算机系/option option value艺术系艺术系/option /select /div div idmembersGrid classmembers-grid !-- JavaScript 渲染出来的卡片会放在这里 -- /div /section /main script srcdata.js/script script srcmain.js/script /body /html这里有几个关键点meta charsetutf-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 classempty-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 charsetutf-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 里是membersGridJS 里写成members-grid。解决把script放到/body前面或者给 script 加defer。如果你把main.js写在head里改成script srcdata.js defer/script script srcmain.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 nameviewport contentwidthdevice-width, initial-scale1.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 同学录时少折腾几次一次把静态页面做扎实。本文还有配套的精品资源点击获取