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 打开看到的仍然是上一版原型里的手机界面。

理由有三条:

  1. 单元测试的学习成本差很多。 作业附录推荐的是 JavaScript 的测试教程,JS 这边有成熟而且轻量的方案;换成 APP,就要额外学 Android 的测试框架,而两个人只有一周。
  2. 附加特点几乎零成本。 筛选、复制、推荐这些加分项在纯前端都是几行代码的事。
  3. 选 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 总体流程图

flowchart TD A[打开页面] --> B[首页: 浏览与筛选] A --> C[搜索页: 输入关键词] B --> D[信息详情] C --> D D --> E{这条是不是你发布的} E -->|不是| F[复制联系方式, 到微信或 QQ 联系] E -->|是| G[标记为已找到或已归还] F --> H[东西找回或归还之后] H --> G G --> I[列表与详情同步更新: 卡片变灰, 不再展示联系方式] A --> J[发布页: 选择寻物或招领并填写] J -->|必填项没填全| J2[逐项标红, 保留已填内容] J2 --> J J -->|校验通过| K[发布成功, 进入详情页]

5.3 数据流图

这张图想说明的是一次操作里数据在哪儿走:用户只跟页面打交道,页面只跟存取层打交道,所有规则判断都在数据层完成,最后只有存取层认识 localStorage。

flowchart LR USER([用户]) -->|填写表单| PUB[发布页 ui-publish] USER -->|点筛选或搜索| HOME[首页 ui-home] USER -->|输入关键词| SEA[搜索页 ui-search] USER -->|点卡片| DET[详情页 ui-detail] USER -->|标记状态或编辑| MINE[我的发布 ui-mine] PUB -->|validateItem 校验| MODEL[model.js 纯函数] HOME -->|fetchItems 查询| STORE[store.js 数据层] SEA -->|fetchItems 查询| STORE MINE -->|fetchMine 查询| STORE DET -->|fetchItem 取单条| STORE STORE -->|调用规则| MODEL MODEL -->|校验结果与筛选排序结果| STORE PUB -->|create 新增| STORE MINE -->|close 标记 / reopen 撤销 / update 编辑| STORE STORE -->|序列化写入| LS[(localStorage)] LS -->|读取并反序列化| STORE STORE -->|列表数据| HOME STORE -->|列表数据| SEA STORE -->|我的发布| MINE STORE -->|单条详情| DET

5.4 状态闭环:联系之后这条信息怎么结束

老师上次讲评里专门点了一句:「联系之后如何结束这条信息,没有清楚地展开。既然已经设计了『已找到』『已归还』,就应该让人看明白:谁来修改?从哪个入口修改?修改后其他用户在哪里看到结果?」 这次我们把这三个问号全部变成了明确规则。

flowchart LR NEW([发布一条信息]) --> ACTIVE[进行中: 寻找中 / 待认领] ACTIVE -->|发布者标记已找到或已归还| CLOSED[已结束: 已找到 / 已归还] CLOSED -->|发布者撤销标记| ACTIVE CLOSED -->|仍然保留在已结束筛选里| HISTORY([作为历史记录])

这张图画的是状态流转,写成流程图是为了保证在博客园的 mermaid 里一定能渲染出来。

问题 我们的答案
谁改? 只有发布者本人。发布时把这条信息的 id 记进本机记录,只有记录里的条目才会渲染出标记按钮,别人的页面上根本没有这个按钮
从哪改? 两个入口,改的是同一条数据:① 底部「我的」→ 我的发布 → 卡片上的「标记为已找到 / 已归还」;② 自己那条信息的详情页底部主按钮
防误触? 标记前必须经过一次二次确认,弹窗里写清后果。标错了可以在我的发布里撤销
别人在哪看到? ① 首页卡片:状态徽章从「寻找中 / 待认领」变成「已找到 / 已归还」,卡片整体变灰;② 首页默认只显示进行中的,切到「已结束」才看得到;③ 详情页:顶部出现状态横幅,联系方式区域变成「该信息已结束,不再展示联系方式」,底部按钮不可点;④ 搜索结果默认不再包含它
两个人发了同一样东西的两条信息怎么办? 各管各的:拾获者结束自己的招领,失主结束自己的寻物。两条信息之间不做自动关联匹配,那是另一个量级的功能,本次不做

5.5 页面与路由

