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 个用例也是因为这个才写得出来。

界面层本身只做了三件事:

  1. 路由:用一个 state.route 记住当前在哪个页面,PAGES 是一张「路由 → 渲染函数」的注册表,每个渲染函数只负责返回一段 HTML 字符串。切换页面就是把 state.route 一改、重新渲染一次。
  2. 事件代理:所有可点击元素的交互都写在 data-action 属性上(例如 data-action="detail" data-id="LF20260924001"),在 document 上挂一个 click 监听统一分发。这样做的好处是页面每次重渲染都不用重新绑定事件,也不会出现「旧节点的事件没解绑」这类问题;同时跳转参数(id、type)通过 data-\\\* 直接带在元素上,不需要额外的全局变量。
  3. 单一数据出口:页面永远不直接读写 localStorage,一律走 store。store 是唯一的出入口,它负责生成信息编号、写入创建时间、把整个列表序列化后持久化。

数据本身只有一种形状——一条信息就是一条记录:

{ id, name, type, cate, place, time, desc, contact, pub, dept, status, mine, createdAt }

列表、搜索、发布、状态修改全都围绕这一条记录展开,没有第二种数据结构,这也让「同一个信息池」这个设计落在了代码上:寻物和招领是同一种记录的两个 type 取值,而不是两张表,所以一次搜索能同时命中两类信息。

3.2 模块架构与数据流

01-架构与数据流图

六个脚本按依赖顺序加载,ui.js 是唯一的界面层;validate / search / store 三个核心模块各自只做一件事,并且共同依赖 utils 与 seed;数据的最终落点是 localStorage 里的一个键 lost-found-items-v1。

3.3 关键实现一:发布 / 编辑信息(含校验分支)

02-发布编辑流程

这条流程里有两个值得说的设计点:

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

3.4 关键实现二:关键词搜索与筛选

03-搜索筛选流程

搜索的关键是匹配范围和空结果的语义:

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

3.5 关键实现三:更新信息状态(已找到 / 已归还)

04-状态更新流程

题面要求「物品找回或归还后,由发布者把信息标记为已找到或已归还」。这里的实现是:只有自己发布的信息(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 给出明确反馈——复制这种动作如果没有反馈,用户会不确定到底复制成功没有,然后重复点好几次。

成果展示。

05-详情页

06-一键复制

4.3 界面总览

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

07-首页

搜索页与发布页:

08-搜索页

09-发布页

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

10-我的发布

五、目录说明与使用说明

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 测试人员如何运行

运行页面(必须用谷歌浏览器):

  1. 从 项目仓库 下载全部文件(Code → Download ZIP,或 git clone);
  2. 保持目录结构不变,解压后双击打开根目录下的 index.html;
  3. 无需安装 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,不能把数组弄乱)。

最后想了一下测试的人可能会怎么问。 作业里专门有一问是「如何考虑将来测试人员的刁难」,我们照着这个方向自查了三件事,并且把答案直接放进了代码里:

  1. 「关键词传 null 进去会怎样?」 —— searchItems 第一行先把关键词转成字符串再处理,所以传 null、undefined 都不会报错;用例里也补了空字符串和纯空格两种情况。
  2. 「你浏览器里存着旧数据,测试结果还准吗?」 —— 这是个真问题。ItemStore 的构造函数可以接收一个 storage 参数,测试的时候传内存版的实现进去,既不依赖、也不会污染浏览器里的真实数据。store.test.js 里的用例都是这么建的:
function newStore() {
  return new ItemStore({ storage: createMemoryStorage(), key: 'test-key' });
}
  1. 「连着发两条信息,编号会不会重复?」 —— 编号是「日期 + 当天第几条」拼出来的,用例专门验证了同一天连续新增时序号会递增,比如从 LF20261009001 变成 LF20261009002。

跑出来的结果是 51 条全过。 用谷歌浏览器打开 tests/test.html 就能看到:

51 / 51 全部用例通过

不过这套测试有一个明显的缺口:它只能覆盖 js/ 下那五个不碰页面的模块,界面层(ui.js)完全没测到。第八节里说的「详情页没有返回入口」,就是因为单元测试测不出来,只能靠人工一条条点页面才发现。如果接着往下做,下一步应该是想办法把界面也自动测起来,但那需要有能模拟点击和渲染的环境,这次时间不够,没做。

七、Github 的代码签入记录

按「每完成一个功能就签入一次」的方式提交,9 个功能 PR 依次合并,另有 2 次修复与文档提交:

11-签入记录

12-PR列表

八、遇到的代码模块异常或结对困难及解决方法

困难一:详情页进去了却出不来

  • 问题描述:走查页面时发现,从首页点开任意一条信息进入详情页之后,页面上没有任何返回入口,只能靠浏览器的后退按钮才能回到列表。
  • 做过哪些尝试:一开始以为是返回按钮被样式遮住了,检查 .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,比直接翻代码快得多。

九、评价队友

值得学习的地方。

  1. 签入这件事执行得很稳。 作业要求「每做完一个功能,编译成功后应至少 commit 一次,禁止截止前一次性上传」。说起来简单,实际要在每个阶段都停下来同步一次,中途很容易图省事就攒着一起交。队友按 9 个功能节点逐个提交、逐个开 PR,commit message 也都是「feat: 实现了什么」这种能看出内容的写法,不是「update」或者「改了一下」,最后签入记录能一条条对得上功能划分。
  2. 合并前会真的把页面点一遍。 每个 PR 之后都在本地用谷歌浏览器把页面走一遍,并且照着 README 的操作说明确认「按文档写的方法能不能跑出正确结果」。详情页没有返回入口这个问题(第八节困难一)就是在这轮走查里发现的,发现之后才补了修复。

需要改进的地方。

  1. 参与得偏晚,对代码细节不太熟。 现在的分工其实更接近「一个人写、一个人签入和验证」,队友主要是功能做完之后才参与进来,对 store 里数据怎么存、search 怎么匹配这些细节掌握得不够,万一被问到某个分支是怎么写的,容易答不上来。下次最好从设计阶段就一起把关键模块过一遍,至少做到能自己讲清楚 store 和 search 这两个文件。
  2. 走查可以更系统一点。 这次是手动一条条点的,覆盖到多少基本靠自觉。如果走查前先把页面列成一张清单(每个页面 × 正常和异常路径),再把「隐藏了底部导航的页面能不能返回」这种检查项固定进去,就不太会漏掉某条分支。
posted @ 2026-10-10 18:01  Otonash1  阅读(10)  评论(0)    收藏  举报