2026秋软件工程结对作业(第二次):校园失物招领 WEB 程序实现
| 项目 | 内容 |
|---|---|
| 课程 | H202601软件工程与软件工程实践 |
| 作业要求 | 2026秋软件工程结对作业(第二次之程序实现) |
| 结对成员 | 102401231 赵紫龙、102401228 林彦翔 |
| 队友的博客 | 林彦翔(kk咸鱼) |
| 本次博客链接 | 赵紫龙 |
| GitHub 仓库 | https://github.com/zzl3141/102401231-102401228 |
| 在线预览 | https://zzl3141.github.io/102401231-102401228/ |
| 上一次作业 | 校园失物招领小程序需求分析与原型设计 |
一、这次做了什么
上一次我们站在产品经理的角度,把「校园失物招领」的首页、发布、搜索、详情和「我的发布」画了出来。这一次要站到程序员的角度,把这些页面真正做出来。
最终的成品是一个校园失物招领网页,跑起来只要双击一个 index.html:
- 发布寻物和招领两类信息,寻物填「丢失时间 / 丢失地点」,招领自动变成「拾取时间 / 拾取地点」
- 首页集中展示,可以按类型(全部 / 寻物 / 招领)、类别、地点筛选
- 按物品名称、描述、地点做关键词搜索
- 详情页看到完整信息和发布者联系方式,一键复制
- 东西找回或归还后,发布者本人把信息标记为「已找到 / 已归还」,标错了能撤销
- 另外写了 81 个单元测试用例
二、技术选型:为什么选了 WEB
作业允许 WEB 或 APP 二选一。我们选了 WEB,形态是「移动优先的响应式页面」:默认按手机宽度排版,在电脑上居中显示成一条 430px 的画布,用 Chrome 打开看到的仍然是上一版原型里的手机界面。
理由有三条:
- 单元测试的学习成本差很多。 作业附录推荐的是 JavaScript 的测试教程,JS 这边有成熟而且轻量的方案;换成 APP,就要额外学 Android 的测试框架,而两个人只有一周。
- 附加特点几乎零成本。 筛选、复制、推荐这些加分项在纯前端都是几行代码的事。
- 选 APP 的唯一理由是原型是手机界面,但这一点用响应式布局就能解决,不必为此多背一份打包和真机调试的风险。
顺便说一个反过来的决定:一开始我们想把 JS 拆成 ES Module 分文件写,后来放弃了。原因是助教是双击打开
index.html的,走的是file://协议,ES Module 会被浏览器的同源策略拦下,页面直接白屏。改成普通<script>标签加全局命名空间之后,双击就能跑。这件事写在第十节的困难里。
三、分工
| 成员 | 负责内容 | 对应文件 |
|---|---|---|
| 102401231 赵紫龙 | 技术选型、工程骨架、数据层、首页、搜索页、详情页、发布/编辑表单、我的发布与状态闭环、附加特点、单元测试 | index.html、css/、js/ 全部、tests/ |
| 102401228 林彦翔 | 「我的发布」的删除信息功能(含二次确认);移动端视觉细节与无障碍改进(触摸按压反馈、无图卡片占位、减弱动效适配、全面屏安全区、键盘焦点圈) | js/ui-mine.js、css/components.css |
协作方式:赵紫龙建仓库并在 main 上按功能分阶段提交;林彦翔 fork 之后在自己的分支上开发,有进展就提 Pull Request,赵紫龙 review 后合并。整个过程一共提了 2 个 PR(详见第九节)。
两个人同一个宿舍,讨论基本是面对面完成的。大体上是一个人写、另一个人现场看,写完一段就换角色重看一遍。
四、PSP 表格
| PSP2.1 | 阶段 | 预估耗时 / min | 赵紫龙实际 / min | 林彦翔实际 / min |
|---|---|---|---|---|
| Planning | 计划 | 30 | 40 | 20 |
| Estimate | 估计这个任务需要多少时间 | 30 | 25 | 10 |
| Development | 开发 | — | — | — |
| Analysis | 需求分析(包括学习新技术) | 60 | 90 | 40 |
| Design Spec | 生成设计文档 | 40 | 35 | 10 |
| Design Review | 设计复审 | 20 | 25 | 15 |
| Coding Standard | 代码规范 | 15 | 15 | 10 |
| Design | 具体设计 | 50 | 55 | 30 |
| Coding | 具体编码 | 420 | 460 | 150 |
| Code Review | 代码复审 | 40 | 50 | 20 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 150 | 60 |
| Reporting | 报告 | 30 | 35 | 40 |
| Test Report | 测试报告 | 30 | 35 | 20 |
| Size Measurement | 计算工作量 | 15 | 15 | 10 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 30 | 25 |
| 合计 | 870 | 1060 | 460 |
预估与实际差异说明
预估合计 870 分钟,赵紫龙实际 1060 分钟,林彦翔实际 460 分钟。我这边每一项都超过了预估,最集中的三处是:
- 需求分析(含学习新技术)60 → 90:查清「
file://协议下为什么不能用 ES Module」花掉的时间比预想多,还要再定出替代方案(普通<script>标签 + 全局命名空间)。 - 具体编码 420 → 460:附加特点里的「相关推荐」改了两次规则(从「得分 > 0」提高到「得分 ≥ 2」),首页筛选状态也返工过一次。
- 测试 120 → 150:用例从最初几十个补到 81 个,而且有两个 bug 是用例跑出来才发现、修完还要重跑。
林彦翔那边合计 460 分钟,比预估少,主要原因是分工:他 fork 之后主要做「删除信息」和「移动端视觉与无障碍」两个方向的增量,所以「具体编码」只用了约 150 分钟。他的超时项是「需求分析」(要先把我已经打通的闭环读懂才能动手)和「报告」(个人总结部分反复改措辞)。
这次最大的经验是:「学习一个新技术点」和「需要反复调规则」的工作要单独估,混在「需求分析」「具体编码」这些大项里就一定会低估。下次我会把这些拆成独立条目再估。
五、解题思路与设计实现说明
5.1 整体思路
核心流程就是作业里给定的那一句:发布信息 → 浏览 / 搜索 → 查看详情 → 联系发布者 → 更新状态。
我们把它拆成四层,一层只管一件事:
| 层 | 文件 | 职责 | 为什么要单独一层 |
|---|---|---|---|
| 数据层 | js/model.js |
校验、筛选、排序、状态流转、序列化容错 | 全是纯函数,不碰 DOM,单元测试测的就是这一层 |
| 存取层 | js/store.js |
读写 localStorage、提供示例数据、异步读取外壳 | UI 不直接碰存储,以后换后台只改这里 |
| 路由层 | js/router.js、js/app.js |
hash 路由、底部导航、统一接管点击 | 单页应用里页面切换的唯一入口 |
| 展示层 | js/ui-*.js、css/ |
五个页面的渲染与交互 | 每个页面一个文件,两个人改不同文件不容易冲突 |
这么分最直接的好处是:能测的东西和不能测的东西分开了。 校验规则、筛选逻辑、状态流转全部在 model.js 里,可以脱离浏览器直接用 Node 跑测试;页面里只剩下「把数据画出来」。
5.2 总体流程图
5.3 数据流图
这张图想说明的是一次操作里数据在哪儿走:用户只跟页面打交道,页面只跟存取层打交道,所有规则判断都在数据层完成,最后只有存取层认识 localStorage。
5.4 状态闭环:联系之后这条信息怎么结束
老师上次讲评里专门点了一句:「联系之后如何结束这条信息,没有清楚地展开。既然已经设计了『已找到』『已归还』,就应该让人看明白:谁来修改?从哪个入口修改?修改后其他用户在哪里看到结果?」 这次我们把这三个问号全部变成了明确规则。
这张图画的是状态流转,写成流程图是为了保证在博客园的 mermaid 里一定能渲染出来。
| 问题 | 我们的答案 |
|---|---|
| 谁改? | 只有发布者本人。发布时把这条信息的 id 记进本机记录,只有记录里的条目才会渲染出标记按钮,别人的页面上根本没有这个按钮 |
| 从哪改? | 两个入口,改的是同一条数据:① 底部「我的」→ 我的发布 → 卡片上的「标记为已找到 / 已归还」;② 自己那条信息的详情页底部主按钮 |
| 防误触? | 标记前必须经过一次二次确认,弹窗里写清后果。标错了可以在我的发布里撤销 |
| 别人在哪看到? | ① 首页卡片:状态徽章从「寻找中 / 待认领」变成「已找到 / 已归还」,卡片整体变灰;② 首页默认只显示进行中的,切到「已结束」才看得到;③ 详情页:顶部出现状态横幅,联系方式区域变成「该信息已结束,不再展示联系方式」,底部按钮不可点;④ 搜索结果默认不再包含它 |
| 两个人发了同一样东西的两条信息怎么办? | 各管各的:拾获者结束自己的招领,失主结束自己的寻物。两条信息之间不做自动关联匹配,那是另一个量级的功能,本次不做 |
5.5 页面与路由
整个项目只有一个 index.html,五个页面靠 hash 路由切换。选单页而不是多页面的原因还是那个:file:// 协议下 hash 路由不涉及任何跨文件请求,最不容易出问题。
5.6 关键代码片段与解释
片段一:组合筛选,把「默认只看进行中」这条规则收在一处
function filterItems(items, query) {
query = query || {};
var status = query.status || STATUS_ACTIVE; // 默认只看进行中的
var type = (query.type && query.type !== 'all') ? query.type : '';
var category = (query.category && query.category !== 'all') ? query.category : '';
var place = trim(query.place).toLowerCase();
var out = [];
(items || []).forEach(function (it) {
if (!it) return;
if (type && it.type !== type) return;
if (category && it.category !== category) return;
if (place && String(it.place || '').toLowerCase().indexOf(place) < 0) return;
if (status !== 'all' && it.status !== status) return;
if (!matchKeyword(it, query.keyword)) return;
out.push(it);
});
return sortItems(out);
}
这是全项目被调用最多的函数——首页、搜索页、我的发布都走它。三个值得说的点:
- 「默认只看进行中的」写在了函数里,而不是散在各个页面。 状态闭环里「已结束的不再打扰用户」这条规则只在这一处生效,几个页面自动保持一致;想改口径只改这一行。
- 筛选条件用层层早退(
return)而不是嵌套if。 以后要加一个新的筛选维度,只需要加一行,不用动已有的判断。 place用子串匹配。 地点是用户手填的文本,写「图书馆」要能匹配到「图书馆二楼阅览室」。
片段二:状态流转用「返回新对象」,不改原对象
function markClosed(item, now) {
if (!item) return item;
if (item.status === STATUS_CLOSED) return item; // 已经结束的不重复标记
var copy = shallow(item);
copy.status = STATUS_CLOSED;
copy.closedReason = CLOSED_REASON[item.type] || '已结束';
copy.closedAt = now || Date.now();
return copy;
}
结束原因不是让用户选的,而是由信息类型推出来的:寻物的结束原因是「已找到」,招领的结束原因是「已归还」。这样表单里少一个下拉框,用户也不可能把「寻物」标成「已归还」这种自相矛盾的状态。
另外这里是返回一个新对象、而不是直接改传进来的 item。好处有两个:一是函数没有副作用,测试时可以把「调用前」和「调用后」放在一起断言(我们真的这么测了);二是万一以后要加「撤销上一步」,不用重写数据层。
片段三:给同步的 localStorage 加一层异步外壳
var LOAD_DELAY = 220;
function fetchItems(queryObj, onDone) {
var q = {};
for (var k in (queryObj || {})) {
if (Object.prototype.hasOwnProperty.call(queryObj, k)) q[k] = queryObj[k];
}
var forced = q.__fail === true;
delete q.__fail;
root.setTimeout(function () {
if (forced) {
onDone({ ok: false, error: '网络开了个小差(走查演示)' });
return;
}
try {
onDone({ ok: true, items: M.filterItems(readItems(), q) });
} catch (e) {
onDone({ ok: false, error: (e && e.message) || '读取失败' });
}
}, LOAD_DELAY);
}
这段看起来有点多余——数据就在 localStorage 里,同步读一下就有了,为什么要套个 setTimeout?
因为我们想解决一个很容易糊弄过去的问题:设计稿上画了「加载中」和「加载失败」,但代码里根本没有这两个状态。 如果同步读完直接渲染,骨架屏只会闪一帧甚至看不到,「加载失败」更是永远不可能出现,走查的时候只能靠嘴说「这里应该有」。
加了这层外壳之后:
- 页面先渲染骨架屏,数据到了再替换,「加载中」是真实存在的一帧
- 读取过程包在
try / catch里,出错就走失败态,页面上有「重新加载」按钮 - 路由上带
?fail=1可以强制让这次读取失败,走查和演示时能真的看到失败界面长什么样
而且这一层也是以后换真实后台的唯一入口:把 setTimeout 换成 fetch,五个页面一行都不用改。
片段四:发布表单的防重复提交
function two(n) { return n < 10 ? '0' + n : '' + n; }
function submit() {
if (submitting) return; // 双保险:状态位 + 按钮禁用
var form = collect();
var res = M.validateItem(form);
errors = {};
res.errors.forEach(function (e) {
if (!errors[e.field]) errors[e.field] = e.message;
});
if (!res.ok) { showErrors(); return; } // 校验不过就停在原地,已填内容不丢
submitting = true;
var btn = document.querySelector('[data-act="submit"]');
if (btn) {
btn.disabled = true;
btn.classList.add('is-loading');
btn.textContent = editingId ? '保存中…' : '发布中…';
}
root.setTimeout(function () { /* 真正写入数据,成功后跳到详情页 */ }, S.LOAD_DELAY);
}
这里有一个细节值得单独说:校验规则一行都没写在页面里,全部来自 M.validateItem(form)。页面只负责把返回的 errors 数组按字段名标红。这样做的好处是,单元测试测的就是真正在跑的那份规则——不会出现「测试里校验是这套、页面上是另一套」的情况。
「防重复提交」用了两层:一个 submitting 状态位挡住重复调用,按钮同时禁用并改成「发布中…」给用户反馈。只做其中一层都不够——只禁按钮,用户按回车还是能提交第二次。
5.7 开发过程中的自查记录
下面是我们在开发过程中逐条走查的记录。
| 编号 | 操作 | 预期 | 实际结果 |
|---|---|---|---|
| W1 | 首页默认 | 只显示进行中的信息 | 5 条进行中,卡片正常渲染 |
| W2 | 切「寻物」/「招领」 | 分别只显示对应类型 | 2 条 / 3 条 |
| W3 | 切类别「证件」 | 只剩证件类 | 1 条 |
| W4 | 切「已结束」 | 显示已结束的信息 | 1 条,卡片变灰 |
| W5 | 搜索「雨伞」「钥匙」 | 各命中 1 条 | 符合 |
| W6 | 搜索不存在的词 | 空状态 + 返回首页入口 | 符合 |
| W7 | 搜索框只输空格 | 回到初始态,不能把全部信息列出来 | 符合 |
| W8 | 空表单直接提交 | 逐项标红且保留已填内容 | 5 条错误(日期默认今天,所以不报) |
| W9 | 连点两次发布 | 只生成一条 | 第二次点击被挡 |
| W10 | 标记「已找到」 | 二次确认后列表与详情同步变化 | 符合 |
| W11 | 撤销标记 | 恢复进行中并回到列表 | 符合 |
| W12 | 打开别人的信息 | 不出现任何标记按钮 | 符合 |
| W13 | 刷新页面 | 数据仍在 | 符合 |
| W14 | 强制加载失败 ?fail=1 |
出现失败态与「重新加载」 | 点重试后恢复正常 |
| W15 | 删除自己发布的信息(林彦翔加的功能) | 二次确认后从列表里消失 | 2 条 → 1 条,确认文案写清了「无法恢复」 |
六、附加特点设计与展示
6.1 一共做了六个
| # | 附加特点 | 解决什么问题 |
|---|---|---|
| 1 | 按物品类别筛选 | 丢的是钥匙但记不清在哪丢的,一个个翻太慢 |
| 2 | 一键复制联系方式 | 手动抄微信号容易抄错,尤其是手机号 |
| 3 | 按地点筛选 | 「我就是在图书馆丢的」,按区域缩范围最快 |
| 4 | 最近搜索 | 反复搜同一个词不用重复输入 |
| 5 | 相关推荐 | 光靠搜索容易漏:丢东西的人不一定会去搜「招领」,捡到东西的人也不知道失主在找什么 |
| 6 | 删除自己发布的信息 | 发错了、或者同一样东西发重复了,得能撤掉(这个特点是林彦翔加的) |
前五个是赵紫龙做的,第六个是林彦翔在 PR #1 里加的。删除是破坏性操作,所以它必须走二次确认,弹窗里写清「删除后这条信息会从列表和我的发布里消失,无法恢复」,避免手滑。
6.2 创意独到之处与意义
前四个是把已有操作变快,真正有点想法的是第五个相关推荐。
校园失物招领最根本的痛点是「信息对不上」:丢的人发了一条寻物,捡到的人发了一条招领,两条信息其实说的是同一件东西,但两个人都在等对方来搜自己的关键词。如果两边起的名不一样——一个写「蓝牙耳机」、一个写「无线耳机」——纯关键词搜索就永远碰不上。
所以我们在详情页做了一件事:主动把可能对应的另一类信息推到你面前。丢东西的人打开自己的寻物信息,下面会列出可能相关的招领;反过来也一样。这样即使两个人起的名字不完全一样,也有机会碰上。
意义在于:它把「靠用户自己搜到对方」变成了「系统主动提示对不上的两边」,正好落在老师说的「集中浏览、减少重复询问和无效联系」这个目标上。
6.3 实现思路与关键代码
推荐不能乱推,推错了比不推更烦人,所以我们设计了一个简单的打分规则:
| 条件 | 加分 | 说明 |
|---|---|---|
| 物品名称有共同词 | +3 | 用 2 字片段做比对,能认出「蓝牙耳机」和「无线耳机」都有「耳机」 |
| 类别相同 | +2 | 都是「电子产品」 |
| 地点有共同词 | +1 | 都在「三区食堂」 |
| 总分 ≥ 2 才算相关 | — | 这条最关键,下面会解释为什么 |
function relatedItems(items, target, limit) {
if (!target) return [];
var scored = [];
(items || []).forEach(function (it) {
if (!it || it.id === target.id) return;
if (it.status === STATUS_CLOSED) return; // 已经结束的不再推荐
if (it.type === target.type) return; // 只看另一类:寻物配招领
var score = 0;
if (it.title && target.title && hasOverlap(it.title, target.title)) score += 3;
if (it.category && it.category === target.category) score += 2;
if (it.place && target.place && hasOverlap(it.place, target.place)) score += 1;
if (score >= 2) scored.push({ item: it, score: score });
});
scored.sort(function (a, b) {
if (b.score !== a.score) return b.score - a.score;
return (b.item.createdAt || 0) - (a.item.createdAt || 0);
});
return scored.slice(0, limit || 3).map(function (x) { return x.item; });
}
「总分 ≥ 2」这条门槛是被真实 bug 逼出来的。 第一版我们写的是「分数大于 0 就推荐」,结果发现:一条「图书馆二楼阅览室」的招领会把「三区食堂二楼」的寻物推出来——因为两段文字都有「二楼」,地点那一项各得 1 分。这种推荐比没有推荐更让人困惑。改成必须拿到 2 分之后,光靠地点沾一点边不算数,至少要有相同的类别或者名称里的共同词,推荐质量才可用。
另外两个细节:已经结束的信息不参与推荐(找回来了就别再打扰人),同类型不互相推荐(寻物只推招领,不然只是把同一类信息换个地方再列一遍)。
6.4 按地点筛选的实现思路
地点标签不是写死的常量,而是从当前数据里推出来的——把校园里常去的区域列成一个候选表,再看哪些区域在现有信息里真的出现过:
function placeTagsOf(items, tags, limit) {
var pool = tags || PLACE_TAGS; // ['一教','二教','三区食堂','图书馆','紫金楼','实验楼','操场']
var used = {};
(items || []).forEach(function (it) {
var p = trim(it && it.place);
if (!p) return;
pool.forEach(function (t) {
if (p.indexOf(t) >= 0) used[t] = true;
});
});
var out = pool.filter(function (t) { return used[t]; });
return limit ? out.slice(0, limit) : out;
}
这样做的直接好处:不会出现「点了以后一条结果都没有」的死标签。数据变了标签也跟着变,用户新发布的「操场看台」也会自动出现在筛选里。
6.5 成果展示

