# 模块边界与信息隐藏：哪些代码应该放在一起

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

原文：https://hex4c59.cc/articles/software-design-04-module-boundaries/


待办事项程序已经能列出任务、完成任务，也能按操作名称选择要执行的功能。[上一篇](https://hex4c59.cc/articles/software-design-03-indirection/)把这条调用路径讲清楚了：请求入口找到处理函数，处理函数取出任务编号，任务函数修改数据，再把结果交回来。

接下来整理代码时，很容易想到：列出任务是一个功能，完成任务是另一个功能，把它们各放一个文件，程序就更清楚了吗？如果以后改变任务在内部的保存方式，哪些文件需要跟着改？

本文的选择是：在当前这样小的程序里，把创建、查找和修改同一份任务数据的代码放在一起。这些操作都要知道任务在内部怎样保存，改变保存方式时也需要一起调整。请求名称和页面显示各有自己的变化原因，可以继续留在外面。等内部确实出现需要分开维护的部分，再按这些需要拆分。

**模块**可以先理解为一组共同承担某项工作的代码。它向其他代码提供使用方式，内部负责完成具体工作。**模块边界**说明哪些工作和决定由这组代码负责，其他代码通过什么方式与它配合。这里的边界首先是职责和使用方式的划分。

**信息隐藏**指把某些实现决定留在模块内部，让使用者不必知道它们，也不必跟随它们的变化修改。比如任务内部使用数组，还是使用按编号保存任务的 Map 表，页面只需要列出和完成任务时，可以通过函数使用功能，而不直接读取内部容器。

本文面向能阅读简单 JavaScript 的读者，沿用同一个任务程序，不要求了解架构术语。我们会比较两种代码组织方式，主动把数组换成 Map，运行相同请求，再检查修改位置。示例只处理程序运行期间的数据，没有文件保存或实际页面；这次变化用来观察设计，不是性能优化的测量。

## 先区分：文件与模块分别解决什么问题

文件解决代码放在哪里的问题。模块还需要回答：这组代码负责什么，其他代码怎样使用它，哪些事情由它自己决定。

假设一个文件只负责完成任务，它就有清楚的入口。但如果它必须知道另外三个文件怎样保存任务、怎样排列字段、怎样修改列表，文件虽然很短，理解它时仍要追踪那些关系。

反过来，一份代码提供列出和完成两项功能，不一定意味着职责混乱。它们可能共同维护同一份任务数据，需要依靠同一套编号、查找和修改规则。是否放在一起，需要看这些规则之间的关系。

在 JavaScript 里，使用 `import` 和 `export` 的代码文件也称为语言模块。`export` 让文件提供一个功能，`import` 让另一个文件取得它。本文的 `.mjs` 文件按照这种模块方式运行，具体规则可以查看 [Node.js 的 JavaScript 模块文档](https://nodejs.org/download/release/v25.9.0/docs/api/esm.html#introduction)。Node.js 是在浏览器外运行 JavaScript 的环境。

讨论设计时，一个模块也可能由几个文件共同实现。因此，本文会分别观察文件的改动位置和使用者必须知道的细节，不把“一个文件”和“一项完整职责”画等号。

## 上一篇已经有一条值得保留的边界

上一篇的 `createTaskList` 创建一份任务数据，再返回两个函数：

```js
const taskList = createTaskList();
taskList.listTasks();
taskList.completeTask("t1");
```

这是使用方式的片段，假设已经取得 `createTaskList`。完整运行命令在后面给出。

函数是可以按名称执行的一段代码。执行函数叫调用，发起执行的代码叫调用者；传进去的信息叫参数，交回的结果叫返回值。这里，调用者提供任务编号，就能让 `completeTask` 完成对应任务。

内部任务数组没有直接交出来。调用者需要知道任务接口的使用方式，以及成功、失败和数据变化的约定；它不用知道数组变量叫什么、查找时使用哪一种循环。

这里的**接口**仍是其他代码使用功能的方式，**契约**仍是双方依靠的条件和行为保证。接口和契约把外部使用方式与内部工作联系起来。信息隐藏要判断的是：满足这些约定之后，还有哪些决定可以由实现自己调整。

本篇保留上一篇的任务实现作为一种方案，再构造按操作分文件的方案作比较。当前问题是这些已经存在的代码怎样组织，文件保存留到后续需求再讨论。

## 先写清比较时不能改变的行为

两种方案都使用同样的演示数据：

```js
export const defaultTasks = [
  { id: "t1", title: "写周报", completed: false },
  { id: "t2", title: "整理笔记", completed: false },
];
```

这是 `initial-tasks.mjs` 的内容。`id` 是编号，`title` 是标题，`completed` 表示是否完成；`false` 表示未完成，`true` 表示已完成。对象中这些有名称的数据项叫字段。

为了比较代码组织，而不是偷偷更换功能，我们固定以下行为：

| 情况 | 本篇要求保留的结果 |
| --- | --- |
| 列出任务 | 返回带 `id`、`title`、`completed` 的任务数组 |
| 首次完成 `t1` | 成功，`changed: true`，只把 `t1` 标为完成 |
| 再次完成 `t1` | 成功，`changed: false` |
| 编号缺失、类型错误或全为空白 | `invalid-id`，任务不变 |
| 编号格式合法但不存在 | `not-found`，任务不变 |
| 调用者修改返回的数据 | 不改动内部任务 |
| 创建两份任务列表 | 两份数据互不影响 |

编号仍按原样匹配，`" t1 "` 不会自动变成 `"t1"`。本篇还明确固定列表顺序：按初始任务放入的顺序返回，完成任务不改变顺序。以后如果需要排序，这是需要单独讨论的外部行为。

创建时的初始数据要求编号是唯一、非空的字符串，标题是字符串，完成标记是布尔值，也就是 `true` 或 `false`。本例假设这些初始数据已经合法，没有实现导入损坏文件时的数据检查。这个条件很重要：重复编号在数组和 Map 中可能产生不同结果，不能在比较时略去它。

## 第一种安排：按操作把代码分到不同文件

先看按操作拆分的数组版本，位于示例包的 `by-operation/array` 目录。

| 文件 | 负责什么 | 需要知道的内部细节 |
| --- | --- | --- |
| `state.mjs` | 创建任务数据 | 任务放在数组里，字段有哪些 |
| `list-tasks.mjs` | 列出任务 | 从 `state.tasks` 逐项读取，复制字段 |
| `complete-task.mjs` | 完成任务 | 从 `state.tasks` 查找并修改目标 |
| `index.mjs` | 组装对外使用方式 | 把同一份数据交给两个操作 |

`state` 在这里是保存当前任务数据的对象。程序在某个时刻保留的数据，通常叫**状态**；本篇先用这个名字指出数据在哪里，谁能修改它留到下一篇继续讨论。

`state.mjs` 的完整代码是：

```js
export function createState(initialTasks) {
  const tasks = [];
  for (const task of initialTasks) {
    tasks.push({ id: task.id, title: task.title, completed: task.completed });
  }
  return { tasks };
}
```

`[]` 创建数组，`for ... of` 逐项读取传入的任务，`push` 把新对象放入数组。这里复制三项简单字段，后续修改不会直接改写传进来的任务对象。

`list-tasks.mjs` 的完整代码是：

```js
export function listTasks(state) {
  const result = [];
  for (const task of state.tasks) {
    result.push({ id: task.id, title: task.title, completed: task.completed });
  }
  return result;
}
```

完成任务的文件保留上一篇的编号检查、查找和修改规则，区别是函数接收 `state` 与 `id` 两个参数。它通过 `state.tasks` 找到数组，再逐项比较编号。完整文件在示例包中，这里先观察它依靠的数据路径。

这种安排容易按操作名找文件。需要读列出任务时，打开 `list-tasks.mjs` 即可开始阅读；某个操作变长时，也有单独维护的位置。代价是两项操作都必须了解共享数据对象的表示方式。

## 把这些文件组装成同一个任务接口

`index.mjs` 没有特别的语言能力，它是我们选定的入口文件名。它的完整内容如下：

```js
import { defaultTasks } from "../../initial-tasks.mjs";
import { createState } from "./state.mjs";
import { listTasks } from "./list-tasks.mjs";
import { completeTask } from "./complete-task.mjs";

export function createTaskList(initialTasks = defaultTasks) {
  const state = createState(initialTasks);
  return {
    listTasks() {
      return listTasks(state);
    },
    completeTask(id) {
      return completeTask(state, id);
    },
  };
}
```

`./` 表示相对于当前文件所在目录，`../` 表示上一层目录；这里的路径让不同文件互相取得功能。调用 `createTaskList` 时，只创建一次 `state`，两个对外函数使用同一份数据。

返回对象里的 `listTasks` 与导入的 `listTasks` 处在不同位置：前者是调用者使用的方法，后者是本文件取得的实现函数。对象上提供的函数也常叫方法。前者不要求调用者提供 `state`，它在内部把保存好的 `state` 交给实现。

从外面看，这个接口与上一篇一致。调用者仍然写 `taskList.completeTask("t1")`，不需要自己组装状态对象。按操作拆分的方案也已经替外部使用者隐藏了一部分细节；后面比较的是这些细节在实现内部又分散到了哪里。

## 第二种安排：把共同维护的决定放在一起

另一种方案位于 `by-decision/array/tasks.mjs`，沿用上一篇的 `createTaskList`。除了把演示初始值移到共用文件，函数内部的实现保持相同。

它在创建时准备任务数组，在同一个函数内部定义 `listTasks` 和 `completeTask`，最后只交回这两个操作。任务数据的创建、查找、修改和结果复制，都由这一组代码维护。

这个方案把“任务内部怎样表示”当作一项共同决定。列出任务和完成任务围绕这项决定配合，暂时没有各自拆成独立文件。调用者通过功能使用任务，不需要拿到内部数组。

这就是本例的信息隐藏：使用者可以完成任务，但不用知道我们通过循环查找，还是通过另一种容器按编号取得任务。隐藏的内容需要能够明确指出来。如果只说“这个模块隐藏了实现”，却说不清使用者还需要知道哪些字段和操作，判断就太粗了。

Parnas 在 1972 年的论文 [On the Criteria To Be Used in Decomposing Systems into Modules](https://www.cs.lafayette.edu/~gexia/cs301/resources/parnas.html) 中，比较了按处理过程与按需要隐藏的设计决定组织模块的方式。他提出的一个判断方向，是寻找难以确定或可能变化的决定，让相应模块承担这些决定。本文借用这个问题来检查任务表示，不逐段翻译论文案例。

在我们的程序里，任务怎样保存在运行中的数据容器里，是值得检查的一项决定。它是否真的会变化、变化时会影响谁，还需要用具体方案观察。论文给出的分析视角，并不替这个小程序自动作出选择。

## 主动更换一次表示，观察变化会传播到哪里

现在把内部任务数组换成 Map，仍然保存同样的两条任务。

数组按顺序存放任务，原来的完成操作需要逐项比较编号。Map 保存一组“名称对应值”的关系；这里用任务编号作为查找名称，对应的值是任务对象。这与上一篇的处理器表使用同一种语言功能，但保存的内容不同：上一篇保存函数，这里保存任务。

Map 中的这个查找名称通常叫**键**，对应保存的内容叫**值**。例如键为 `"t1"`，值为写周报这条任务。任务在程序内部用数组还是 Map 保存，叫它的**内部表示**。

这次替换是一个受控制的设计实验：改变内部表示，保留前面的行为清单。我们没有测量查找时间，也没有据此声称 Map 对当前两条任务更快。真实项目是否要替换容器，应当有自己的需求依据。

按操作拆分的版本需要协调三处代码：创建容器时改用 Map，列出时取得其中的任务对象，完成时按编号查找。集中维护的版本也要调整这三项工作，只是它们位于同一个实现文件。

## Map 版本实际改变了哪些写法

创建任务数据时，`new Map()` 创建空表，`set` 把编号与任务放进去。下面是按操作拆分的 Map 版 `state.mjs` 的完整内容：

```js
export function createState(initialTasks) {
  const tasks = new Map();
  for (const task of initialTasks) {
    tasks.set(task.id, { id: task.id, title: task.title, completed: task.completed });
  }
  return { tasks };
}
```

完成任务的查找部分原来是下面这段循环。这是从函数中取出的片段，不是一个独立文件：

```js
let task;
for (const item of state.tasks) {
  if (item.id === id) {
    task = item;
    break;
  }
}
```

Map 版改为：

```js
const task = state.tasks.get(id);
```

`get(id)` 取得对应任务，没有对应编号时返回 `undefined`。后面的不存在检查、完成标记修改和结果复制继续沿用原来的规则。

列出任务时，需要遍历 Map 保存的任务值，而不是直接照搬数组遍历。下面是 Map 版 `list-tasks.mjs` 的完整内容：

```js
export function listTasks(state) {
  const result = [];
  for (const task of state.tasks.values()) {
    result.push({ id: task.id, title: task.title, completed: task.completed });
  }
  return result;
}
```

`values()` 让循环逐项取得 Map 中保存的值。Map 按放入顺序遍历；我们的初始化按原数组顺序放入任务，完成时只修改已有任务的字段，所以保留了比较所要求的列表顺序。语言规则见 [Map 的值遍历](https://tc39.es/ecma262/multipage/keyed-collections.html#sec-map.prototype.values)及其关联的迭代算法，示例还用编号 `"10"`、`"2"` 检查了顺序没有被当作数字大小重新排列。

集中维护的方案使用同样的 Map 操作。下面是 `by-decision/map/tasks.mjs` 的完整内容：

```js
import { defaultTasks } from "../../initial-tasks.mjs";

// 每次创建一份独立的演示数据。initialTasks 须已满足任务数据约定。
export function createTaskList(initialTasks = defaultTasks) {
  const tasks = new Map();
  for (const task of initialTasks) {
    tasks.set(task.id, { id: task.id, title: task.title, completed: task.completed });
  }

  function completeTask(id) {
    if (typeof id !== "string" || id.trim() === "") {
      return { ok: false, reason: "invalid-id" };
    }

    const task = tasks.get(id);

    if (task === undefined) {
      return { ok: false, reason: "not-found" };
    }

    const changed = task.completed === false;
    task.completed = true;

    return {
      ok: true,
      changed,
      task: {
        id: task.id,
        title: task.title,
        completed: task.completed,
      },
    };
  }

  function listTasks() {
    const result = [];
    for (const task of tasks.values()) {
      result.push({ id: task.id, title: task.title, completed: task.completed });
    }
    return result;
  }

  return { completeTask, listTasks };
}
```

这里没有把任务操作重新改成请求分发。`completeTask` 仍然只接收编号，`listTasks` 仍然返回普通任务数组。Map 留在创建函数内部，调用者拿到的还是那两个方法。

返回给调用者的任务对象由 `id`、`title`、`completed` 重新组装。内部容器换掉以后，外部数据形状没有跟着变。保留这一步转换，也是保留接口约定的一部分。

## 只换创建数据的文件，会出现什么问题

有时把变量集中到一个 `state.mjs`，会产生一种直觉：数据已经集中，以后只改它就够了。现在可以用实际代码检查这个推断。

`mixed-demo.mjs` 故意混用 Map 版的状态创建函数和数组版的完成函数：

```js
import { defaultTasks } from "./initial-tasks.mjs";
import { createState } from "./by-operation/map/state.mjs";
import { completeTask } from "./by-operation/array/complete-task.mjs";

const state = createState(defaultTasks);
console.log("新表示中确实存在 t1：", state.tasks.has("t1"));
console.log("只换状态文件，仍用旧完成代码：", JSON.stringify(completeTask(state, "t1")));
console.log("t1 的完成标记：", state.tasks.get("t1").completed);
```

`has` 检查 Map 中有没有对应键。`JSON.stringify` 把结果对象转成 JSON 文本，便于在终端看清字段。运行得到：

```text
新表示中确实存在 t1： true
只换状态文件，仍用旧完成代码： {"ok":false,"reason":"not-found"}
t1 的完成标记： false
```

任务存在，旧完成代码却没有找到它。原因是直接遍历 Map 时，每项提供的是键和值组成的一对数据；旧代码期待每项直接是带 `id` 的任务对象。查找条件因此不再符合新的表示。Map 的默认遍历规则见规范中的 [Map entries](https://tc39.es/ecma262/multipage/keyed-collections.html#sec-map.prototype.entries)。

创建数据的位置虽然只有一处，知道容器怎样使用的代码却不止一处。把数据放到单独文件以后，其他操作仍然需要知道该怎样遍历和查找它。这个反例帮助我们找到真实的依赖：完成操作仍然依靠 `state.tasks` 的容器类型和遍历方式。

故意混用的版本单独放在演示文件里，不是推荐实现。四个正式比较版本都已完整调整，并通过同一组行为检查。

## 三个文件与一个文件，分别说明了什么

运行 `node compare.mjs`，会比较两组数组版与 Map 版的实际文件内容：

```text
按操作拆分，发生变化的文件： state.mjs, list-tasks.mjs, complete-task.mjs
集中隐藏表示，发生变化的文件： tasks.mjs
```

这份清单来自示例包中的文件字节比较，不是估计的修改范围。按操作拆分时，三个文件分别了解容器的创建、遍历和查找；集中维护时，这些工作在 `tasks.mjs` 里一起调整。前者的 `index.mjs` 没有变化，两个方案共用的请求入口与调用代码也没有变化。

这个结果支持一个有限判断：在当前很短的任务实现中，把使用同一容器的代码集中维护，可以让相关调整更靠近，也减少跨文件核对的工作。两种实现要修改的实际逻辑仍是创建、遍历与查找三项，并没有因为放进一个文件就只剩下一项工作。

文件数也不能直接代表模块设计的好坏。假如团队把整个 `by-operation` 目录视为一个任务模块，三个文件只是内部实现的分工，对外仍然只有两项任务操作，那么这次修改也可以说发生在同一个设计模块内部。

因此，需要同时问两个问题：变化涉及哪些文件，以及外部调用者是否还可以按原来的任务接口工作。前一个问题帮助估计编辑和阅读位置，后一个问题帮助判断使用者有没有被迫了解新细节。

如果单个文件已经很难阅读，把内部函数拆出去可能有帮助。只要外部仍然通过任务接口使用功能，内部文件之间需要协调，也可以是合理的安排。当前示例不到需要这样拆分的规模，我会保留集中版本；这个选择不需要扩展成“所有同类代码都必须在一个文件”。

## 同一个调用者怎样使用四个版本

示例包保留两种组织方式各自的数组版与 Map 版，一共四个完整版本。共享的 `consumer.mjs` 通过任务接口发送同一组请求，观察列出、首次完成、重复完成和失败结果。

“调用者”在这里就是使用任务功能的代码。它不是特定的人，也不一定是页面。为了比较，我们使用一个本地演示函数代替页面，让结果可以直接打印和检查。

下面是在示例根目录可运行的使用方式：

```js
import { createTaskList } from "./by-decision/map/tasks.mjs";
import { runScenario } from "./consumer.mjs";

console.log(JSON.stringify(runScenario(createTaskList())));
```

`runScenario` 的参数是一份提供任务操作的对象。它不知道内部保存的是数组还是 Map，也不知道任务操作各有自己的文件，还是共同位于一个文件。请求入口仍使用上一篇的处理器表代码，文件内容没有变化。

选择不同实现的演示入口必须取得相应的创建函数，这个组装位置知道使用哪个版本。我们在比较程序中列出四条导入路径，以便分别运行；任务调用者本身保持同一份代码。实际项目如果更换模块文件路径，取得模块的组装位置也可能需要修改，不能把这样的修改漏出清单。

这进一步说明，两种方案在最外层接口上都有效地隔开了表示变化。本例没有证明按操作拆分一定让页面跟着修改，演示输出也没有出现这种情况。它暴露的是实现内部对共享表示的共同依赖，以及当前规模下不同拆法的阅读成本。

## 没有导出变量，为什么仍可能交出内部数据

`export` 和 `import` 可以控制文件明确提供哪些名称。不过，只检查有没有 `export tasks`，还不足以判断任务数据是否藏好。

示例包的 `leaky-store.mjs` 是一个独立反例：

```js
import { defaultTasks } from "./initial-tasks.mjs";

// 反例：返回内部数组，使用者可以绕过任务操作直接修改数据。
export function createLeakyStore() {
  const tasks = [];
  for (const task of defaultTasks) {
    tasks.push({ id: task.id, title: task.title, completed: task.completed });
  }
  function getTasks() {
    return tasks;
  }
  return { getTasks };
}
```

数组变量留在函数内部，但 `getTasks` 返回的值仍然指向那个数组。使用者因此持有了对内部数组的**引用**，可以读写同一份数据。

`leak-demo.mjs` 修改返回的第一条任务标题，然后再次读取。它还对正式方案的返回副本作相同修改：

```text
返回内部数组后： 外部改写
返回任务副本后： 写周报
```

返回内部数组时，调用者已经能够绕过任务操作修改标题，未来也可能增加或删除数据。任务模块虽然有自己的文件，却不能再独自依靠“修改都经过既定函数”这个条件。

正式方案逐项创建返回对象，当前字段只有字符串与布尔值。调用者修改副本不会影响内部任务，这项约定与上一篇一致。如果任务后来包含嵌套对象，复制到什么程度还需要重新检查，不能把当前三项简单字段的结果推广成任意深度的数据都已隔开。

信息隐藏在这里是一种代码合作方式，不是防止同一进程里的任意代码攻击，也不是数据加密。我们主要限制普通调用者需要依靠哪些细节，以及是否会意外共享可修改的内部对象。

## 哪些细节可以隐藏，哪些约定必须继续说明

把容器藏起来，不等于把任务本身的所有信息都藏起来。调用者要显示标题、判断完成状态，就需要取得相关数据。

本例希望隐藏的是容器类型、查找办法和内部变量。希望保持稳定的是完成任务的使用方式、结果含义、失败原因，以及返回任务的字段。两类内容可以放在同一个文件里实现，却承担不同作用。

| 如果发生这项变化 | 当前边界能否让任务调用者保持原样 |
| --- | --- |
| 内部数组换成 Map | 可以，前提是继续履行原有任务接口 |
| 查找循环换成按编号取得任务 | 可以，当前结果与失败约定保持相同 |
| 内部新增辅助字段，但返回值不变 | 可能可以，需要检查它是否改变可观察行为 |
| 返回字段从 `completed` 改成另一种形式 | 不能直接假设可以，使用该字段的代码需要配合 |
| 任务顺序从初始顺序改成按标题排序 | 已改变本篇固定的列表行为，需要明确调整约定 |
| 完成任务改为保存成功后才算成功 | 当前结果与执行方式可能不够，需要重新设计 |

“可观察行为”就是使用者能够通过功能看见的结果和变化。内部做了什么调整，只有在这些结果继续符合原约定时，才能说使用者仍可照原样配合。

边界能限制一部分变化的传播。它不能承诺任何产品需求都只改内部实现。把哪些规则写进接口，也是选择：约定越具体，使用者越容易依靠它，未来改变这些行为时需要照顾的内容也越明确。

## 请求入口、任务数据和显示文字，各自放什么

把任务表示集中维护以后，还要避免把所有相关代码都塞进任务文件。

请求入口知道 `"list"` 和 `"complete"` 这些操作名称，负责选择功能。任务模块知道编号、任务数据和完成规则。页面需要的文字、颜色与布局，则属于显示任务的代码。

它们使用同一份任务信息，但并不因此拥有完全相同的职责：

| 需要作出的决定 | 当前适合维护的位置 | 变化时主要检查谁 |
| --- | --- | --- |
| 哪个操作名选择哪个功能 | 请求入口 | 发送请求的代码与登记关系 |
| 任务内部怎样创建、查找和修改 | 任务实现 | 共同维护任务数据的代码 |
| 用户看到“已完成”还是其他文字 | 使用结果的显示代码 | 具体显示位置 |

假设页面把完成标记从“已完成”换成“完成了”。任务数据仍然只需要 `completed: true`，没必要为了显示文字改变任务的内部保存方式。反过来，把任务数据换成 Map，也不应要求显示代码去调用 `get` 或 `values`。

如果某项显示规则本身就是任务规则的一部分，例如任务已过期时是否仍允许完成，就需要重新区分“怎样显示”和“是否允许操作”。名称里都有任务两个字，不足以证明它们应该放在一起。

这里还没有实现真实页面，表格描述的是当前接口支持的职责划分。页面与文件保存需求加入时，需要继续核对这种划分是否适用。

## 集中维护不等于把所有工作塞进一个大模块

当前任务实现很短，列出和完成共同依靠任务表示，把它们放在一起容易检查。等需求增加，原来的集中方案也可能需要重新考虑。

假设任务程序开始支持搜索、导入、导出、提醒和多人共享。如果一个文件同时决定数据保存格式、提醒时机、网络请求和页面提示，它就要配合许多不同类型的变化。任务容器明明没变，只改提醒规则也要进入这个大文件，相关代码还可能互相读写变量，维护会变得困难。

这种情况下可以先列出实际变化：提醒规则是否独立维护，文件格式是否独立演变，搜索是否有单独的数据结构。根据这些关系拆分，比“每满两百行拆一个文件”更能说明理由。具体行数有助于发现阅读困难，但不是职责的定义。

内部辅助函数也可以单独放文件。例如将来任务对象包含复杂的日期或标签转换，转换代码可能值得独立维护。是否拆出这一部分，要说明它提供什么使用方式、哪些其他代码会使用，以及更换任务表示时它是否需要知道新细节。

拆出去以后，仍可以让整个任务目录对外只提供清楚的任务接口。内部可以有几个合作文件，外部不需要直接取得它们的中间数据。团队如果希望所有调用者遵守这条边界，需要在实际代码组织和审查中维持它；简单地把目录命名为 `internal` 不会自动阻止别的文件导入。

本例没有实现这些新增功能，也没有为它们提前创建空目录。它们说明会改变当前选择的条件：功能确实独立变化，或者原来的内部实现已经影响阅读与维护时，再决定更细的分工。

## 多加一层存储函数，是否就能解决所有变化

另一个容易想到的办法，是在操作与数据之间再加一组函数，例如按编号查任务、写回任务、取得所有任务。这样数组和 Map 的差别有机会集中在更底下。

这项安排可能有实际用途。如果以后几个任务功能都要以相同方式取得数据，而数据来源开始独立变化，集中提供查找和保存能力，可以减少重复处理。不过，它也会增加一套调用者需要理解和实现者需要维护的约定。

要检查这层接口究竟交出什么。如果所谓查找函数直接返回内部可修改的任务对象，完成操作仍然可以改它的任意字段，写回规则也可能被绕过。容器类型虽然藏住了，字段与修改方式还在影响其他代码。需要隐藏哪些决定，并没有因为出现一个新函数而自动回答。

如果存储函数只提供原始读写，而完成任务本身有编号检查、重复完成和失败时数据不变的规则，那么这些规则还需要有明确位置。把它们全部分散到调用者，也会让同一项任务操作出现不同解释。

对当前示例，两项操作和一小份数据还不需要额外的通用存储层。先保留已有任务接口已经能满足需求。以后真的加入文件保存时，还要说明读取失败、保存失败和操作成功的关系，那时才有足够材料比较新的分层。这正是本系列后续要继续检查的变化。

## 拆分之前，怎样作出一个具体判断

可以先选一种准备应对的变化，沿着现在的代码找一遍：改变任务容器，需要谁理解新表示；改变公开操作名称，需要谁配合；改变页面文字，是否会碰到任务数据规则。

随后再评价拆分后的安排。它是否把原本分散的同一决定集中维护，是否减少调用者必须知道的细节，新增的接口是否有清楚用途，出错时是否容易找到负责的代码。这些问题能够对应实际修改，不需要先给方案贴上“更解耦”的标签。

如果看到“耦合”这个词，可以先把它理解为代码之间的依赖关系。在当前例子中，列出和完成的实现都依靠共享数据的形状，容器变化时就必须协调。我们希望调整的是依赖哪些细节、由谁维护它们。通过接口使用另一段代码，仍然是一种正常依赖。

同样，看到“内聚”，可以先看模块内部的工作为什么聚在一起。列出和完成都需要维护任务表示，这是当前共同维护的理由；请求名称和页面文字变化不同，不能仅因为它们也与任务有关就全部放进来。这两个词在这里描述的是代码之间的具体关系。

对于这份小程序，我会继续使用集中维护任务表示的版本。支持这个选择的是当前实现短、两项操作共同依靠数据规则、外部接口已经够用。之后如果内部代码变长，或者某项工作需要独立变化，再用相同方法重新检查，而不是把今天的布局当成永久答案。

## 运行四个版本，检查修改有没有改变功能

[下载完整示例包](software-design-04-examples.zip)，解压后进入 `software-design-04-examples` 目录。

本次验证日期为 **2026 年 10 月 15 日**，环境为 macOS arm64、Node.js **25.9.0**。示例使用 Node.js 自带功能，没有第三方依赖，不需要安装依赖包。

```sh
node demo.mjs
node compare.mjs
node mixed-demo.mjs
node leak-demo.mjs
node --test contracts.test.mjs
```

`demo.mjs` 依次运行按操作拆分的数组版与 Map 版，以及集中维护表示的数组版与 Map 版。每组打印方案名称，再打印相同请求产生的结果与最终任务数据。四组数据输出一致：首次完成成功且确实改变数据，重复完成成功但不再改变，未知任务、非法编号和未知操作分别保留原来的失败原因。

`compare.mjs` 打印前面的实际文件变化清单。`mixed-demo.mjs` 演示只更换一部分实现时怎样破坏行为，`leak-demo.mjs` 演示返回内部数组与返回副本的区别。两个反例是为了观察问题而构造的，没有混入四个正式方案。

最后一个命令运行 **28 项测试**，本次全部通过。测试是将输入与预期结果写成可以重复执行的检查。它们覆盖四个方案的成功与失败、重复完成、副本、初始数据复制、列表顺序、独立任务实例和共享请求入口，还检查了文件变化清单与两个反例。

这里的相同结果是对所列任务、输入和代码的检查。它不等于已经检查了任意初始数据或所有未来功能。尤其是初始数据合法且编号唯一的条件，仍然需要由使用示例的代码满足。

下载包含 22 个文件。`initial-tasks.mjs` 提供相同的初始数据，`request-entry.mjs` 与上一篇的表分发入口一致，`consumer.mjs` 是共同调用者。两组比较目录保留四个完整版本，其余文件负责演示、文件比较、测试与运行说明。四个版本均可直接运行和对照。

## 换一个内部字段，再检查一次边界

可以把 Map 版任务对象的内部完成字段从 `completed` 改为 `done`，对外仍然返回 `completed`。初始数据也继续接收原来的 `completed` 字段，由内部创建过程转换。

先列出需要协调的三项工作：初始化时怎样建立 `done`，完成时怎样读取和修改它，返回任务时怎样组装 `completed`。分别在两种组织方式中实现，再运行原来的行为检查。记录修改涉及的文件和函数，以及请求入口、共同调用者是否需要知道这项内部变化。

接着只在纸上修改一个不同条件：页面也决定改为读取 `done`。这次改变的是外部数据约定，原来依靠 `completed` 的代码不再能保持原样。比较这两个练习，检查自己是否分清了“内部表示变化”和“对外约定变化”。这两项是留给读者的变式，下载包保留正文比较的原始版本。

当你能说清一条任务数据由谁创建、哪些函数能够改它、返回副本意味着什么，就可以继续下一篇。[下一篇：状态与副作用](https://hex4c59.cc/articles/software-design-05-state-effects/)会讨论谁负责数据变化，比较计算、保存和采用的顺序，以及操作失败后应当保留怎样的数据。

## 来源与结论的适用范围

本文核对了 Parnas 的模块划分原论文大学转录版、Node.js 25.9.0 模块文档，以及 TC39 的 Map 相关规则，资料访问日期为 **2026 年 10 月 15 日**。具体链接放在对应论点旁。

论文提供分析模块的视角，语言资料说明示例写法怎样执行，四个版本与测试提供本例的运行证据。任务案例、文件组织和当前选择是本文的比较与判断。下一次需求改变时，还需要重新检查这个判断成立的条件。

