第 08 篇
重构中的行为保持:换一种实现,怎样确认功能没变
沿异步任务保存程序,把数组实现分步迁移到 Map,用相同约定检查每个阶段,解释重构范围、过渡代码、列表顺序、保存格式和行为变化。
Hex4C59 20 分钟阅读 更新于 2026-10-17
软件工程·原创·概念与设计
待办事项程序已经能完成任务、等待保存、报告失败,也有一组检查。现在想把内部保存任务的数组换成 Map,调用者仍然使用 listTasks() 和 completeTask(id)。
函数名称没变,正常完成任务时也能返回成功,是否就算改好了?未必。完成一条任务后,列表顺序可能变了;保存函数收到的可能不再是数组;等待期间收到无效编号,返回原因也可能与以前不同。它们都可能影响调用者,即使我们没有打算增加任何功能。
本篇讨论重构:为使代码更容易理解和修改,在约定的可观察行为保持不变的前提下,调整代码内部的组织或实现。Martin Fowler 的定义把“内部结构改变”和“可观察行为保持”放在一起;本文会把后者具体写成当前程序需要保留的结果、顺序和外部动作。[1]
做法是先确定修改范围,再把迁移分成能单独运行和检查的小步。测试为已经检查的条件提供证据,不代替接口约定,也不自动证明所有情况都相同。
读者需要能阅读简单 JavaScript,并理解 await 等待 Promise 的结果;Promise 是表示一项操作最终结果的对象。第六篇解释异步保存,第七篇解释断言和受控保存。本文会复用那组检查,再补上与迁移有关的观察。
1. 先有一个值得做的修改,再谈怎么重构 #
当前只有两条任务:写周报和整理笔记。用数组完全可以完成这些功能。这里选择“内部改为按编号存储”作为练习目标,便于观察内部表示改变时,哪些外部约定仍需保留。
Map 是 JavaScript 提供的一种集合,按键保存值。本例用任务编号作为键,用任务对象作为值,例如编号 t1 对应写周报。它支持按键取得已有任务。[2] 文中把数组和 Map 这类保存多条数据的结构统称为容器。
仅凭这点,不能推导出当前程序应该迁移,更不能声称换完一定更快。本章没有测量速度或内存。迁移会增加 Map 的构造、读取和保存前转换,读者也需要理解另一种数据结构。
第四篇比较过数组与 Map 的完整实现。本篇补齐修改过程:当我们决定迁移时,怎样保留已有异步保存的规则,怎样在出错后找到需要回看的那一步。
真实项目若没有相应需求,保留当前数组实现也是合理选择。重构的收益应当落到实际工作,例如某一类修改更集中、重复代码更少、职责更容易说明。文件更多或代码样子更新,并不能单独说明值得做。
2. 什么叫“行为没有变” #
这里的可观察行为,是调用者和保存方能够通过接口观察到的事情。它包括返回值,也包括什么时候能读到新数据、保存收到什么,以及失败后还能不能继续操作。
我们把本次要保留的约定列出来:
| 观察位置 | 本次要保留的约定 |
|---|---|
| 创建任务对象 | 缺少保存函数时,同步抛出 TypeError |
| 读取列表 | 返回数组,按初始顺序排列,外部修改副本不影响内部 |
| 完成任务 | 仍返回 Promise;等待后取得操作结果 |
| 成功结果 | 保留 ok、changed 和任务的三个字段 |
| 保存参数 | 仍是任务数组的副本,字段与顺序不变 |
| 保存未结束 | 当前列表保留旧数据,第二次修改返回 busy |
| 保存失败 | 返回 save-failed,保留旧任务,之后可以重试 |
| 重复完成 | 返回 changed: false,不额外保存 |
ok 表示操作是否成功,changed 表示这一次是否改变任务;任务字段仍是编号 id、标题 title 和完成标记 completed。
内部变量叫什么、任务放在数组还是 Map、计算函数位于哪个文件,可以在这次范围内改变。保存参数的格式和公开结果不在这个范围内。把内部 Map 直接交给保存方,会让保存方也必须跟着改,已经超过了原定迁移边界。
先写清观察范围,才能判断测试究竟在保护什么。只说“功能保持不变”,不同人可能分别想到按钮还能点、结果还是成功、文件格式完全相同,讨论时很容易各说各的。
3. 输入前提也属于这份约定 #
本系列一直要求初始任务合法、编号唯一,标题为字符串,完成标记为布尔值;任务只包含这三个简单字段。保存函数应正确保存收到的副本,并最终正常完成或报告失败。
这些前提在数组换成 Map 时尤其重要。同一个 Map 键只保留一个对应值;如果初始任务里有两个相同编号,迁移后就不能同时按这一个键保存两条记录。[2]
我们不能拿一份可能有重复编号的真实数据,直接套用“本章检查通过,所以迁移安全”。应当先确认数据满足前提。若真实调用从来没有保证过编号唯一,就需要先决定怎样处理重复,补充验证和调用约定,再安排迁移。
缺少检查,也不表示可以随意改变行为。本篇限定合法输入,是为了明确论证范围;真实项目还应核对实际数据、现有调用和已经依赖的行为。
同样,保存失败后保留旧内存,不表示外部文件一定没变。这个限制来自前面的保存设计,本次重构也没有把它变成完整的故障恢复。
4. 先让旧版本接受检查 #
下载包中的 00-original.mjs 与第七篇的异步任务所有者逐字节相同。它继续使用原来的初始任务、复制函数和完成计算。
所谓基线,在这里就是开始修改之前已经核对过的版本。我们先对它运行相同的检查,确认没有把旧程序原本就存在的问题,误认为后面某一步刚引入的问题。
在解压的示例目录执行:
node --test --test-name-pattern "^00-original" behavior.test.mjs file.test.mjs本次这个命令运行了该版本的 12 项行为检查与 2 项真实文件检查,共 14 项通过。其余三个版本也会使用相同的两组检查。命令通过测试名称前缀选择版本。[4]
第七篇的 contract-checks.mjs 没有改写预期,仍检查等待前后采用数据、保存拒绝和第二次修改。新增检查补充任务顺序、数组参数、忙碌优先级、数据副本与创建方式等约定,并先在原实现上运行。
如果测试要求的是我们希望将来才有的行为,应当把它作为需求变化处理。不能先把预期改成新样子,再把旧版本测试失败解释为“旧代码不够规范”。
5. 把一次迁移分成四个可运行版本 #
本章保留四份演示代码:
| 文件 | 这一阶段改变什么 | 尚且保留什么 |
|---|---|---|
00-original.mjs | 不改变,建立基线 | 原数组实现 |
01-rename.mjs | 把内部当前数据命名为 currentTasks | 原容器、计算、保存顺序 |
02-map-bridge.mjs | 当前数据改用 Map,调用原计算前转成数组 | 原来的数组计算函数 |
03-map-direct.mjs | 完成计算直接接收 Map | 对外数组、结果与异步约定 |
这里的编号是为了阅读而保留的代码快照,不是四个真实 Git 提交。快照指把某一步的代码完整保存下来,方便运行和比较。
实际修改项目时,通常是在当前实现上依次做这些变化,每完成一步就检查;不需要在产品里长期维护四套任务实现。下载包同时保留它们,是为了让读者能够重现每一步的结果。
小步的价值在于缩小需要回看的变化。假如只改了命名就失败,先查名字和引用;假如前一步通过,而引入 Map 后失败,就优先看这一步涉及的数据构造和转换。它不能自动定位错误,但能减少同时需要怀疑的内容。
6. 第一步:重命名只改内部引用 #
原来的 tasks 既出现在“当前任务”的位置,也容易与“传给保存的任务”混淆。我们把所有者内部那一个变量改名为 currentTasks,明确它代表当前已经采用的数据。
例如,读取和采用的位置变成:
// 01-rename.mjs 中的局部片段。
let currentTasks = copyTasks(initialTasks);
function listTasks() {
return copyTasks(currentTasks);
}
// 保存正常结束后:
currentTasks = proposal.nextTasks;这一步不改 listTasks、completeTask,也不改返回对象里的 task 字段。调用者不需要知道局部变量换了名字。
重命名还应区分代码引用和字符串。initial-tasks.mjs 是文件路径,不能因为里面也有 tasks 就一起替换。公开结果里的字符串和属性名,也不能靠全文替换顺手改掉。
运行:
node --test --test-name-pattern "^01-rename" behavior.test.mjs file.test.mjs这一阶段同样为 14 项通过。此时没有引入新的容器问题,后面就可以把注意力转向数据表示,而不是同时怀疑一次大范围重命名。
7. 换容器以前,先看 Map 提供了什么 #
下面是一段可以单独运行的例子:
const tasks = new Map([
["t1", { id: "t1", title: "写周报", completed: false }],
["t2", { id: "t2", title: "整理笔记", completed: false }],
]);
console.log(tasks.get("t1").title);
console.log([...tasks.values()].map(task => task.id));输出:
写周报
[ 't1', 't2' ]get 按键取得值;values() 提供依次读取值的方式,方括号中的 ... 把这些值收集成数组。这里读取的顺序对应键的插入顺序。[2]
当前程序的外部接口仍然需要数组。我们可以在内部使用 Map,在读取或保存的位置取出任务,再交给原来的 copyTasks 构造数组副本。
要让外部代码继续按原来的方式使用任务,读取和保存的位置都需要把内部 Map 转换成约定的数组。内部换了容器,外部格式不会自动保持。
8. 构造 Map 时,先隔开外部数据 #
to-task-map.mjs 的完整代码是:
import { copyTasks } from "./copy-tasks.mjs";
export function toTaskMap(tasks) {
const byId = new Map();
for (const task of copyTasks(tasks)) {
byId.set(task.id, task);
}
return byId;
}先复制任务,再逐条放进 Map,是为了不把外部传入的任务对象直接作为内部当前数据。否则,调用者修改初始数组里某条任务的标题,Map 里的那一个对象也会随之改变。
这里用 set 保存键与值,键是编号。复制仍由旧的 copyTasks 完成,保留三个字段,不增加新的数据内容。
这个函数只负责构造容器,不保存、不检查忙碌,也不完成任务。把它单独写出,是因为过渡阶段的初始化和采用数据都需要这个转换,名称也能说明那两处在做什么。
如果最后只有一处简单使用,是否继续单独留一个文件,可以另行判断。当前演示先保留清楚的修改范围,不把文件整理和容器迁移同时做完。
9. 第二步:先接通新容器,保留原计算 #
在 02-map-bridge.mjs 中,当前数据已经是 Map,但 planCompletion 仍然接收数组。因此,计算前先取得数组副本,保存成功后再把计算得到的数组转回 Map。
关键位置如下,这是局部片段,完整文件在下载包里:
let currentTasks = toTaskMap(initialTasks);
function listTasks() {
return copyTasks(currentTasks.values());
}
// completeTask 中,忙碌检查通过后计算:
const proposal = planCompletion(copyTasks(currentTasks.values()), id);
// 保存仍接收数组副本:
const saveResult = await saveSnapshot(copyTasks(proposal.nextTasks));
// 保存正常结束、成功值符合约定后才采用:
currentTasks = toTaskMap(proposal.nextTasks);这一步没有把等待和失败判断删掉。上面的片段分处完整函数的不同位置,不能连在一起直接作为完成任务函数;特别是保存失败或无需修改时,完整代码会提前交回结果。
现在运行:
node --test --test-name-pattern "^02-map-bridge" behavior.test.mjs file.test.mjs14 项检查继续通过。我们已经确认:在当前条件下,Map 的构造、读取和采用,可以接在原来的计算与保存过程上。
这个阶段还不能说明新的 Map 计算正确,因为我们尚未替换计算。分步的意义恰恰是先确认已改的部分,再推进剩下的变化。
10. 过渡代码有成本,也有退出条件 #
上一步的数组与 Map 来回转换,是为了暂时连接新旧两部分。本文称它为过渡代码:迁移期间用于保持连接,最终是否保留,需要有明确判断。
它让我们一次只改容器管理,但增加了复制与转换。代码从 Map → 数组 → Map 走了一圈,阅读时也要多追踪几处数据形状。
本例的退出条件是:新的完成计算能够直接接收 Map,并通过相同的公开行为检查。达到这个条件以后,计算前的数组转换与采用时的转回动作就可以移除。
退出条件应当说清楚。否则一段本来只为迁移服务的转换,可能长期留在那里,后来的人既不知道为什么存在,也不知道什么时候能删。
当然,有些转换本来就是长期边界。例如保存方继续接收数组,就需要在保存前产生数组。不能因为它也叫转换,就把它与临时连接代码一起删除。
11. 第三步:让完成计算直接处理 Map #
map-plan-completion.mjs 的完整代码如下:
export function planMapCompletion(currentTasks, id) {
if (typeof id !== "string" || id.trim() === "") {
return { result: { ok: false, reason: "invalid-id" }, nextTasks: null };
}
const target = currentTasks.get(id);
if (target === undefined) {
return { result: { ok: false, reason: "not-found" }, nextTasks: null };
}
const changed = target.completed === false;
const completedTask = { id: target.id, title: target.title, completed: true };
const result = { ok: true, changed, task: { ...completedTask } };
if (changed === false) {
return { result, nextTasks: null };
}
const nextTasks = new Map(currentTasks);
nextTasks.set(id, completedTask);
return { result, nextTasks };
}它仍然先处理编号,再区分不存在与已经完成。changed 仍然由原任务是否未完成决定,返回给调用者的任务仍是三个字段。
有变化的是准备保存、等待所有者采用的新数据,后文称为候选数据:它从数组变为新的 Map。new Map(currentTasks) 复制键与值的对应关系,接着用新任务替换指定编号的值。[2]
不要直接写 target.completed = true。target 是当前 Map 中的对象,直接修改就会在保存以前改变当前数据,违背等待期间读取旧任务的约定。这里先创建 completedTask,再把它放入候选 Map。
{ ...completedTask } 则为结果另外创建任务对象,避免调用者以后修改 result.task 时,改到候选 Map 中的那一个对象。对象中的三个值是字符串和布尔值,本例没有可修改的子对象。
12. 新 Map 不等于所有任务都被复制了 #
new Map(currentTasks) 新建了集合,但其中尚未替换的任务对象仍可以与旧 Map 共享。复制容器和复制每一个对象,是两件不同的事。
本例允许内部共享未变化的任务对象,前提是内部不直接修改它们。要完成的任务会创建新对象;读取列表和交给保存的任务,也仍通过 copyTasks 产生副本。
因此,虽然新旧 Map 在内部共享未变化的任务,外部拿到的仍是副本,不能借此修改当前任务。下载包会检查初始数据、列表结果、操作结果,以及保存方收到的数据,分别被外部修改时是否影响当前任务。
保存副本被修改的那项检查,只验证隔离效果。它不能证明那个故意修改数据的保存方确实履行了正确保存的职责,两者需要分开判断。
以后如果任务增加一个可以修改的子对象,或者内部新增直接改任务的操作,就需要重新检查共享规则。不能把“这三个简单字段的例子通过了”推广为任意复杂数据都已经隔离。
13. 最终所有者:保留等待、错误转换与采用的位置 #
这是 03-map-direct.mjs 的完整代码。先看容器与计算发生变化的地方,再沿 await 追踪保存:
import { defaultTasks } from "./initial-tasks.mjs";
import { copyTasks } from "./copy-tasks.mjs";
import { toTaskMap } from "./to-task-map.mjs";
import { planMapCompletion } from "./map-plan-completion.mjs";
export function createAsyncTaskList(saveSnapshot, initialTasks = defaultTasks) {
if (typeof saveSnapshot !== "function") {
throw new TypeError("需要提供保存函数");
}
let currentTasks = toTaskMap(initialTasks);
let busy = false;
function listTasks() {
return copyTasks(currentTasks.values());
}
async function completeTask(id) {
if (busy) {
return { ok: false, reason: "busy" };
}
busy = true;
try {
const proposal = planMapCompletion(currentTasks, id);
if (proposal.result.ok === false || proposal.result.changed === false) {
return proposal.result;
}
try {
const saveResult = await saveSnapshot(copyTasks(proposal.nextTasks.values()));
if (saveResult !== undefined) {
return { ok: false, reason: "save-failed" };
}
} catch {
return { ok: false, reason: "save-failed" };
}
currentTasks = proposal.nextTasks;
return proposal.result;
} finally {
busy = false;
}
}
return { listTasks, completeTask };
}相对过渡版本,完成计算前不用再转数组,采用时也不再从数组重建 Map。保存参数仍从候选 Map 中取值并复制成数组。
同时,busy 仍在等待之前设定,保存仍被等待,失败仍只在保存附近转换为 save-failed,采用仍在正常保存之后,finally 仍负责释放忙碌状态。
不要在容器迁移时顺手把整个完成函数都包进一个返回 save-failed 的捕获。那会让其他位置未处理的错误也被解释为保存失败。也不要为了统一样子,把创建任务对象的函数改成 async;那会改变调用者直接取得对象和同步抛错的方式。
这些都可以成为另一次明确讨论的改变,但它们不属于当前容器迁移必须做的事情。
14. 保存方为什么仍然需要数组 #
json-file-save.mjs 沿用第七篇,它把收到的数据交给 JSON.stringify,再写成文本。我们本来约定它收到任务数组,因此写出的文件也包含任务数组。
如果把私有 Map 直接传过去,在本章环境中,下面这段完整代码会打印 {}:
const tasks = new Map([
["t1", { id: "t1", title: "写周报", completed: true }],
]);
console.log(JSON.stringify(tasks));这个正常完成的写入,可能只保存了空对象。若检查只关心“保存 Promise 没拒绝”,就看不到文件内容丢失。
所以,我们既检查保存函数收到的参数仍为数组,也在真实临时文件里写入后读回,比较编号、标题、完成标记和排列顺序。
这说明一处内部表示变化,可以通过边界转换被限制在内部;但若把转换删掉,外部文件格式也会跟着改变。那时需要讨论旧文件怎么读、其他调用者怎么处理,已经不是本次不改变外部格式的重构。
15. 更新值和删除后重建,顺序不同 #
在当前 Map 中,t1 先于 t2 插入。更新已有键的值,会保留它的位置;先删除 t1 再插入,则把这个键放到后面。[2]
下面两段是计算中的替代片段:
// 保留原位置,本章正式实现使用这一句。
nextTasks.set(id, completedTask);// 会改变位置的对照,故意偏离现有顺序约定。
nextTasks.delete(id);
nextTasks.set(id, completedTask);完成 t1 后,后一种写法读出的顺序是 t2、t1。任务数量没少,完成标记也可以正确,但列表内容的排列已经改变。
如果产品以后要求“已完成任务放在后面”,这种顺序变化可能是想要的功能。当前却没有这个要求,已有约定是保留初始顺序,所以要让检查指出差异。
不能等列表突然跳动之后,才说“Map 自然就是这样”。容器提供的动作有具体语义,选了不同动作,就需要承担相应行为。
16. “顺便改一下判断顺序”也可能改变接口 #
当前任务对象忙碌时,第二次调用无论给出什么编号,都先得到 busy。本次保留的检查包括:先发起有效修改,在保存等待中,再调用 completeTask("")。
有人可能觉得无效编号应当先检查,于是把编号判断搬到 busy 前面。这样第二次调用就会从 busy 变成 invalid-id。
这个选择未必永远不合适,但它改变了现有结果。调用者如果按 busy 提示稍后重试,按 invalid-id 提示修改输入,界面反应也会不同。
本章的 validate-first 反例就是这项改动。它被现有约定检查拒绝,说明已经越过本次行为保持的范围,不是在评价“先验证编号”这条规则永远错误。
如果决定调整优先级,应当明确修改约定和预期,解释原因,并让它作为一项可单独理解的行为变化。这样,审核代码的人才能区分内部整理与用户会遇到的新结果。
17. 相同检查怎样应用到四个版本 #
versions.mjs 只登记版本名称及其创建函数。behavior.test.mjs 把每一项约定检查应用到所有版本上,调用形式如下,这是删节示意:
// versions 和 checks 的完整定义见对应源文件。
for (const [name, createTaskList] of versions) {
for (const [description, check] of checks) {
test(name + ":" + description, async function () {
await check(createTaskList);
});
}
}检查并不直接读取各版本私有 Map。它通过 listTasks、操作结果和保存方收到的数据,判断那些公开约定。
预期也不是运行新版本得到的答案,而是明确写出的任务、顺序和失败结果。这样,即使几个版本恰好犯了同样的错误,也不会仅仅因为彼此一致就通过。
最终版本另外有 5 项 Map 计算检查,帮助定位候选数据的计算问题;它们补充较小部分的验证,不替代四个版本共同接受的外部行为检查。
运行下面的阶段演示,可以看到四个版本都接受相同约定判断:
node stages-demo.mjs四行均报告检查通过,并给出相同的完成结果;最后还会打印直接把 Map 转成 JSON 所得到的 {}。完整测试则继续检查失败、等待和副本,不只比较这四行正常结果。
18. 保留三个偏离约定的版本,验证检查的作用 #
下载包的 faults/ 保存三种故意偏离现有约定的实现,不能当作推荐实现使用:
| 比较版本 | 改动 | 检查观察到的差异 |
|---|---|---|
reinsert-task | 删除任务键后再插入 | 完成任务后,原列表顺序改变 |
save-map | 直接把内部 Map 交给保存 | 保存方收到的不是数组 |
validate-first | 把编号验证搬到忙碌判断前 | 等待时无效编号不再得到 busy |
在示例目录运行:
node fault-demo.mjs本次输出是:
correct:检查通过
reinsert-task:检查失败:完成任务应保留原列表顺序
save-map:检查失败:保存方应收到数组
validate-first:检查失败:等待时应先报告 busy每个比较进程使用相同的迁移检查。正常实现退出 0,三个对照各自退出 1;演示再核对这些预期退出码。它自己正常结束,表示观察到了这些通过与失败。
也可以单独运行 node probe.mjs save-map,观察退出码 1。不要为了得到通过而改掉数组断言;应当先判断自己是否确实准备改变保存接口。
这三项反例说明检查能发现列出的差异,不意味着所有可能的迁移错误都已经覆盖。
19. 检查失败以后,先区分哪一类问题 #
重构过程中出现失败,不应当立刻把预期改成程序刚输出的值。先读失败信息,判断是哪一种情况。
如果原实现通过,新实现改变了本次要求保留的结果,例如列表顺序,就修改新实现。这个时候,失败是在提醒我们回到约定。
如果检查要求私有变量继续叫 tasks、数组循环必须保留某种写法,而这些不属于接口承诺,则检查可能把内部实现固定得过细。应当把观察移到结果或外部动作上,但保留它原本想保护的行为。
如果团队决定修改既有规则,例如允许修改排队,就承认这是新行为,单独写出成功、失败和等待方式,再修改相应预期。不要把它藏在一次结构整理里。
还可能只是检查运行方式有问题,例如导入路径改错、异步操作没等待、临时文件相互干扰。错误信息提供线索,但最终仍要核对具体条件。
前一步通过的完整版本在这里很有用。它允许我们比较一个较小的变化,而不必靠记忆推测几百行修改中哪一处导致了差异。
20. 真实文件检查仍然不能省略 #
每个阶段都接上同一个真实保存函数,完成 t1 后重新读取 JSON,预期仍然是按原顺序排列的两条任务。
失败检查则在本次临时目录里选择一个父目录不存在的路径,确认操作返回 save-failed,当前完成标记仍为 [false, false]。
这两种检查对四个版本各运行一次,共 8 项。它们不依赖读者已有任务文件,每项新建自己的临时目录,并等待清理结束。[3]
受控保存容易观察“尚未结束”,真实文件检查则确认新的任务实现与保存函数接起来后,写出的内容仍符合原约定。内部迁移可能只在传递保存参数时出错,所以原先的真实文件检查,到了重构时也仍有用途。
本次没有启动完整页面,没有多个进程同时写入,也没有制造部分写入、断电或崩溃。读回数组正确,不能推导出这些更强的条件也已经得到验证。
21. 小步修改不等于每一行都单独停一次 #
应当把一步划到什么程度,可以看它是否承担一个清楚的目的、能否完成后运行、失败时是否容易说清需要回看哪些变化。
本例把内部重命名单独处理,再把容器管理与计算迁移分开。这能减少同时变化的因素。若一个很小的函数只需要改一个局部名称,当然不必人为再拆出若干步骤。
相反,改容器的同时增加自动重试、修改错误结果、改变保存格式,即使最后只有一个提交,读者也很难判断每项变化服务于什么目的。
提交可以按可独立理解的逻辑组织;工作过程则应经常保持可运行和可检查。两者不要求机械地一一对应。
也不需要一直把过渡代码和旧版本留在产品里。确认最终实现采用以后,再清理未使用的连接和旧路径,并继续检查实际入口。本章保留历史文件,是教学材料的用途;最终所有者运行时已不再导入旧的数组计算函数。
22. 测试之外,还要问改完是否更合适 #
相同检查通过,只说明已经检查的行为在这些条件下符合预期。它不说明新的组织方式一定更易读,也不说明一次迁移值得花费这些维护工作。
我们可以重新列出本次得失:内部现在按编号取得任务;原来的数组计算和来回连接已从最终路径移除;外部仍使用数组。但构造 Map、保存前转数组、新计算模块和对象共享规则,也都需要理解。
对于只有两条任务的程序,这些成本可能超过收益。对一个已经明确按编号组织内部数据的项目,这个迁移过程可以提供参考,结论仍应根据实际调用、维护问题与测量来判断。
因此,一次完整的重构说明应当回答两组问题:哪些行为保留、怎样核对;哪些内部安排改变、为什么值得改变。只有第一组,可能保留了一个没有必要的新结构;只有第二组,又可能把功能变化当成代码整理带过去。
23. 下载、命令与验证范围 #
下载第八篇完整示例,解压后进入 software-design-08-examples。无需安装第三方依赖:
node --version
node --test behavior.test.mjs map.test.mjs file.test.mjs faults.test.mjs
node stages-demo.mjs
node fault-demo.mjs只观察最终阶段的公共行为与真实文件,可以运行:
node --test --test-name-pattern "^03-map-direct" behavior.test.mjs file.test.mjs本次在 macOS arm64、Node.js 25.9.0 上运行,完整测试为 65 项通过,没有跳过或取消。具体组成是:12 项公共行为检查应用到 4 个版本,共 48 项;Map 计算 5 项;真实文件 8 项;独立进程比较 4 项。
其中的 48 项重复使用同一组约定,数量增加不表示发现错误的能力自动增加。我们保留这些重复运行,是为了让每个可运行阶段都接受相同判断。
包内 README.md 说明阶段、文件和反例的预期退出码。完整代码块、阶段命令、演示结果及解压包均已实际验证;没有测量性能,也没有证明任意输入或任意运行环境中的完全等价。
任务仍限定为合法、编号唯一的三个简单字段;保存应最终结束,不内部等待同一所有者的其他修改。保存正常结果仍要求 undefined,同步抛错和拒绝仍转换为 save-failed。本次没有增加取消、期限、排队、启动加载或故障恢复。
可以继续做两项练习。第一,把列表顺序明确改成“未完成在前”,先写出新约定,说明哪些检查应当变化,哪些保存与失败约定仍应保留。第二,在最后实现上把一段现有逻辑移入有名字的函数,再从原处调用它,也就是做一次小范围函数提取。先说清它减少了哪一段阅读负担,再运行原有检查,注意不要扩大错误捕获范围。这两项是练习,不属于本次已实现的功能。
下一篇进入持久化:把保存、退出和重新读取接起来,明确初始化、损坏拒绝与旧格式升级,再检查新增需求对模块边界的影响。返回系列目录,可以查看完整阅读顺序。
参考资料 #
资料访问日期为 2026-10-15。外部资料用于解释定义和接口规则,本篇迁移顺序、约定选择与结论范围由任务案例及实际运行确定。
- Martin Fowler:Definition of Refactoring。引用限于作者对重构目的、内部结构与可观察行为的定义。
- TC39:Map 对象与相关操作。用于核对键值构造、查找、更新、删除与遍历;在线规范为持续更新的工作稿。
- Node.js 25.9.0:Promise 文件接口。用于真实临时目录、写入、读取与清理;本次没有检查所有文件系统故障。
- Node.js 25.9.0:测试运行器。用于版本名称过滤与异步检查;断言及受控保存的具体解释见第七篇。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