flowchart LR HOME[#/home 首页] -->|点搜索框| SEA[#/search 搜索] HOME -->|点卡片| DET[#/detail/:id 详情] SEA -->|点结果卡片| DET HOME -->|底部导航| PUB[#/publish 发布] HOME -->|底部导航| MINE[#/mine 我的发布] MINE -->|点编辑| PUB2[#/publish?id=xxx 编辑] MINE -->|点卡片| DET DET -->|本人且进行中| MINE

整个项目只有一个 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 文件」,所以正式测试请走下面的步骤;上面这个网址只是方便老师、助教和同学快速看一眼效果。

本地运行的步骤:

  1. 从 GitHub 下载或克隆整个仓库到本地;
  2. 用谷歌浏览器双击打开根目录的 index.html;
  3. 不需要装任何东西:没有 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,原因见下一节。

打开 index.html 后看到的第一个界面


八、单元测试

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 直接就能跑起来。

自己整理的一份简易教程(其实主要是我们踩过的坑):

  1. 准备:确认 node -v 在 18 以上,不需要装任何东西。

  2. 写用例:用 describe 分组、用 it 写单条用例、用 assert 断言结果。一组相关的用例放在同一个 describe 里,名字写清楚在测什么。

  3. 运行:node tests/model.test.js。它会逐条打印 ✔ / ✖,最后给出 pass 和 fail 的条数。

  4. 被测代码要能被 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 /* , ... */ };
    });
    
  5. 依赖浏览器的代码要多想一步:store.js 用了 localStorage,而 Node 里没有这个东西。我们写了一个内存版的假 localStorage 顶替它(就是一个普通对象加上 getItem / setItem),这样存取层也能在 Node 里直接测,不用开浏览器。

  6. 别只测顺利的情况:这是写完之后最有体会的一点,见 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」,光靠地点沾一点边不算数。

第四,想想「将来测试的人会怎么刁难我们」。 老师的原话是「你如何考虑将来测试人员的刁难」,我们的思路分三类:

  1. 乱输入:空格、超长文本、特殊符号、全是标点的关键词。
  2. 乱操作:连点两次发布、点开别人的信息想找标记按钮、直接改地址栏里的 id(我们用「这条信息不存在或已被删除」的空状态兜住)、把自己的信息标记完再撤销。
  3. 乱数据:手动把 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 条用例之后发现逻辑是对的,但这条覆盖是补上才踏实的。

GitHub 仓库首页

提交记录:一个功能一次 commit

Pull requests:两个由 537-7 提交并合并的 PR


十、遇到的困难与解决方法

困难一:「双击就能跑」这个要求,把技术方案框死了

问题描述:我们原本打算把 JavaScript 拆成几个 ES Module 分文件写,这样每个文件职责清楚。在本地起服务测试时一切正常,但想到作业要求「其他同学下载所有文件后用 Chrome 运行 html 文件就能展现预期结果」,就用双击的方式试了一次——页面全白,控制台报的是模块加载被拦下的错误。

做过哪些尝试:

  1. 先怀疑是文件路径写错,检查了一遍相对路径,没有问题。
  2. 查资料后确认:file:// 协议下浏览器不允许加载 ES Module,因为模块请求会被当成跨源请求拦掉。这不是代码写错,是协议限制。
  3. 改用最原始的方案:普通 <script> 标签按顺序加载,每个文件把自己的能力挂到一个全局对象上(window.LFModel、window.LFStore…)。
  4. 顺带做了一个检查:全站不能出现 fetch、XMLHttpRequest 和绝对路径。数据干脆内联在 store.js 里,不去读 JSON 文件。

是否解决:解决了。现在双击 index.html 就能跑,加载顺序在 index.html 里写了注释固定下来。

有何收获:以前觉得「怎么运行」是交付时才需要考虑的事,这次发现运行方式会反过来决定代码怎么组织。需求里那句「用谷歌浏览器运行 html 文件就能展现预期结果」看着像一句客套话,实际上是一条硬约束。

困难二:首页的筛选状态会「粘」住,刚发布的信息看不见

问题描述:走查的时候发现一个很怪的路径——先在首页把筛选切到「已结束」,然后去发布页发一条新信息,再回首页,新信息不见了。看起来像发布失败,其实数据写进去了,只是首页还停在上一次的「已结束」筛选上。

做过哪些尝试:

  1. 第一反应是发布没写进去,检查了 localStorage,数据在,排除。
  2. 又想是不是首页没刷新,但首页确实重新渲染了。
  3. 最后定位到:筛选状态是存在模块变量里的,切换筛选不走路由,所以重新进入首页时状态被保留了下来。

是否解决:解决了。改成每次「进入」首页都回到默认视图(全部 / 全部类别 / 进行中),页面内点筛选仍然保留状态不闪屏;如果地址栏里带了筛选参数,就以参数为准。改完复测:发布后回首页立刻能看到,条数从 6 条变 7 条。

有何收获:状态的「生命周期」和状态本身一样重要。 我们之前只想了「筛选状态要不要记住」,没想过「记多久」。这个 bug 不崩溃、不报错,但会让用户以为功能坏了,比直接报错更难发现。

困难三:两个只有靠测试才能发现的匹配 bug

问题描述:写完单元测试跑第一遍时,两条用例红了,而且都不是我们预期的:

  1. 物品名称只有一个字「伞」时,匹配不到「雨伞」;
  2. 相关推荐把不相关的信息推了出来——「图书馆二楼阅览室」和「三区食堂二楼」都被判成了「地点相关」,只因为都含「二楼」。

