第 04 篇
模块边界与信息隐藏:哪些代码应该放在一起
沿用待办事项程序,解释模块、边界与信息隐藏,比较按操作拆文件和集中维护任务表示,通过数组改为 Map 观察修改传播、行为保持与拆分成本。
Hex4C59 22 分钟阅读 更新于 2026-10-17
软件工程·原创·概念与设计
待办事项程序已经能列出任务、完成任务,也能按操作名称选择要执行的功能。上一篇把这条调用路径讲清楚了:请求入口找到处理函数,处理函数取出任务编号,任务函数修改数据,再把结果交回来。
接下来整理代码时,很容易想到:列出任务是一个功能,完成任务是另一个功能,把它们各放一个文件,程序就更清楚了吗?如果以后改变任务在内部的保存方式,哪些文件需要跟着改?
本文的选择是:在当前这样小的程序里,把创建、查找和修改同一份任务数据的代码放在一起。这些操作都要知道任务在内部怎样保存,改变保存方式时也需要一起调整。请求名称和页面显示各有自己的变化原因,可以继续留在外面。等内部确实出现需要分开维护的部分,再按这些需要拆分。
模块可以先理解为一组共同承担某项工作的代码。它向其他代码提供使用方式,内部负责完成具体工作。模块边界说明哪些工作和决定由这组代码负责,其他代码通过什么方式与它配合。这里的边界首先是职责和使用方式的划分。
信息隐藏指把某些实现决定留在模块内部,让使用者不必知道它们,也不必跟随它们的变化修改。比如任务内部使用数组,还是使用按编号保存任务的 Map 表,页面只需要列出和完成任务时,可以通过函数使用功能,而不直接读取内部容器。
本文面向能阅读简单 JavaScript 的读者,沿用同一个任务程序,不要求了解架构术语。我们会比较两种代码组织方式,主动把数组换成 Map,运行相同请求,再检查修改位置。示例只处理程序运行期间的数据,没有文件保存或实际页面;这次变化用来观察设计,不是性能优化的测量。
先区分:文件与模块分别解决什么问题 #
文件解决代码放在哪里的问题。模块还需要回答:这组代码负责什么,其他代码怎样使用它,哪些事情由它自己决定。
假设一个文件只负责完成任务,它就有清楚的入口。但如果它必须知道另外三个文件怎样保存任务、怎样排列字段、怎样修改列表,文件虽然很短,理解它时仍要追踪那些关系。
反过来,一份代码提供列出和完成两项功能,不一定意味着职责混乱。它们可能共同维护同一份任务数据,需要依靠同一套编号、查找和修改规则。是否放在一起,需要看这些规则之间的关系。
在 JavaScript 里,使用 import 和 export 的代码文件也称为语言模块。export 让文件提供一个功能,import 让另一个文件取得它。本文的 .mjs 文件按照这种模块方式运行,具体规则可以查看 Node.js 的 JavaScript 模块文档。Node.js 是在浏览器外运行 JavaScript 的环境。
讨论设计时,一个模块也可能由几个文件共同实现。因此,本文会分别观察文件的改动位置和使用者必须知道的细节,不把“一个文件”和“一项完整职责”画等号。
上一篇已经有一条值得保留的边界 #
上一篇的 createTaskList 创建一份任务数据,再返回两个函数:
const taskList = createTaskList();
taskList.listTasks();
taskList.completeTask("t1");这是使用方式的片段,假设已经取得 createTaskList。完整运行命令在后面给出。
函数是可以按名称执行的一段代码。执行函数叫调用,发起执行的代码叫调用者;传进去的信息叫参数,交回的结果叫返回值。这里,调用者提供任务编号,就能让 completeTask 完成对应任务。
内部任务数组没有直接交出来。调用者需要知道任务接口的使用方式,以及成功、失败和数据变化的约定;它不用知道数组变量叫什么、查找时使用哪一种循环。
这里的接口仍是其他代码使用功能的方式,契约仍是双方依靠的条件和行为保证。接口和契约把外部使用方式与内部工作联系起来。信息隐藏要判断的是:满足这些约定之后,还有哪些决定可以由实现自己调整。
本篇保留上一篇的任务实现作为一种方案,再构造按操作分文件的方案作比较。当前问题是这些已经存在的代码怎样组织,文件保存留到后续需求再讨论。
先写清比较时不能改变的行为 #
两种方案都使用同样的演示数据:
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 的完整代码是:
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 的完整代码是:
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 没有特别的语言能力,它是我们选定的入口文件名。它的完整内容如下:
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 中,比较了按处理过程与按需要隐藏的设计决定组织模块的方式。他提出的一个判断方向,是寻找难以确定或可能变化的决定,让相应模块承担这些决定。本文借用这个问题来检查任务表示,不逐段翻译论文案例。
在我们的程序里,任务怎样保存在运行中的数据容器里,是值得检查的一项决定。它是否真的会变化、变化时会影响谁,还需要用具体方案观察。论文给出的分析视角,并不替这个小程序自动作出选择。
主动更换一次表示,观察变化会传播到哪里 #
现在把内部任务数组换成 Map,仍然保存同样的两条任务。
数组按顺序存放任务,原来的完成操作需要逐项比较编号。Map 保存一组“名称对应值”的关系;这里用任务编号作为查找名称,对应的值是任务对象。这与上一篇的处理器表使用同一种语言功能,但保存的内容不同:上一篇保存函数,这里保存任务。
Map 中的这个查找名称通常叫键,对应保存的内容叫值。例如键为 "t1",值为写周报这条任务。任务在程序内部用数组还是 Map 保存,叫它的内部表示。
这次替换是一个受控制的设计实验:改变内部表示,保留前面的行为清单。我们没有测量查找时间,也没有据此声称 Map 对当前两条任务更快。真实项目是否要替换容器,应当有自己的需求依据。
按操作拆分的版本需要协调三处代码:创建容器时改用 Map,列出时取得其中的任务对象,完成时按编号查找。集中维护的版本也要调整这三项工作,只是它们位于同一个实现文件。
Map 版本实际改变了哪些写法 #
创建任务数据时,new Map() 创建空表,set 把编号与任务放进去。下面是按操作拆分的 Map 版 state.mjs 的完整内容:
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 };
}完成任务的查找部分原来是下面这段循环。这是从函数中取出的片段,不是一个独立文件:
let task;
for (const item of state.tasks) {
if (item.id === id) {
task = item;
break;
}
}Map 版改为:
const task = state.tasks.get(id);get(id) 取得对应任务,没有对应编号时返回 undefined。后面的不存在检查、完成标记修改和结果复制继续沿用原来的规则。
列出任务时,需要遍历 Map 保存的任务值,而不是直接照搬数组遍历。下面是 Map 版 list-tasks.mjs 的完整内容:
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 的值遍历及其关联的迭代算法,示例还用编号 "10"、"2" 检查了顺序没有被当作数字大小重新排列。
集中维护的方案使用同样的 Map 操作。下面是 by-decision/map/tasks.mjs 的完整内容:
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 版的状态创建函数和数组版的完成函数:
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 文本,便于在终端看清字段。运行得到:
新表示中确实存在 t1: true
只换状态文件,仍用旧完成代码: {"ok":false,"reason":"not-found"}
t1 的完成标记: false任务存在,旧完成代码却没有找到它。原因是直接遍历 Map 时,每项提供的是键和值组成的一对数据;旧代码期待每项直接是带 id 的任务对象。查找条件因此不再符合新的表示。Map 的默认遍历规则见规范中的 Map entries。
创建数据的位置虽然只有一处,知道容器怎样使用的代码却不止一处。把数据放到单独文件以后,其他操作仍然需要知道该怎样遍历和查找它。这个反例帮助我们找到真实的依赖:完成操作仍然依靠 state.tasks 的容器类型和遍历方式。
故意混用的版本单独放在演示文件里,不是推荐实现。四个正式比较版本都已完整调整,并通过同一组行为检查。
三个文件与一个文件,分别说明了什么 #
运行 node compare.mjs,会比较两组数组版与 Map 版的实际文件内容:
按操作拆分,发生变化的文件: state.mjs, list-tasks.mjs, complete-task.mjs
集中隐藏表示,发生变化的文件: tasks.mjs这份清单来自示例包中的文件字节比较,不是估计的修改范围。按操作拆分时,三个文件分别了解容器的创建、遍历和查找;集中维护时,这些工作在 tasks.mjs 里一起调整。前者的 index.mjs 没有变化,两个方案共用的请求入口与调用代码也没有变化。
这个结果支持一个有限判断:在当前很短的任务实现中,把使用同一容器的代码集中维护,可以让相关调整更靠近,也减少跨文件核对的工作。两种实现要修改的实际逻辑仍是创建、遍历与查找三项,并没有因为放进一个文件就只剩下一项工作。
文件数也不能直接代表模块设计的好坏。假如团队把整个 by-operation 目录视为一个任务模块,三个文件只是内部实现的分工,对外仍然只有两项任务操作,那么这次修改也可以说发生在同一个设计模块内部。
因此,需要同时问两个问题:变化涉及哪些文件,以及外部调用者是否还可以按原来的任务接口工作。前一个问题帮助估计编辑和阅读位置,后一个问题帮助判断使用者有没有被迫了解新细节。
如果单个文件已经很难阅读,把内部函数拆出去可能有帮助。只要外部仍然通过任务接口使用功能,内部文件之间需要协调,也可以是合理的安排。当前示例不到需要这样拆分的规模,我会保留集中版本;这个选择不需要扩展成“所有同类代码都必须在一个文件”。
同一个调用者怎样使用四个版本 #
示例包保留两种组织方式各自的数组版与 Map 版,一共四个完整版本。共享的 consumer.mjs 通过任务接口发送同一组请求,观察列出、首次完成、重复完成和失败结果。
“调用者”在这里就是使用任务功能的代码。它不是特定的人,也不一定是页面。为了比较,我们使用一个本地演示函数代替页面,让结果可以直接打印和检查。
下面是在示例根目录可运行的使用方式:
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 是一个独立反例:
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 修改返回的第一条任务标题,然后再次读取。它还对正式方案的返回副本作相同修改:
返回内部数组后: 外部改写
返回任务副本后: 写周报返回内部数组时,调用者已经能够绕过任务操作修改标题,未来也可能增加或删除数据。任务模块虽然有自己的文件,却不能再独自依靠“修改都经过既定函数”这个条件。
正式方案逐项创建返回对象,当前字段只有字符串与布尔值。调用者修改副本不会影响内部任务,这项约定与上一篇一致。如果任务后来包含嵌套对象,复制到什么程度还需要重新检查,不能把当前三项简单字段的结果推广成任意深度的数据都已隔开。
信息隐藏在这里是一种代码合作方式,不是防止同一进程里的任意代码攻击,也不是数据加密。我们主要限制普通调用者需要依靠哪些细节,以及是否会意外共享可修改的内部对象。
哪些细节可以隐藏,哪些约定必须继续说明 #
把容器藏起来,不等于把任务本身的所有信息都藏起来。调用者要显示标题、判断完成状态,就需要取得相关数据。
本例希望隐藏的是容器类型、查找办法和内部变量。希望保持稳定的是完成任务的使用方式、结果含义、失败原因,以及返回任务的字段。两类内容可以放在同一个文件里实现,却承担不同作用。
| 如果发生这项变化 | 当前边界能否让任务调用者保持原样 |
|---|---|
| 内部数组换成 Map | 可以,前提是继续履行原有任务接口 |
| 查找循环换成按编号取得任务 | 可以,当前结果与失败约定保持相同 |
| 内部新增辅助字段,但返回值不变 | 可能可以,需要检查它是否改变可观察行为 |
返回字段从 completed 改成另一种形式 | 不能直接假设可以,使用该字段的代码需要配合 |
| 任务顺序从初始顺序改成按标题排序 | 已改变本篇固定的列表行为,需要明确调整约定 |
| 完成任务改为保存成功后才算成功 | 当前结果与执行方式可能不够,需要重新设计 |
“可观察行为”就是使用者能够通过功能看见的结果和变化。内部做了什么调整,只有在这些结果继续符合原约定时,才能说使用者仍可照原样配合。
边界能限制一部分变化的传播。它不能承诺任何产品需求都只改内部实现。把哪些规则写进接口,也是选择:约定越具体,使用者越容易依靠它,未来改变这些行为时需要照顾的内容也越明确。
请求入口、任务数据和显示文字,各自放什么 #
把任务表示集中维护以后,还要避免把所有相关代码都塞进任务文件。
请求入口知道 "list" 和 "complete" 这些操作名称,负责选择功能。任务模块知道编号、任务数据和完成规则。页面需要的文字、颜色与布局,则属于显示任务的代码。
它们使用同一份任务信息,但并不因此拥有完全相同的职责:
| 需要作出的决定 | 当前适合维护的位置 | 变化时主要检查谁 |
|---|---|---|
| 哪个操作名选择哪个功能 | 请求入口 | 发送请求的代码与登记关系 |
| 任务内部怎样创建、查找和修改 | 任务实现 | 共同维护任务数据的代码 |
| 用户看到“已完成”还是其他文字 | 使用结果的显示代码 | 具体显示位置 |
假设页面把完成标记从“已完成”换成“完成了”。任务数据仍然只需要 completed: true,没必要为了显示文字改变任务的内部保存方式。反过来,把任务数据换成 Map,也不应要求显示代码去调用 get 或 values。
如果某项显示规则本身就是任务规则的一部分,例如任务已过期时是否仍允许完成,就需要重新区分“怎样显示”和“是否允许操作”。名称里都有任务两个字,不足以证明它们应该放在一起。
这里还没有实现真实页面,表格描述的是当前接口支持的职责划分。页面与文件保存需求加入时,需要继续核对这种划分是否适用。
集中维护不等于把所有工作塞进一个大模块 #
当前任务实现很短,列出和完成共同依靠任务表示,把它们放在一起容易检查。等需求增加,原来的集中方案也可能需要重新考虑。
假设任务程序开始支持搜索、导入、导出、提醒和多人共享。如果一个文件同时决定数据保存格式、提醒时机、网络请求和页面提示,它就要配合许多不同类型的变化。任务容器明明没变,只改提醒规则也要进入这个大文件,相关代码还可能互相读写变量,维护会变得困难。
这种情况下可以先列出实际变化:提醒规则是否独立维护,文件格式是否独立演变,搜索是否有单独的数据结构。根据这些关系拆分,比“每满两百行拆一个文件”更能说明理由。具体行数有助于发现阅读困难,但不是职责的定义。
内部辅助函数也可以单独放文件。例如将来任务对象包含复杂的日期或标签转换,转换代码可能值得独立维护。是否拆出这一部分,要说明它提供什么使用方式、哪些其他代码会使用,以及更换任务表示时它是否需要知道新细节。
拆出去以后,仍可以让整个任务目录对外只提供清楚的任务接口。内部可以有几个合作文件,外部不需要直接取得它们的中间数据。团队如果希望所有调用者遵守这条边界,需要在实际代码组织和审查中维持它;简单地把目录命名为 internal 不会自动阻止别的文件导入。
本例没有实现这些新增功能,也没有为它们提前创建空目录。它们说明会改变当前选择的条件:功能确实独立变化,或者原来的内部实现已经影响阅读与维护时,再决定更细的分工。
多加一层存储函数,是否就能解决所有变化 #
另一个容易想到的办法,是在操作与数据之间再加一组函数,例如按编号查任务、写回任务、取得所有任务。这样数组和 Map 的差别有机会集中在更底下。
这项安排可能有实际用途。如果以后几个任务功能都要以相同方式取得数据,而数据来源开始独立变化,集中提供查找和保存能力,可以减少重复处理。不过,它也会增加一套调用者需要理解和实现者需要维护的约定。
要检查这层接口究竟交出什么。如果所谓查找函数直接返回内部可修改的任务对象,完成操作仍然可以改它的任意字段,写回规则也可能被绕过。容器类型虽然藏住了,字段与修改方式还在影响其他代码。需要隐藏哪些决定,并没有因为出现一个新函数而自动回答。
如果存储函数只提供原始读写,而完成任务本身有编号检查、重复完成和失败时数据不变的规则,那么这些规则还需要有明确位置。把它们全部分散到调用者,也会让同一项任务操作出现不同解释。
对当前示例,两项操作和一小份数据还不需要额外的通用存储层。先保留已有任务接口已经能满足需求。以后真的加入文件保存时,还要说明读取失败、保存失败和操作成功的关系,那时才有足够材料比较新的分层。这正是本系列后续要继续检查的变化。
拆分之前,怎样作出一个具体判断 #
可以先选一种准备应对的变化,沿着现在的代码找一遍:改变任务容器,需要谁理解新表示;改变公开操作名称,需要谁配合;改变页面文字,是否会碰到任务数据规则。
随后再评价拆分后的安排。它是否把原本分散的同一决定集中维护,是否减少调用者必须知道的细节,新增的接口是否有清楚用途,出错时是否容易找到负责的代码。这些问题能够对应实际修改,不需要先给方案贴上“更解耦”的标签。
如果看到“耦合”这个词,可以先把它理解为代码之间的依赖关系。在当前例子中,列出和完成的实现都依靠共享数据的形状,容器变化时就必须协调。我们希望调整的是依赖哪些细节、由谁维护它们。通过接口使用另一段代码,仍然是一种正常依赖。
同样,看到“内聚”,可以先看模块内部的工作为什么聚在一起。列出和完成都需要维护任务表示,这是当前共同维护的理由;请求名称和页面文字变化不同,不能仅因为它们也与任务有关就全部放进来。这两个词在这里描述的是代码之间的具体关系。
对于这份小程序,我会继续使用集中维护任务表示的版本。支持这个选择的是当前实现短、两项操作共同依靠数据规则、外部接口已经够用。之后如果内部代码变长,或者某项工作需要独立变化,再用相同方法重新检查,而不是把今天的布局当成永久答案。
运行四个版本,检查修改有没有改变功能 #
下载完整示例包,解压后进入 software-design-04-examples 目录。
本次验证日期为 2026 年 10 月 15 日,环境为 macOS arm64、Node.js 25.9.0。示例使用 Node.js 自带功能,没有第三方依赖,不需要安装依赖包。
node demo.mjs
node compare.mjs
node mixed-demo.mjs
node leak-demo.mjs
node --test contracts.test.mjsdemo.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 的代码不再能保持原样。比较这两个练习,检查自己是否分清了“内部表示变化”和“对外约定变化”。这两项是留给读者的变式,下载包保留正文比较的原始版本。
当你能说清一条任务数据由谁创建、哪些函数能够改它、返回副本意味着什么,就可以继续下一篇。下一篇:状态与副作用会讨论谁负责数据变化,比较计算、保存和采用的顺序,以及操作失败后应当保留怎样的数据。
来源与结论的适用范围 #
本文核对了 Parnas 的模块划分原论文大学转录版、Node.js 25.9.0 模块文档,以及 TC39 的 Map 相关规则,资料访问日期为 2026 年 10 月 15 日。具体链接放在对应论点旁。
论文提供分析模块的视角,语言资料说明示例写法怎样执行,四个版本与测试提供本例的运行证据。任务案例、文件组织和当前选择是本文的比较与判断。下一次需求改变时,还需要重新检查这个判断成立的条件。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