第 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.mjs

14 项检查继续通过。我们已经确认:在当前条件下,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。外部资料用于解释定义和接口规则,本篇迁移顺序、约定选择与结论范围由任务案例及实际运行确定。

  1. Martin Fowler:Definition of Refactoring。引用限于作者对重构目的、内部结构与可观察行为的定义。
  2. TC39:Map 对象与相关操作。用于核对键值构造、查找、更新、删除与遍历;在线规范为持续更新的工作稿。
  3. Node.js 25.9.0:Promise 文件接口。用于真实临时目录、写入、读取与清理;本次没有检查所有文件系统故障。
  4. Node.js 25.9.0:测试运行器。用于版本名称过滤与异步检查;断言及受控保存的具体解释见第七篇。

一起讨论

选中正文中的文字,可以针对这段内容发起讨论。

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论