# 重构中的行为保持：换一种实现，怎样确认功能没变

作者：Hex4C59 · 发布于 2026-10-15

原文：https://hex4c59.cc/articles/software-design-08-refactoring/


待办事项程序已经能完成任务、等待保存、报告失败，也有一组检查。现在想把内部保存任务的数组换成 Map，调用者仍然使用 `listTasks()` 和 `completeTask(id)`。

函数名称没变，正常完成任务时也能返回成功，是否就算改好了？未必。完成一条任务后，列表顺序可能变了；保存函数收到的可能不再是数组；等待期间收到无效编号，返回原因也可能与以前不同。它们都可能影响调用者，即使我们没有打算增加任何功能。

本篇讨论**重构**：为使代码更容易理解和修改，在约定的可观察行为保持不变的前提下，调整代码内部的组织或实现。Martin Fowler 的定义把“内部结构改变”和“可观察行为保持”放在一起；本文会把后者具体写成当前程序需要保留的结果、顺序和外部动作。[1]

做法是先确定修改范围，再把迁移分成能单独运行和检查的小步。测试为已经检查的条件提供证据，不代替接口约定，也不自动证明所有情况都相同。

读者需要能阅读简单 JavaScript，并理解 `await` 等待 Promise 的结果；Promise 是表示一项操作最终结果的对象。[第六篇](/articles/software-design-06-async-errors/)解释异步保存，[第七篇](/articles/software-design-07-testability/)解释断言和受控保存。本文会复用那组检查，再补上与迁移有关的观察。

## 1. 先有一个值得做的修改，再谈怎么重构

当前只有两条任务：写周报和整理笔记。用数组完全可以完成这些功能。这里选择“内部改为按编号存储”作为练习目标，便于观察内部表示改变时，哪些外部约定仍需保留。

**Map** 是 JavaScript 提供的一种集合，按键保存值。本例用任务编号作为键，用任务对象作为值，例如编号 `t1` 对应写周报。它支持按键取得已有任务。[2] 文中把数组和 Map 这类保存多条数据的结构统称为容器。

仅凭这点，不能推导出当前程序应该迁移，更不能声称换完一定更快。本章没有测量速度或内存。迁移会增加 Map 的构造、读取和保存前转换，读者也需要理解另一种数据结构。

[第四篇](https://hex4c59.cc/articles/software-design-04-module-boundaries/)比较过数组与 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` 与第七篇的异步任务所有者逐字节相同。它继续使用原来的初始任务、复制函数和完成计算。

所谓**基线**，在这里就是开始修改之前已经核对过的版本。我们先对它运行相同的检查，确认没有把旧程序原本就存在的问题，误认为后面某一步刚引入的问题。

在解压的示例目录执行：

```sh
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`，明确它代表当前已经采用的数据。

例如，读取和采用的位置变成：

```js
// 01-rename.mjs 中的局部片段。
let currentTasks = copyTasks(initialTasks);
function listTasks() {
  return copyTasks(currentTasks);
}
// 保存正常结束后：
currentTasks = proposal.nextTasks;
```

这一步不改 `listTasks`、`completeTask`，也不改返回对象里的 `task` 字段。调用者不需要知道局部变量换了名字。

重命名还应区分代码引用和字符串。`initial-tasks.mjs` 是文件路径，不能因为里面也有 `tasks` 就一起替换。公开结果里的字符串和属性名，也不能靠全文替换顺手改掉。

运行：

```sh
node --test --test-name-pattern "^01-rename" behavior.test.mjs file.test.mjs
```

这一阶段同样为 14 项通过。此时没有引入新的容器问题，后面就可以把注意力转向数据表示，而不是同时怀疑一次大范围重命名。

## 7. 换容器以前，先看 Map 提供了什么

下面是一段可以单独运行的例子：

```js
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));
```

输出：

```text
写周报
[ 't1', 't2' ]
```

`get` 按键取得值；`values()` 提供依次读取值的方式，方括号中的 `...` 把这些值收集成数组。这里读取的顺序对应键的插入顺序。[2]

当前程序的外部接口仍然需要数组。我们可以在内部使用 Map，在读取或保存的位置取出任务，再交给原来的 `copyTasks` 构造数组副本。

要让外部代码继续按原来的方式使用任务，读取和保存的位置都需要把内部 Map 转换成约定的数组。内部换了容器，外部格式不会自动保持。

## 8. 构造 Map 时，先隔开外部数据

`to-task-map.mjs` 的完整代码是：

```js
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。

关键位置如下，这是局部片段，完整文件在下载包里：

```js
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);
```

这一步没有把等待和失败判断删掉。上面的片段分处完整函数的不同位置，不能连在一起直接作为完成任务函数；特别是保存失败或无需修改时，完整代码会提前交回结果。

现在运行：

```sh
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` 的完整代码如下：

```js
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` 追踪保存：

```js
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 直接传过去，在本章环境中，下面这段完整代码会打印 `{}`：