做过哪些尝试:

  1. 第一个问题:我们的匹配逻辑是把中文切成 2 字片段来比对的(「雨伞」→ 雨、伞 两个片段)。但「伞」只有一个字,切不出片段,于是两边永远对不上。加了「包含关系」的兜底判断,一个字的标题也能匹配上更长的名称。
  2. 第二个问题:算了一下分数,地点那一项只值 1 分。原来的门槛是「得分大于 0 就推荐」,所以光靠地点沾边也能进来。把门槛提到「得分 ≥ 2」,也就是至少要类别相同,或者名称里有共同词。

是否解决:都解决了,两条用例现在都是绿的。

有何收获:这两个 bug 我们盯着代码看了很久都没看出来,是用例跑出来的。尤其是「伞」那一条——现实里几乎没有人会给物品起一个单字的名字,但测试用例不会替你想「这合理吗」,它只会告诉你「这里没走通」。这大概就是老师说的「想想将来测试人员会怎么刁难你」的意思。

困难四:两个人改同一份代码,怎么不互相覆盖

问题描述:这是结对编程本身带来的问题。两个人对着同一份代码改,很容易出现一种情况——一个人刚调好的地方,被另一个人改回去了,而且两个人都不确定是谁改的。

做过哪些尝试:

  1. 按文件划分归属,而不是按「谁有空谁改」。每个 js/ui-*.js 只归一个人,model.js 这种所有人都会用到的公共文件由一个人维护,另一个人要用就直接调,不复制一份。
  2. 在每个文件头写清楚负责人和这个文件要做什么。这样打开文件就知道该不该动它,沟通成本降到最低。
  3. 要改对方的文件之前先在群里说一声,避免两个人同时在改。
  4. 每完成一个功能就提交一次,出问题能通过 commit 记录定位到是哪一步引起的。

是否解决:这四条从第一天就定下来写进了协作说明,整个开发过程没有出现互相覆盖的情况。

有何收获:以前觉得「分工」就是分任务,现在觉得分工的一半是分文件。任务分得再清楚,两个人还是可能改到同一行代码;文件归属划清楚之后,冲突从「需要解决的问题」变成了「不会发生的问题」。


十一、评价队友

赵紫龙眼中的林彦翔

值得学习的地方:林彦翔找问题的角度和我很不一样。他 fork 之后没有等我派任务,自己先走了一遍「我的发布」,提出「发错了信息没法撤掉」这个缺口,而且实现时主动加了二次确认——破坏性操作必须先确认,这一点比我细心。他的第二个 PR 更让我意外:他关注的是无图卡片占位、prefers-reduced-motion(系统开了「减弱动态效果」的用户不该看到骨架屏一直流动)、全面屏底部安全区、键盘焦点圈这些无障碍细节。这些我平时不会想到,但它们恰恰是「能用」和「好用」之间的差距。

需要改进的地方:他新增删除功能时调用了数据层的 store.remove(),却没有顺手补对应的单元测试——这个函数之前一直没被覆盖,是我评审 PR 时才发现的。功能本身是对的,但如果每加一个功能就顺手想一下「这一层有没有测试覆盖」,我们就不用等到评审阶段才补。他自己也提到,以前觉得测试是「证明自己写对了」,现在明白它最有价值的时刻是用例红了的时候。

十二、个人总结

赵紫龙

这次和上次画原型最大的不同是:上次只要把界面「画出来」,这一次每个点击背后都要回答「数据从哪来、状态存在哪、点了之后会发生什么」。

我最大的体会是「怎么运行」会反过来决定代码怎么组织。一开始我们打算用 ES Module 分文件写,直到用双击的方式试了一次、页面全白,才意识到助教走的是 file:// 协议,模块请求会被同源策略拦下。改成普通 <script> 标签加全局命名空间之后,项目才真正做到了「下载下来就能跑」。

另一件改变我想法的事是写单元测试。以前我以为测试是「证明自己写对了」,结果两个 bug 是用例跑出来的:「伞」只有一个字、切不出 2 字片段,所以永远匹配不上「雨伞」;「图书馆二楼阅览室」和「三区食堂二楼」都含「二楼」,就被当成地点相关推了出去。这两个问题我盯着代码看了很久都没发现。老师上次说「页面设计与实际交互之间还需要更充分的检查」,现在算是有体会了——人眼走一遍,只能覆盖到「正常的那些情况」。

如果重来一次,我会先和林彦翔一起把数据结构接口和阶段划分定下来,而不是自己一口气把主流程写完。这次我把阶段 5~7 都做掉了,留给他的原创空间小了很多,这一点他在评价里也提到了。下一次结对,我会先停下来,把该留给队友的部分真正留出去。

posted @ 2026-10-09 15:29  zzl314  阅读(13)  评论(0)    收藏  举报