首页多了「类别」和「地点」两行筛选标签,标签会随数据变化。

详情页底部的「可能相关的信息」——这条招领下面推的正是对应的寻物。

搜索页的「最近搜索」,按回车才会记录,最多留 6 条。
七、目录说明与使用说明
7.1 目录是怎么组织的
按「数据 / 存取 / 路由 / 页面」四层分开,同一个东西只放在一个地方:
102401231-102401228/
├─ index.html # 唯一入口,双击即可在浏览器打开
├─ README.md # 目录说明 + 使用说明(就是本节内容)
├─ package.json # 只用于 npm test,跑网页本身不需要它
├─ css/
│ ├─ base.css # 变量、重置、手机宽度画布、顶部栏与底部导航
│ └─ components.css # 卡片、徽章、按钮、表单、空状态、骨架屏
├─ js/
│ ├─ model.js # 纯函数:校验 / 筛选 / 排序 / 状态流转(单元测试对象)
│ ├─ store.js # localStorage 读写 + 内置示例数据 + 异步读取外壳
│ ├─ router.js # hash 路由
│ ├─ ui-common.js # 各页面共用的小件:顶部栏、信息卡片、空状态、二次确认
│ ├─ ui-home.js # 首页
│ ├─ ui-search.js # 搜索页
│ ├─ ui-detail.js # 信息详情页
│ ├─ ui-publish.js # 发布 / 编辑
│ ├─ ui-mine.js # 我的发布
│ └─ app.js # 启动入口:注册路由、同步底部导航、统一接管点击
├─ img/ # 物品图片素材(沿用第一版原型里的图)
└─ tests/
├─ model.test.js # 数据层纯函数的 51 个用例
└─ store.test.js # 存取层与状态流转的 26 个用例
几个约定:
- HTML / CSS / JS 分开,
css/里只放样式,js/里每个文件只负责一件事。 - 页面一个文件,
ui-*.js互不引用,避免两个人改同一个文件时冲突。 - 加载顺序不能改:
model → store → router → ui-common → ui-* → app。index.html里也写了注释说明。 - 页面路由:
#/home、#/search、#/publish、#/detail/:id、#/mine,编辑走#/publish?id=xxx。
7.2 测试人员怎么运行
最快的办法:直接点这个网址:
https://zzl3141.github.io/102401231-102401228/
这是 GitHub Pages 自动部署的同一份代码。作业要求的是「下载所有文件后用谷歌浏览器运行 html 文件」,所以正式测试请走下面的步骤;上面这个网址只是方便老师、助教和同学快速看一眼效果。
本地运行的步骤:
- 从 GitHub 下载或克隆整个仓库到本地;
- 用谷歌浏览器双击打开根目录的
index.html; - 不需要装任何东西:没有 npm install、没有本地服务器、不需要联网。
克隆命令:
git clone https://github.com/zzl3141/102401231-102401228.git
两个给测试人员准备的开关(在地址栏里改路由):
| 想看什么 | 打开这个地址 |
|---|---|
| 恢复成内置示例数据 | index.html#/home?reset=1 |
| 首页加载失败的样子 | index.html#/home?fail=1 |
| 搜索结果加载失败 | index.html#/search?kw=雨伞&fail=1 |
| 详情页加载失败 | index.html#/detail/itm_seed_1?fail=1 |
想跑单元测试的话(可选,需要本机有 Node.js 18 及以上版本):
npm test
也不需要 npm install,原因见下一节。