```js
const tasks = new Map([
  ["t1", { id: "t1", title: "写周报", completed: true }],
]);
console.log(JSON.stringify(tasks));
```

这个正常完成的写入，可能只保存了空对象。若检查只关心“保存 Promise 没拒绝”，就看不到文件内容丢失。

所以，我们既检查保存函数收到的参数仍为数组，也在真实临时文件里写入后读回，比较编号、标题、完成标记和排列顺序。

这说明一处内部表示变化，可以通过边界转换被限制在内部；但若把转换删掉，外部文件格式也会跟着改变。那时需要讨论旧文件怎么读、其他调用者怎么处理，已经不是本次不改变外部格式的重构。

## 15. 更新值和删除后重建，顺序不同

在当前 Map 中，`t1` 先于 `t2` 插入。更新已有键的值，会保留它的位置；先删除 `t1` 再插入，则把这个键放到后面。[2]

下面两段是计算中的替代片段：

```js
// 保留原位置，本章正式实现使用这一句。
nextTasks.set(id, completedTask);
```

```js
// 会改变位置的对照，故意偏离现有顺序约定。
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` 把每一项约定检查应用到所有版本上，调用形式如下，这是删节示意：

```js
// 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 计算检查，帮助定位候选数据的计算问题；它们补充较小部分的验证，不替代四个版本共同接受的外部行为检查。

运行下面的阶段演示，可以看到四个版本都接受相同约定判断：

```sh
node stages-demo.mjs
```

四行均报告检查通过，并给出相同的完成结果；最后还会打印直接把 Map 转成 JSON 所得到的 `{}`。完整测试则继续检查失败、等待和副本，不只比较这四行正常结果。

## 18. 保留三个偏离约定的版本，验证检查的作用

下载包的 `faults/` 保存三种故意偏离现有约定的实现，不能当作推荐实现使用：

| 比较版本 | 改动 | 检查观察到的差异 |
| --- | --- | --- |
| `reinsert-task` | 删除任务键后再插入 | 完成任务后，原列表顺序改变 |
| `save-map` | 直接把内部 Map 交给保存 | 保存方收到的不是数组 |
| `validate-first` | 把编号验证搬到忙碌判断前 | 等待时无效编号不再得到 `busy` |

在示例目录运行：

```sh
node fault-demo.mjs
```

本次输出是：

```text
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.zip)，解压后进入 `software-design-08-examples`。无需安装第三方依赖：

```sh
node --version
node --test behavior.test.mjs map.test.mjs file.test.mjs faults.test.mjs
node stages-demo.mjs
node fault-demo.mjs
```

只观察最终阶段的公共行为与真实文件，可以运行：

```sh
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`。本次没有增加取消、期限、排队、启动加载或故障恢复。

可以继续做两项练习。第一，把列表顺序明确改成“未完成在前”，先写出新约定，说明哪些检查应当变化，哪些保存与失败约定仍应保留。第二，在最后实现上把一段现有逻辑移入有名字的函数，再从原处调用它，也就是做一次小范围函数提取。先说清它减少了哪一段阅读负担，再运行原有检查，注意不要扩大错误捕获范围。这两项是练习，不属于本次已实现的功能。

[下一篇进入持久化](https://hex4c59.cc/articles/software-design-09-persistence/)：把保存、退出和重新读取接起来，明确初始化、损坏拒绝与旧格式升级，再检查新增需求对模块边界的影响。返回[系列目录](https://hex4c59.cc/articles/software-design/)，可以查看完整阅读顺序。

## 参考资料

资料访问日期为 2026-10-15。外部资料用于解释定义和接口规则，本篇迁移顺序、约定选择与结论范围由任务案例及实际运行确定。

1. [Martin Fowler：Definition of Refactoring](https://martinfowler.com/bliki/DefinitionOfRefactoring.html)。引用限于作者对重构目的、内部结构与可观察行为的定义。
2. [TC39：Map 对象与相关操作](https://tc39.es/ecma262/multipage/keyed-collections.html#sec-map-objects)。用于核对键值构造、查找、更新、删除与遍历；在线规范为持续更新的工作稿。
3. [Node.js 25.9.0：Promise 文件接口](https://nodejs.org/download/release/v25.9.0/docs/api/fs.html#promises-api)。用于真实临时目录、写入、读取与清理；本次没有检查所有文件系统故障。
4. [Node.js 25.9.0：测试运行器](https://nodejs.org/download/release/v25.9.0/docs/api/test.html)。用于版本名称过滤与异步检查；断言及受控保存的具体解释见第七篇。

