2026秋软件工程结对作业(第二次之程序实现)——校园失物招领
2026秋软件工程结对作业(第二次之程序实现)——校园失物招领
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | H202601 软件工程与软件工程实践(福州大学·计算机与大数据学院) |
| 这个作业要求在哪里 | 2026 秋软件工程结对作业(第二次之程序实现) |
| 结对同学博客 | https://www.cnblogs.com/zzzoq/p/23256357 |
| 本作业博客 | https://www.cnblogs.com/otonash1/p/23255748 |
| 结对成员 | 学号 032401135(沈文驰)、学号 052405110(陈佳慧) |
| 项目仓库 | https://github.com/otonash1/032401135-052405110 |
| 呈现形式 | WEB 网页(纯前端,用谷歌浏览器直接打开 index.html 即可运行) |
一、具体分工
| 成员 | 负责内容 |
|---|---|
| 032401135(沈文驰) | 需求梳理与功能边界确认、交互与页面结构设计、index.html / css / 全部 js 模块的编码实现、单元测试用例设计与测试框架编写、README 与目录说明 |
| 052405110(陈佳慧) | 建库后 fork 本仓库,按功能阶段分批把代码签入自己的仓库并逐个发起 Pull Request,合并前在本地用谷歌浏览器逐条走查各页面并核对 README 的操作说明 |
二、PSP 表格
(单位:分钟;为两人合计投入)
| PSP2.1 | Personal Software Process Stages | 预估耗时 | 实际耗时 |
|---|---|---|---|
| Planning | 计划 | 50 | 40 |
| Estimate | 估计这个任务需要多少时间 | 20 | 15 |
| —— | 确认「不做什么」(沿用第一次作业的功能边界) | 30 | 25 |
| Development | 开发 | 890 | 1035 |
| Analysis | 需求分析(包括学习新技术) | 90 | 120 |
| Design Spec | 生成设计文档 | 60 | 45 |
| Design Review | 设计复审 | 30 | 20 |
| Coding Standard | 代码规范(为目前的开发制定合适的规范) | 20 | 15 |
| Design | 具体设计 | 90 | 100 |
| Coding | 具体编码 | 420 | 520 |
| Code Review | 代码复审 | 60 | 50 |
| Test | 测试(自我测试,修改代码,提交修改) | 120 | 165 |
| Reporting | 报告 | 150 | 150 |
| Test Report | 测试报告 | 40 | 35 |
| Size Measurement | 计算工作量 | 20 | 15 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 90 | 100 |
| 合计 | 1090 | 1225 |
三、解题思路描述与设计实现说明
3.1 代码实现思路(文字描述)
这次作业要的是「发布信息 — 浏览或搜索 — 查看详情 — 联系发布者 — 更新状态」这一条主线。动手之前我们先把它拆成了两部分:
- 页面上显示什么、点击之后跳到哪,这部分是界面;
- 一条信息有哪些字段、怎么校验、怎么搜、存到哪里,这部分是逻辑。
按这个分法,界面和逻辑就分别放进了不同的文件。js/utils.js、js/seed.js、js/validate.js、js/search.js、js/store.js 这五个文件里没有一行碰页面的代码,可以单独拿出来跑;只有 js/ui.js 会动 DOM。这么分之后有个直接的好处:那几个纯逻辑的模块可以直接拿来做单元测试,后面那 51 个用例也是因为这个才写得出来。
界面层本身只做了三件事:
- 路由:用一个
state.route记住当前在哪个页面,PAGES是一张「路由 → 渲染函数」的注册表,每个渲染函数只负责返回一段 HTML 字符串。切换页面就是把state.route一改、重新渲染一次。 - 事件代理:所有可点击元素的交互都写在
data-action属性上(例如data-action="detail" data-id="LF20260924001"),在document上挂一个 click 监听统一分发。这样做的好处是页面每次重渲染都不用重新绑定事件,也不会出现「旧节点的事件没解绑」这类问题;同时跳转参数(id、type)通过data-\\\*直接带在元素上,不需要额外的全局变量。 - 单一数据出口:页面永远不直接读写
localStorage,一律走store。store是唯一的出入口,它负责生成信息编号、写入创建时间、把整个列表序列化后持久化。
数据本身只有一种形状——一条信息就是一条记录:
{ id, name, type, cate, place, time, desc, contact, pub, dept, status, mine, createdAt }
列表、搜索、发布、状态修改全都围绕这一条记录展开,没有第二种数据结构,这也让「同一个信息池」这个设计落在了代码上:寻物和招领是同一种记录的两个 type 取值,而不是两张表,所以一次搜索能同时命中两类信息。
3.2 模块架构与数据流