八、单元测试
8.1 我们用的测试工具,以及是怎么学的
最终用的是 Node.js 自带的 node:test。
作业附录推荐的是 Mocha(廖雪峰和阮一峰的教程举的都是 Mocha 的例子),我们一开始也打算照做。动手装的时候发现一个问题:Mocha 需要 npm install 装依赖,而这个项目从第一天起就定了一个目标——别人下载下来双击 index.html 就能用,不需要装任何东西。为了测试往仓库里塞一个 node_modules,跟这个目标是矛盾的。
于是换成了 Node 18 以上版本内置的测试框架 node:test。它和 Mocha 的写法几乎一样,都是 describe / it / assert 三件套,但零依赖,node --test 或者 npm test 直接就能跑起来。
自己整理的一份简易教程(其实主要是我们踩过的坑):
-
准备:确认
node -v在 18 以上,不需要装任何东西。 -
写用例:用
describe分组、用it写单条用例、用assert断言结果。一组相关的用例放在同一个describe里,名字写清楚在测什么。 -
运行:
node tests/model.test.js。它会逐条打印 ✔ / ✖,最后给出pass和fail的条数。 -
被测代码要能被 require:这是最容易卡住的地方。
model.js在浏览器里是普通<script>,在 Node 里要能被require,所以文件头用了一段 UMD 包装:(function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); // Node 环境:可以被 require } else { root.LFModel = factory(); // 浏览器环境:挂到 window 上 } })(typeof self !== 'undefined' ? self : this, function () { /* ... 真正的代码 ... */ return { validateItem: validateItem /* , ... */ }; }); -
依赖浏览器的代码要多想一步:
store.js用了localStorage,而 Node 里没有这个东西。我们写了一个内存版的假localStorage顶替它(就是一个普通对象加上getItem / setItem),这样存取层也能在 Node 里直接测,不用开浏览器。 -
别只测顺利的情况:这是写完之后最有体会的一点,见 8.3。
8.2 测了哪些函数
一共 81 个用例,分成 15 组:
| 测试文件 | 分组 | 主要测的函数 |
|---|---|---|
tests/model.test.js(51 个) |
表单校验 | validateItem、checkContact |
| 新建条目 | createItem |
|
| 关键词匹配 | matchKeyword、isBlankKeyword |
|
| 组合筛选与排序 | filterItems、sortItems |
|
| 状态流转 | markClosed、reopenItem、isMine、statusLabel |
|
| 展示格式化 | formatDate、relativeTime、contactText、stats |
|
| 序列化与容错 | normalize、deserialize、serialize |
|
| 附加特点 | placeTagsOf、hasOverlap、relatedItems、pushRecent |
|
tests/store.test.js(30 个) |
初始化 | seedIfEmpty、reset |
| 增删查 | create、get、query、placeTags |
|
| 修改与状态 | update、close、reopen |
|
| 删除信息 | remove |
|
| 最近搜索 | recent、addRecent、clearRecent |
|
| 脏数据容错 | 坏 JSON、非法条目 | |
| 异步读取外壳 | fetchItems、fetchItem、fetchMine |
举一个例子,说明我们的用例长什么样:
describe('状态流转 —— markClosed / reopenItem', () => {
it('标记已找到:写入状态、结束原因和结束时间', () => {
const before = item({ id: 'a', type: 'lost' });
const after = M.markClosed(before, 1791158400000);
assert.equal(after.status, 'closed');
assert.equal(after.closedReason, '已找到');
assert.equal(after.closedAt, 1791158400000);
assert.equal(before.status, 'active', '不能改到原对象'); // 关键:确认没有副作用
});
it('已经结束的条目再标记一次:不动 closedAt', () => {
const closed = M.markClosed(item({ id: 'a' }), 111);
const again = M.markClosed(closed, 999);
assert.equal(again.closedAt, 111); // 防重复标记
});
it('撤销标记:恢复进行中并清空结束信息', () => {
const back = M.reopenItem(M.markClosed(item({ id: 'a' }), 111));
assert.equal(back.status, 'active');
assert.equal(back.closedReason, '');
assert.equal(back.closedAt, 0);
});
});
运行结果(完整输出约 130 行,这里摘一段;下面每一条用例的名字、通过状态和耗时都是 npm test 跑出来的真实输出):
$ npm test
▶ validateItem —— 必填项与格式校验
✔ 空表单:8 个必填项全部报错,ok 为 false (0.96ms)
✔ 物品名称只有空格:等同于没填 (2.05ms)
✔ 物品名称过短或过长:给出长度提示 (0.33ms)
✔ 日期晚于今天:拦下来 (0.13ms)
✔ 手机号不是 11 位:拦下来 (0.12ms)
✔ 合法表单:ok 为 true,errors 为空 (0.53ms)
✔ validateItem —— 必填项与格式校验 (6.84ms)
▶ filterItems —— 组合筛选
✔ 默认只返回进行中的信息,且按日期倒序 (0.34ms)
✔ type 筛选 (0.16ms)
✔ place 按子串筛选 (0.09ms)
✔ status=closed 只看已结束,status=all 看全部 (0.11ms)
✔ 查询条件为空、列表为空都不报错 (0.14ms)
✔ filterItems —— 组合筛选 (0.98ms)
▶ 状态流转 —— markClosed / reopenItem / isMine / statusLabel
✔ 标记已找到:写入状态、结束原因和结束时间 (0.45ms)
✔ 已经结束的条目再标记一次:不动 closedAt (0.15ms)
✔ 撤销标记:恢复进行中并清空结束信息 (0.16ms)
✔ 状态流转 —— markClosed / reopenItem / isMine / statusLabel (0.71ms)
……(中间几组省略)
ℹ tests 51 ℹ suites 8 ℹ pass 51 ℹ fail 0
--- tests/store.test.js ---
✔ 初始化与内置示例数据 (4.59ms)
✔ 新增与查询 (1.54ms)
✔ 修改与状态流转 (1.32ms)
✔ 删除信息 (1.06ms)
✔ 最近搜索 (0.84ms)
✔ 脏数据容错 (0.89ms)
✔ 异步读取外壳 (1.16s)
ℹ tests 30 ℹ suites 7 ℹ pass 30 ℹ fail 0
这里没有放终端截图,是因为测试输出的每一行本来就是可以复制的文本:贴成代码块比截图更清楚,助教也能直接对照仓库里的
tests/复现。
8.3 测试数据是怎么构造的,怎么考虑各种情况
第一,先顺着「正常一次」把正向用例写出来,例如一份完整的合法表单应该校验通过:
function validForm(over) {
return Object.assign({
type: 'lost', title: '校园卡', category: '证件', date: '2026-01-01',
place: '一教三楼走廊', desc: '蓝色卡套,卡角有一道折痕,姓名首字母是 L',
contactType: 'wechat', contact: 'zzl_314'
}, over || {});
}
这个 validForm 是整套用例的基础:要测哪个字段的边界,就只覆盖那一个字段,其余字段保持合法。这样一条用例挂掉时,能立刻知道是哪条规则出了问题,而不是十几个规则一起报错。
第二,用白盒的思路逐条找分支。 我们会对着代码看「这里一共有几种走法」:
- 例如
validateItem里,名称那一项有三种走法:空 / 长度不对 / 合法,那就写三条用例。 - 日期那一项因为还要判断格式,多了「格式不对」和「晚于今天」两条。
- 联系方式有四种类型,每种都有各自的格式规则,所以四种都单独测。
第三,专挑「边界」和「不合理输入」。 这部分最有意思,因为它真的抓出了 bug:
| 我们故意输入的东西 | 期望行为 | 结果 |
|---|---|---|
名称只输空格 ' ' |
等同于没填 | 通过 |
| 搜索框只输空格 | 不能把全部信息列出来 | 通过(早期版本会列出全部,已修) |
搜索关键词 %、_ |
按普通字符处理,不能当通配符把全部匹配出来 | 通过 |
localStorage 里存了坏 JSON |
读到空列表,不能崩 | 通过 |
数组里混进 null、纯数字 |
跳过非法项,保留合法的 | 通过 |
| 物品名称只有一个字(「伞」) | 应该能和「雨伞」匹配上 | 失败,已修 |
| 地点都带「二楼」的两条不同信息 | 不该被当成相关推荐 | 失败,已修 |
后面两条是这次测试最实在的收获:
- 单字标题匹配失败:判断两个名称有没有共同词时,我们是把它切成 2 字片段来比对的。可是「伞」只有一个字,切不出片段,就永远匹配不上「雨伞」。补了一条「包含关系」的兜底判断才修好。这种问题肉眼测的时候根本不会想到——谁会拿一个字当物品名呢。
- 相关推荐误报:「图书馆二楼阅览室」和「三区食堂二楼」都含「二楼」,于是被判定成地点相关。后来把门槛从「得分大于 0」提高到「得分 ≥ 2」,光靠地点沾一点边不算数。
第四,想想「将来测试的人会怎么刁难我们」。 老师的原话是「你如何考虑将来测试人员的刁难」,我们的思路分三类:
- 乱输入:空格、超长文本、特殊符号、全是标点的关键词。
- 乱操作:连点两次发布、点开别人的信息想找标记按钮、直接改地址栏里的 id(我们用「这条信息不存在或已被删除」的空状态兜住)、把自己的信息标记完再撤销。
- 乱数据:手动把
localStorage改坏、改成一个不是数组的东西、往数组里塞非法条目。这些都用try / catch加normalize()过滤兜住了,测试里也专门覆盖。
最后说一句我们觉得最值的地方。 写测试之前,我们以为测试就是「证明自己写对了」。写完之后发现它最有价值的时刻恰恰是用例红了的时候——上面那两个 bug 都不是我们盯着代码看出来的,是用例跑出来的。老师上次说「页面设计与实际交互之间还需要更充分的检查」,现在能理解那句话了:光靠人眼走一遍,只能覆盖到「正常的那些情况」。
九、GitHub 签入记录
仓库地址:https://github.com/zzl3141/102401231-102401228(Public,助教可以直接 clone)
协作方式:赵紫龙建仓库,在 main 上按功能分阶段提交;林彦翔 fork 之后在自己的分支上开发,做完提 Pull Request,由赵紫龙 review 再合并。
我们没有把代码攒到最后一次性提交,而是一个功能一次 commit,顺序和博客第五节的实现思路完全对应:
(赵紫龙在 main 上的提交)
01517d2 feat: 实现数据层(校验 / 筛选 / 状态流转 + localStorage 存取)
bf30dd9 chore: 搭建项目骨架(入口、路由、样式、页面占位与图片素材)
96af41c feat: 完成首页列表、分类筛选与加载状态
3920543 feat: 完成搜索页关键词匹配与空状态
fa225ba feat: 完成信息详情页与一键复制联系方式
324af6c feat: 完成发布与编辑表单(校验、防重复提交、图片选填)
dd3cd97 feat: 完成我的发布与状态闭环(标记、二次确认、撤销)
3854f09 fix: 回到首页时重置筛选,避免漏看刚发布的信息
e9940e4 feat: 增加恢复示例数据的走查开关(#/home?reset=1)
e601d52 feat: 完成附加特点(按地点筛选、最近搜索、相关推荐)
64398c5 test: 补充 77 个单元测试用例(node:test,零依赖)
772cf89 docs: README 补充在线预览地址(GitHub Pages)
(林彦翔 fork 后通过 Pull Request 合并进来)
fbf992b feat: 我的发布增加删除信息与二次确认 ← PR #1
944de15 style: 补齐触摸按压反馈与空图片占位 ← PR #2
4861210 style: 详情页安全区适配、提示动效与无障碍细节 ← PR #2
1ce4517 Merge pull request #1
79cff0f Merge pull request #2
(评审 PR 时发现删除功能用到的 store.remove 没有测试覆盖,补上)
f9afeaf test: 补充删除信息的用例
commit message 统一用 类型: 做了什么 的格式(feat / fix / docs / chore / test),目的是让人不用点开代码就知道这次改了什么。其中 3854f09 那条 fix 是一处真实的返工:走查时发现首页筛选状态会残留,导致刚发布的信息看不见,单独作为一条提交记下来。
Review 的时候发现了什么。 两个 PR 都改了 css/components.css,但改的是不同区域(一个加按钮样式、一个加移动端适配),所以没有冲突。真正值得说的是:PR #1 新增的删除功能调用了数据层的 store.remove(),而这个函数之前一直没有测试覆盖——功能能跑,但没人验证过它删完之后「我的发布」里那条 id 会不会残留。补了 4 条用例之后发现逻辑是对的,但这条覆盖是补上才踏实的。



十、遇到的困难与解决方法
困难一:「双击就能跑」这个要求,把技术方案框死了
问题描述:我们原本打算把 JavaScript 拆成几个 ES Module 分文件写,这样每个文件职责清楚。在本地起服务测试时一切正常,但想到作业要求「其他同学下载所有文件后用 Chrome 运行 html 文件就能展现预期结果」,就用双击的方式试了一次——页面全白,控制台报的是模块加载被拦下的错误。
做过哪些尝试:
- 先怀疑是文件路径写错,检查了一遍相对路径,没有问题。
- 查资料后确认:
file://协议下浏览器不允许加载 ES Module,因为模块请求会被当成跨源请求拦掉。这不是代码写错,是协议限制。 - 改用最原始的方案:普通
<script>标签按顺序加载,每个文件把自己的能力挂到一个全局对象上(window.LFModel、window.LFStore…)。 - 顺带做了一个检查:全站不能出现
fetch、XMLHttpRequest和绝对路径。数据干脆内联在store.js里,不去读 JSON 文件。
是否解决:解决了。现在双击 index.html 就能跑,加载顺序在 index.html 里写了注释固定下来。
有何收获:以前觉得「怎么运行」是交付时才需要考虑的事,这次发现运行方式会反过来决定代码怎么组织。需求里那句「用谷歌浏览器运行 html 文件就能展现预期结果」看着像一句客套话,实际上是一条硬约束。
困难二:首页的筛选状态会「粘」住,刚发布的信息看不见
问题描述:走查的时候发现一个很怪的路径——先在首页把筛选切到「已结束」,然后去发布页发一条新信息,再回首页,新信息不见了。看起来像发布失败,其实数据写进去了,只是首页还停在上一次的「已结束」筛选上。
做过哪些尝试:
- 第一反应是发布没写进去,检查了
localStorage,数据在,排除。 - 又想是不是首页没刷新,但首页确实重新渲染了。
- 最后定位到:筛选状态是存在模块变量里的,切换筛选不走路由,所以重新进入首页时状态被保留了下来。
是否解决:解决了。改成每次「进入」首页都回到默认视图(全部 / 全部类别 / 进行中),页面内点筛选仍然保留状态不闪屏;如果地址栏里带了筛选参数,就以参数为准。改完复测:发布后回首页立刻能看到,条数从 6 条变 7 条。
有何收获:状态的「生命周期」和状态本身一样重要。 我们之前只想了「筛选状态要不要记住」,没想过「记多久」。这个 bug 不崩溃、不报错,但会让用户以为功能坏了,比直接报错更难发现。
困难三:两个只有靠测试才能发现的匹配 bug
问题描述:写完单元测试跑第一遍时,两条用例红了,而且都不是我们预期的:
- 物品名称只有一个字「伞」时,匹配不到「雨伞」;
- 相关推荐把不相关的信息推了出来——「图书馆二楼阅览室」和「三区食堂二楼」都被判成了「地点相关」,只因为都含「二楼」。
做过哪些尝试:
- 第一个问题:我们的匹配逻辑是把中文切成 2 字片段来比对的(「雨伞」→
雨、伞两个片段)。但「伞」只有一个字,切不出片段,于是两边永远对不上。加了「包含关系」的兜底判断,一个字的标题也能匹配上更长的名称。 - 第二个问题:算了一下分数,地点那一项只值 1 分。原来的门槛是「得分大于 0 就推荐」,所以光靠地点沾边也能进来。把门槛提到「得分 ≥ 2」,也就是至少要类别相同,或者名称里有共同词。
是否解决:都解决了,两条用例现在都是绿的。
有何收获:这两个 bug 我们盯着代码看了很久都没看出来,是用例跑出来的。尤其是「伞」那一条——现实里几乎没有人会给物品起一个单字的名字,但测试用例不会替你想「这合理吗」,它只会告诉你「这里没走通」。这大概就是老师说的「想想将来测试人员会怎么刁难你」的意思。
困难四:两个人改同一份代码,怎么不互相覆盖
问题描述:这是结对编程本身带来的问题。两个人对着同一份代码改,很容易出现一种情况——一个人刚调好的地方,被另一个人改回去了,而且两个人都不确定是谁改的。
做过哪些尝试:
- 按文件划分归属,而不是按「谁有空谁改」。每个
js/ui-*.js只归一个人,model.js这种所有人都会用到的公共文件由一个人维护,另一个人要用就直接调,不复制一份。 - 在每个文件头写清楚负责人和这个文件要做什么。这样打开文件就知道该不该动它,沟通成本降到最低。
- 要改对方的文件之前先在群里说一声,避免两个人同时在改。
- 每完成一个功能就提交一次,出问题能通过 commit 记录定位到是哪一步引起的。
是否解决:这四条从第一天就定下来写进了协作说明,整个开发过程没有出现互相覆盖的情况。
有何收获:以前觉得「分工」就是分任务,现在觉得分工的一半是分文件。任务分得再清楚,两个人还是可能改到同一行代码;文件归属划清楚之后,冲突从「需要解决的问题」变成了「不会发生的问题」。
十一、评价队友
赵紫龙眼中的林彦翔
值得学习的地方:林彦翔找问题的角度和我很不一样。他 fork 之后没有等我派任务,自己先走了一遍「我的发布」,提出「发错了信息没法撤掉」这个缺口,而且实现时主动加了二次确认——破坏性操作必须先确认,这一点比我细心。他的第二个 PR 更让我意外:他关注的是无图卡片占位、prefers-reduced-motion(系统开了「减弱动态效果」的用户不该看到骨架屏一直流动)、全面屏底部安全区、键盘焦点圈这些无障碍细节。这些我平时不会想到,但它们恰恰是「能用」和「好用」之间的差距。
需要改进的地方:他新增删除功能时调用了数据层的 store.remove(),却没有顺手补对应的单元测试——这个函数之前一直没被覆盖,是我评审 PR 时才发现的。功能本身是对的,但如果每加一个功能就顺手想一下「这一层有没有测试覆盖」,我们就不用等到评审阶段才补。他自己也提到,以前觉得测试是「证明自己写对了」,现在明白它最有价值的时刻是用例红了的时候。
十二、个人总结
赵紫龙
这次和上次画原型最大的不同是:上次只要把界面「画出来」,这一次每个点击背后都要回答「数据从哪来、状态存在哪、点了之后会发生什么」。
我最大的体会是「怎么运行」会反过来决定代码怎么组织。一开始我们打算用 ES Module 分文件写,直到用双击的方式试了一次、页面全白,才意识到助教走的是 file:// 协议,模块请求会被同源策略拦下。改成普通 <script> 标签加全局命名空间之后,项目才真正做到了「下载下来就能跑」。
另一件改变我想法的事是写单元测试。以前我以为测试是「证明自己写对了」,结果两个 bug 是用例跑出来的:「伞」只有一个字、切不出 2 字片段,所以永远匹配不上「雨伞」;「图书馆二楼阅览室」和「三区食堂二楼」都含「二楼」,就被当成地点相关推了出去。这两个问题我盯着代码看了很久都没发现。老师上次说「页面设计与实际交互之间还需要更充分的检查」,现在算是有体会了——人眼走一遍,只能覆盖到「正常的那些情况」。
如果重来一次,我会先和林彦翔一起把数据结构接口和阶段划分定下来,而不是自己一口气把主流程写完。这次我把阶段 5~7 都做掉了,留给他的原创空间小了很多,这一点他在评价里也提到了。下一次结对,我会先停下来,把该留给队友的部分真正留出去。

浙公网安备 33010602011771号