六个脚本按依赖顺序加载,ui.js 是唯一的界面层;validate / search / store 三个核心模块各自只做一件事,并且共同依赖 utils 与 seed;数据的最终落点是 localStorage 里的一个键 lost-found-items-v1。
3.3 关键实现一:发布 / 编辑信息(含校验分支)

这条流程里有两个值得说的设计点:
- 校验不通过时停在原地:
submitPublish()先调validateDraft(),失败就把错误按字段名标回表单(showErrors()给对应.field加上.invalid并写入文案,再滚动到第一个出错字段),不跳转、不清空用户已经填好的内容。这是刻意做的:如果清空重填,用户会直接放弃。 - 发布和编辑共用同一套表单与同一个提交函数:表单页读
state.editId,为空是新建、非空是编辑;提交时只在这一个地方分叉——新建走store.add()进入「发布成功」页,编辑走store.update()后回到「我的发布」。这样避免了两套几乎一样的代码各自长出 bug。
3.4 关键实现二:关键词搜索与筛选

搜索的关键是匹配范围和空结果的语义:
- 关键词在 物品名称 · 类别 · 地点 · 描述 四个字段里做包含匹配(不区分大小写),所以搜「校园卡」能同时搜出寻物和招领,「三区食堂」这种按地点搜也能命中;
- 关键词为空时返回空数组,而不是返回全部。原因在代码注释里写着:搜索页在空关键词下应该展示「热门搜索」,如果把全部信息当成搜索结果列出来,用户会分不清自己到底搜没搜;
- 二次筛选(全部 / 寻物 / 招领 / 已完成)是在搜索结果之上叠加,不是重新搜索,所以切换筛选不会丢掉关键词。
3.5 关键实现三:更新信息状态(已找到 / 已归还)

题面要求「物品找回或归还后,由发布者把信息标记为已找到或已归还」。这里的实现是:只有自己发布的信息(mine 为 true)才会出现在「我的发布」里,每条记录右侧是一个下拉框,选中即改,改完立刻持久化。改成「已完成」之后,这条信息在首页列表和搜索结果里会显示成灰色的「已完成」标签——别人看到的是状态,而不是让发布者去删掉信息,这样既减少了重复询问,也保留了「已归还」的记录。
3.6 关键代码片段与解释
(1)事件代理:一个监听管住所有交互
function handleClick(e) {
const el = e.target.closest ? e.target.closest('\\\[data-action]') : null;
if (!el) return;
const action = el.getAttribute('data-action');
const route = el.getAttribute('data-route');
const type = el.getAttribute('data-type');
if (action === 'go' || action === 'tab') { go(route); return; }
if (action === 'back') { back(); return; }
if (action === 'publish') { go('publish', { type: type }); return; }
if (action === 'detail') { go('detail', { id: el.getAttribute('data-id') }); return; }
// …其余动作同构
}
用它的原因很直接:我们这个项目的页面,每次重渲染都是把 innerHTML 整段换掉的。如果按常规做法给每个按钮单独 addEventListener,那每渲染一次就要重新绑一遍,漏掉一个就会出现「点了没反应」。改成在 document 上统一代理之后,渲染函数就不用管事件了,加一个新交互只要在模板里写一个 data-action。
(2)校验规则集中成数据,而不是散落在 if 里
const RULES = {
name: { label: '物品名称', max: 30 },
place: { label: '地点', max: 40 },
time: { label: '时间', max: 40 },
desc: { label: '补充描述', max: 200, optional: true },
contact: { label: '联系方式', min: 3, max: 40 }
};
把「字段名 → 长度限制 + 是否必填」抽成一张表,好处是提示文案统一由 label 拼出来(「物品名称不能超过 30 个字」),改限制只改一处;而且 label 能直接复用在错误提示里,不用为每个字段手写两遍文案。
(3)搜索是纯函数,所以能被测试
function searchItems(items, keyword) {
const kw = String(keyword == null ? '' : keyword).trim().toLowerCase();
if (!kw) return \\\[];
return items.filter(function (item) {
return SEARCH\\\_FIELDS.some(function (field) {
return String(item\\\[field] == null ? '' : item\\\[field]).toLowerCase().indexOf(kw) >= 0;
});
});
}
SEARCH\\\_FIELDS 定义了参与匹配的四个字段,「四字段搜索」这条需求就变成了一个数组,加字段只改数组。整个函数不碰 DOM、不改传入的数组,因此可以拿一份固定的种子数据反复断言——这也是下面单元测试能覆盖到「搜不到结果」这类异常路径的原因。
(4)localStorage 不可用时要有兜底
function defaultStorage() {
try {
const probe = '\\\_\\\_lf\\\_probe\\\_\\\_';
window.localStorage.setItem(probe, '1');
window.localStorage.removeItem(probe);
return window.localStorage;
} catch (e) {
return createMemoryStorage(); // 隐私模式下退回内存存储
}
}
构造函数允许外部传入任意 storage,默认值则先探一次能不能写,写不了就退回内存实现。这一处同时解决了两件事:浏览器隐私模式下页面不会白屏;单元测试可以把内存版 storage 注进去,不污染真实的浏览器数据。
四、附加特点设计与展示
4.1 特点一:按物品类别筛选 + 数据概览条
这个设计的意义。 题面在附加特点里举了个例子,说可以按物品类别做筛选。我们做类别筛选的时候,顺手在首页顶部加了一条数据概览,显示「全部信息 / 寻物 / 招领」三个数字。
加它的原因是自己用的时候发现的:这个池子里的信息量不大,可能一共就几条。但打开首页时如果不知道总共有多少、寻物和招领各有多少,就不太确定值不值得翻一遍。有了这三个数字,一眼能看到现在的规模;真找水杯的话,再看下面那排类别点一下「水杯」就行(类别一共七个:校园卡·证件 / 钥匙 / 水杯 / 雨伞 / 耳机 / 书籍·资料 / 其他,前面再加一个「全部」)。
实现思路。 概览条的数字不是写死的,而是每次渲染时从当前数据实时算出来:
const lostCount = search.filterByType(all, 'lost').length;
const foundCount = search.filterByType(all, 'found').length;
这样做是因为用户自己发布信息之后回到首页,数字必须跟着变——如果当成静态文案,发布完就会发现「全部信息」还是 8,反而露怯。
类别筛选则完全复用了搜索模块的纯函数 filterByCategory,没有新写一套逻辑:
function filterByCategory(items, cate) {
if (!cate || cate === 'all') return items.slice();
return items.filter(function (item) { return item.cate === cate; });
}
首页现在的筛选是两层叠加:先按类型(全部 / 寻物 / 招领),再按类别,两次筛选都返回新数组,所以互相不会污染:
let list = search.filterByType(all, state.homeType);
list = search.filterByCategory(list, state.homeCate);
list = search.sortByTimeDesc(list);
这里没有再写一套筛选逻辑,直接用搜索模块里现成的 filterByCategory。好处是首页的类别筛选和搜索页的类型筛选行为不会跑偏,而且它本来就是纯函数,顺手也能被单元测试覆盖到。
4.2 特点二:一键复制联系方式
这个设计的意义。 详情页默认不显示联系方式,要点一下「联系 TA」才展开,这是为了隐私。但展开之后,用户下一步是要切到微信或 QQ 去加好友,而手机上看一小段微信号再长按复制,很容易选错。加一个「复制」按钮,就是把「展开」到「真能用上」这一段变顺:展开 → 点复制 → 切到微信粘贴,不用再在屏幕上做精确定位。
实现思路与代码。 剪贴板的现代 API(navigator.clipboard.writeText)在网页里是异步的、并且只在安全上下文(HTTPS 或 localhost)下可用;而这个项目按要求是「下载后用谷歌浏览器直接打开 html 文件」,也就是 file://,属于非安全上下文。所以这里写了两条路径:
function copyContact() {
const el = $('contact-value');
const text = el ? el.textContent : '';
if (!text) return;
const done = function () { toast('联系方式已复制:' + text); };
const fallback = function () {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
toast(ok ? '联系方式已复制:' + text : '复制失败,请手动长按选择');
};
if (navigator.clipboard \\\&\\\& navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else {
fallback();
}
}
关键点是先判断能力再决定用哪条路:有 navigator.clipboard 就优先用它,并在它失败(.then 的第二个回调)时退回 execCommand('copy') 的老办法;execCommand 通过一个临时 textarea 完成选中与复制,用完立刻移除,不会在页面上留下痕迹。无论走哪条路,最后都用同一个 toast 给出明确反馈——复制这种动作如果没有反馈,用户会不确定到底复制成功没有,然后重复点好几次。
成果展示。


4.3 界面总览
首页(含数据概览条与两层筛选):

搜索页与发布页:


「我的发布」:可改状态、编辑、删除。

五、目录说明与使用说明
5.1 目录是如何组织的
lost-found-app/
├── index.html 页面入口,底部按依赖顺序加载 6 个脚本
├── css/
│ └── style.css 全局样式(含移动端 430px 宽的居中布局)
├── js/
│ ├── utils.js 通用工具:HTML 转义、信息编号生成、时间格式化
│ ├── seed.js 种子数据、物品类别、当前用户
│ ├── validate.js 发布表单校验与数据规范化(纯函数)
│ ├── search.js 关键词搜索与类型 / 类别 / 状态筛选(纯函数)
│ ├── store.js 信息仓库:增删改查 + localStorage 持久化
│ └── ui.js 界面与交互:路由 + 事件代理 + 页面渲染
├── tests/
│ ├── test.html 浏览器版测试入口(双击即可运行)
│ ├── harness.js 极简测试框架(test / assert / runTests)
│ ├── loader.js Node 版测试的脚本加载器
│ ├── run.js Node 版测试入口
│ ├── utils.test.js utils 的单元测试(11 个用例)
│ ├── validate.test.js validate 的单元测试(14 个用例)
│ ├── search.test.js search 的单元测试(12 个用例)
│ └── store.test.js store 的单元测试(14 个用例)
├── diagrams/ 关键实现的流程图 / 数据流图
├── shots/ 界面截图
└── README.md 本说明的仓库版本
组织原则只有一条:按「会不会碰页面」分层。utils / seed / validate / search / store 五个文件不引用任何 DOM API,可以被单元测试直接加载;ui.js 是唯一碰 DOM 的文件,它通过 store 这一个出口读写数据。所以看代码时可以先看 store.js 搞清数据形状,再看 ui.js 的 PAGES 注册表搞清有哪些页面,最后按需深入单个模块。
5.2 测试人员如何运行
运行页面(必须用谷歌浏览器):
- 从 项目仓库 下载全部文件(
Code→Download ZIP,或git clone); - 保持目录结构不变,解压后双击打开根目录下的
index.html; - 无需安装 Node.js、无需启动任何本地服务器、无需联网,页面即可正常显示与操作。
数据保存在浏览器本地的
localStorage,刷新不会丢失。如果希望恢复到初始的示例数据,在浏览器控制台执行localStorage.clear()后刷新即可;若浏览器禁用了 localStorage(如隐私模式),程序会自动退回内存存储,功能仍可正常使用,只是刷新后数据重置。
运行单元测试:
- 方式一(推荐,无需环境):用谷歌浏览器打开
tests/test.html,页面顶部会显示「通过数 / 总数」,绿色为通过、红色为失败,失败用例会同时给出期望值与实际值; - 方式二(可选,需 Node.js):在项目根目录执行
node tests/run.js,终端会输出同样的结果。
六、单元测试
6.1 用的什么工具,怎么学的
单元测试这块我们之前没写过,是这次现学的。
一开始想直接用 Mocha,但 Mocha 要装 Node、还要配 package.json 和一堆依赖。作业里又特地说明「下载全部文件后用谷歌浏览器运行 html 就能展现预期结果」,最后决定不引第三方的东西,自己在浏览器里做一个最简单的。这个框架就是 tests/harness.js,一百行出头。
写之前我先把 Mocha 的入门看了一遍,主要搞清楚三件事:怎么声明一条用例、怎么写断言、怎么跑完汇总结果。看完发现,对我们这种小项目来说一个测试框架真的只需要这三个能力,别的暂时都用不上。所以 harness.js 就对外提供三个东西:
test(名字, 函数):注册一条用例assert.ok / equal / deepEqual / notThrow:做断言runTests():把所有注册过的用例跑一遍,返回通过数和失败数
用法跟 Mocha 差不多,区别是不需要装任何东西,引一个 <script> 就能用。
下面是我们自己总结的三步做法,算是给同样没写过单元测试的同学一份简易教程:
第一步,建一个测试文件,把用例写进去。 这是给搜索函数写的第一条用例:
// tests/search.test.js
(function () {
'use strict';
const LF = window.LF;
LF.test('searchItems:搜不到结果时返回空数组', function () {
LF.assert.deepEqual(LF.search.searchItems(LF.seed.SEED\\\_ITEMS, '自行车头盔'), \\\[]);
});
})();
第二步,做一个测试页面,按顺序把被测代码和测试文件都引进来。 tests/test.html 里就是一堆普通的 <script>:先引 js/ 下的五个模块(待测的代码),再引 harness.js 和四个 \\\*.test.js,最后调一次 LF.runTests(),把结果显示在页面上。引用的顺序不能错,因为后面的文件依赖前面挂在 window.LF 上的东西。
第三步,双击 test.html 看结果。 页面会列出每一条用例,通过了标绿、没通过标红,失败时把期望值和实际值都打出来,比如:
✗ searchItems:按物品名称匹配
断言失败:期望 1,实际 2
这样做的好处是:改完代码不用记得自己刚才动过哪里,双击一下文件就知道有没有把别的功能改坏。
6.2 测试代码与测试的函数
框架里最关键的就是注册用例和断言这两部分:
/\\\*\\\* 注册一个用例:name 是用例说明,fn 里用 assert 做断言 \\\*/
function test(name, fn) {
cases.push({ name: name, fn: fn });
}
const assert = {
ok: function (value, message) {
if (!value) throw new Error((message || '断言失败') + ':期望为真,实际为 ' + show(value));
},
equal: function (actual, expected, message) {
if (actual !== expected) {
throw new Error((message || '断言失败') + ':期望 ' + show(expected) + ',实际 ' + show(actual));
}
},
deepEqual: function (actual, expected, message) {
if (show(actual) !== show(expected)) {
throw new Error((message || '断言失败') + ':期望 ' + show(expected) + ',实际 ' + show(actual));
}
},
/\\\*\\\* 断言函数不抛异常(用于校验「非法输入不应崩」) \\\*/
notThrow: function (fn, message) {
try { fn(); } catch (e) {
throw new Error((message || '断言失败') + ':不应抛出异常,实际抛出 ' + e.message);
}
}
};
写断言时我们坚持了一件事:失败信息里必须同时带上期望值和实际值。只写一句「失败了」,过两天自己回头看都不知道差在哪,更别说给别人看。
被测的函数和用例数如下(tests/ 下四个文件,一共 51 条):
| 测试文件 | 测的函数 | 用例数 |
|---|---|---|
utils.test.js |
escapeHtml、makeId、nextSeq、formatPubTime、formatRelative |
11 |
validate.test.js |
validateDraft、normalizeDraft |
14 |
search.test.js |
searchItems、filterByType、filterByCategory、filterByStatus、sortByTimeDesc、queryItems |
12 |
store.test.js |
ItemStore 的 add、get、list、listMine、setStatus、update、remove、load |
14 |
挑几条贴出来:
test('searchItems:关键词为空时返回空数组(搜索页据此展示热门搜索)', function () {
assert.deepEqual(search.searchItems(SEED, ''), \\\[]);
assert.deepEqual(search.searchItems(SEED, ' '), \\\[]);
});
test('searchItems:按描述字段匹配且不区分大小写', function () {
const upper = search.searchItems(SEED, 'MAH');
const lower = search.searchItems(SEED, 'mAh');
assert.equal(upper.length, 1);
assert.equal(lower.length, 1);
assert.equal(lower\\\[0].id, 'LF20260921001');
});
test('sortByTimeDesc:按发布时间倒序且不改动原数组', function () {
const before = SEED.map(function (i) { return i.id; });
const sorted = search.sortByTimeDesc(SEED);
assert.equal(sorted\\\[0].id, 'LF20260924001');
assert.deepEqual(SEED.map(function (i) { return i.id; }), before); // 原数组未被改动
});
test('queryItems:状态筛成 done 后,进行中的信息被排除', function () {
const result = search.queryItems(SEED, { keyword: '校园卡', status: 'done' });
assert.deepEqual(result, \\\[]);
});
第三条里最后一句断言,测的是「原数组没被动过」。考虑的是:首页列表和搜索页都在调这个排序函数,如果它图省事直接把传进来的数组改了,首页的原始顺序就会被弄乱;只在几条数据上验证「排得对不对」是看不出来这个问题的。
6.3 测试数据是怎么想出来的
先固定一份数据。 所有用例都跑在 seed.js 里那份示例数据上。用固定数据的好处是结果可重复——如果每次都要看浏览器里当前存着几条信息、都长什么样,今天过明天挂,就没法拿它判断代码有没有被改坏。
然后一个函数一个函数地列「它可能收到什么」。 说实话我们没什么特别的方法,就是对着代码把每种输入情况写下来。作业里要求用白盒的方法设计用例,我的理解就是看着代码把每个分支都走一遍,所以基本也是这么做的。
有两个做法是实际用上了的:
一是先准备一份合法的数据,每条用例只改一个地方。validate.test.js 里有一个 validDraft() 返回一份填得没问题的表单,测哪个字段就把哪个字段改坏,其他字段不动。这样一条用例只验证一件事,失败的时候不用猜是哪里的问题。
/\\\*\\\* 一份合法的表单草稿,各用例在此基础上只改一个字段制造异常 \\\*/
function validDraft() {
return {
type: 'lost', name: '黑色雨伞', cate: '雨伞',
place: '紫金楼 302', time: '2026-09-24 15:00',
desc: '伞柄上有白色标签', contact: '微信 lf\\\_0924'
};
}
二是有长度限制的字段,边界上下都要测。比如物品名称限制 30 个字,那「刚好 30 字」和「31 字」就各写一条;联系方式限制 3 到 40 个字符,另外测一条 2 个字符的。长度判断最容易把 > 和 >= 写反,只测中间的正常值根本看不出来。
test('validateDraft:名称刚好 30 字通过(边界值 = max)', function () {
const draft = validDraft();
draft.name = new Array(31).join('伞'); // 30 个「伞」
assert.equal(validate.validateDraft(draft).ok, true);
});
test('validateDraft:名称 31 字超长报错(边界值 = max + 1)', function () {
const draft = validDraft();
draft.name = new Array(32).join('伞'); // 31 个「伞」
assert.equal(validate.validateDraft(draft).errors.name, '物品名称不能超过 30 个字');
});
不正常的情况单独列一遍。 能走通的那条路一般不会出问题,出问题的是没人走的那条,所以每个模块都专门列了几条:
- 搜一个根本不存在的词(应该返回空数组,页面才能显示「没有找到」的提示);
- 表单什么都不填就点发布(应该把几处错误一次收集齐,而且不能崩);
store.load()读到一个被写坏的 JSON 字符串(应该退回示例数据,不能让页面白屏);- 删一个不存在的编号(应该返回 false,不能把数组弄乱)。
最后想了一下测试的人可能会怎么问。 作业里专门有一问是「如何考虑将来测试人员的刁难」,我们照着这个方向自查了三件事,并且把答案直接放进了代码里:
- 「关键词传 null 进去会怎样?」 ——
searchItems第一行先把关键词转成字符串再处理,所以传null、undefined都不会报错;用例里也补了空字符串和纯空格两种情况。 - 「你浏览器里存着旧数据,测试结果还准吗?」 —— 这是个真问题。
ItemStore的构造函数可以接收一个storage参数,测试的时候传内存版的实现进去,既不依赖、也不会污染浏览器里的真实数据。store.test.js里的用例都是这么建的:
function newStore() {
return new ItemStore({ storage: createMemoryStorage(), key: 'test-key' });
}
- 「连着发两条信息,编号会不会重复?」 —— 编号是「日期 + 当天第几条」拼出来的,用例专门验证了同一天连续新增时序号会递增,比如从
LF20261009001变成LF20261009002。
跑出来的结果是 51 条全过。 用谷歌浏览器打开 tests/test.html 就能看到:
51 / 51 全部用例通过
不过这套测试有一个明显的缺口:它只能覆盖 js/ 下那五个不碰页面的模块,界面层(ui.js)完全没测到。第八节里说的「详情页没有返回入口」,就是因为单元测试测不出来,只能靠人工一条条点页面才发现。如果接着往下做,下一步应该是想办法把界面也自动测起来,但那需要有能模拟点击和渲染的环境,这次时间不够,没做。
七、Github 的代码签入记录
按「每完成一个功能就签入一次」的方式提交,9 个功能 PR 依次合并,另有 2 次修复与文档提交:


八、遇到的代码模块异常或结对困难及解决方法
困难一:详情页进去了却出不来
- 问题描述:走查页面时发现,从首页点开任意一条信息进入详情页之后,页面上没有任何返回入口,只能靠浏览器的后退按钮才能回到列表。
- 做过哪些尝试:一开始以为是返回按钮被样式遮住了,检查
.navbar的z-index与层级,没有发现遮挡。后来把「当前路由」打印出来才定位到真正原因:详情页属于子页面,渲染时刻意为它隐藏了底部标签栏(tabbar.classList.toggle('hide', route === 'detail' || route === 'success')),但详情页自身的模板里漏了顶部的navbar()——它直接以<div class="detail-hero">开头。于是底部导航被隐藏、顶部返回栏不存在,页面就成了死胡同。对比同类的发布页发现了同一个navbar()调用,确认是详情页漏写。 - 是否解决:已解决。在详情页模板最前面补上
navbar('信息详情'),并逐个验证了三条进入详情页的路径——首页卡片、搜索结果卡片、「我的发布」的「查看」,三条都能正确返回上一页(从搜索页返回时关键词和结果也会保留)。 - 有何收获:这个 bug 不是哪一行代码写错了,而是两个单独看都没问题的决定凑到一起才出的问题——详情页不该有底部导航,所以把 tabbar 隐藏了;子页面该有自己的返回入口,所以别的地方都加了 navbar;但「隐藏了 tabbar 的页面必须自己带上返回入口」这层关系没人去检查。后来我们给自己定了条规矩:凡是写了隐藏底部导航的地方,那个页面第一行就得是
navbar()。
困难二:不用构建工具时,模块该怎么组织
- 问题描述:项目是纯前端、不引入任何构建工具的。最初用 ES Module(
import/export)拆分模块,在本地起服务器时一切正常,但一旦按作业要求「下载文件后直接双击 html 打开」,页面直接空白。 - 做过哪些尝试:先用浏览器控制台看报错,发现是
file://协议下加载 module 脚本被 CORS 策略拦下(模块脚本受同源策略限制,而file://属于不透明的源)。考虑过的替代方案有三个:一是引入 webpack / Vite 打包,但那要求评审者也执行构建,与「双击就能跑」冲突;二是把所有代码塞进一个文件,但那样逻辑与界面就分不开了,单元测试也无从下手;三是保持多文件、改用经典<script>标签按序加载。 - 是否解决:已解决,采用第三个方案。每个模块写成 IIFE,把自己的能力挂到统一的
window.LF命名空间下(LF.utils/LF.validate/LF.search/LF.store),index.html里按依赖顺序用 6 个<script>标签加载。这样既保留了文件级的模块划分,又不需要任何构建步骤。 - 有何收获:以前默认「用构建工具就是更规范」,这次是第一次反过来想:谁要用这个东西、他手上是什么环境。作业要求的是后者能直接打开就跑,那构建工具只能让位。另外改用命名空间之后还有个顺带的好处——这些模块能被
tests/test.html直接用<script>引进去,测试也不用打包。
困难三:改动看不到效果,白排查了半小时
- 问题描述:修改
style.css之后刷新页面,样式没有任何变化,一度以为是选择器优先级的问题,反复调整!important都没用。 - 做过哪些尝试:先怀疑是 CSS 优先级,把选择器权重逐级加高;然后怀疑是文件没保存、编辑了错误的副本;最后打开开发者工具的 Network 面板,发现请求返回的是
304 Not Modified——浏览器一直在用磁盘缓存,根本没有拿到新文件。 - 是否解决:已解决。本地调试时改用禁用缓存的静态服务(在响应头里加
Cache-Control: no-store),确认问题消失。为了区分「真的改了」和「缓存骗人」,还养成了给页面 URL 带上一个递增参数的临时习惯。 - 有何收获:看到的效果不对,不一定是代码写错了,也可能是浏览器给的根本不是新文件。以后碰到「明明改了却看不出变化」,先去 Network 面板看一眼状态码是不是 304,比直接翻代码快得多。
九、评价队友
值得学习的地方。
- 签入这件事执行得很稳。 作业要求「每做完一个功能,编译成功后应至少 commit 一次,禁止截止前一次性上传」。说起来简单,实际要在每个阶段都停下来同步一次,中途很容易图省事就攒着一起交。队友按 9 个功能节点逐个提交、逐个开 PR,commit message 也都是「feat: 实现了什么」这种能看出内容的写法,不是「update」或者「改了一下」,最后签入记录能一条条对得上功能划分。
- 合并前会真的把页面点一遍。 每个 PR 之后都在本地用谷歌浏览器把页面走一遍,并且照着 README 的操作说明确认「按文档写的方法能不能跑出正确结果」。详情页没有返回入口这个问题(第八节困难一)就是在这轮走查里发现的,发现之后才补了修复。
需要改进的地方。
- 参与得偏晚,对代码细节不太熟。 现在的分工其实更接近「一个人写、一个人签入和验证」,队友主要是功能做完之后才参与进来,对
store里数据怎么存、search怎么匹配这些细节掌握得不够,万一被问到某个分支是怎么写的,容易答不上来。下次最好从设计阶段就一起把关键模块过一遍,至少做到能自己讲清楚store和search这两个文件。 - 走查可以更系统一点。 这次是手动一条条点的,覆盖到多少基本靠自觉。如果走查前先把页面列成一张清单(每个页面 × 正常和异常路径),再把「隐藏了底部导航的页面能不能返回」这种检查项固定进去,就不太会漏掉某条分支。

浙公网安备 33010602011771号