# 状态与副作用：谁能修改数据，失败后保留什么

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

原文：https://hex4c59.cc/articles/software-design-05-state-effects/


待办事项程序已经把任务数据留在自己的模块里，其他代码通过列出和完成任务的函数使用它。[上一篇](https://hex4c59.cc/articles/software-design-04-module-boundaries/)讨论了哪些代码适合放在一起，以及内部数组换成 Map 时，怎样让调用者继续按原接口使用。

现在看另一个问题：完成任务时，数据由谁修改？如果程序还要保存这次修改，保存失败后应该保留哪份数据？页面收到“失败”，再读取任务，却发现任务已经完成，这两件事是否符合我们原来的约定？

本文的选择是：先明确由哪段代码维护当前内存数据，并决定是否用新数据替换它。如果要求保存成功之后才接受修改，可以先计算新数据，执行保存，正常结束后再采用新数据。这里“采用”就是把新数据作为后续读取和修改的当前任务。这样能把计算规则与外部动作分开，也能说明每一步失败时留下什么。它仍然需要保存函数履行自己的约定，不能独自保证内存和文件同时恢复原状。

**状态**是程序为了继续工作而保留的当前数据。**副作用**是函数除了交回结果之外，还造成的外部变化，例如改变已经存在的任务数据、写文件或打印信息。**状态所有者**在本文中指负责维护当前数据、决定接受哪些修改的那段代码。

本篇沿用集中维护任务的数组版本，便于逐项观察数据复制与采用过程。阅读需要能看懂简单 JavaScript，保存顺序和陌生名称会随案例解释。示例在 Node.js 中运行，包含受控制的保存失败和真实临时文件；操作都在返回前完成，没有实现后台保存、多进程协作或完整的启动恢复。

## 同样的参数，为什么第二次结果不同

本例仍有写周报和整理笔记两条任务，编号分别为 `t1`、`t2`。`id` 是编号，`title` 是标题，`completed` 表示是否完成。两条任务起初都未完成，返回值里的 `changed` 表示本次是否实际改变了任务，`ok` 表示操作是否成功。

先运行一个与前几篇一致的短例子。它使用示例包里保留的原地修改版本：

```js
import { createTaskList } from "./mutable-tasks.mjs";

const taskList = createTaskList();
console.log(taskList.listTasks()[0].completed);
console.log(taskList.completeTask("t1").changed);
console.log(taskList.completeTask("t1").changed);
```

依次输出 `false`、`true`、`false`：任务最初未完成，第一次操作改变数据，再次操作没有新的改变。

两次 `completeTask("t1")` 的参数相同，结果里的 `changed` 却不同。原因是第一次执行改变了程序保留的数据，第二次执行读到的是更新后的任务。要解释结果，只看这一行调用的参数已经不够，还需要知道当前任务状态。

函数是可以执行的一段代码，执行它叫调用，交给它的信息叫参数，执行后交回的值叫返回值。`listTasks` 让调用者观察当前数据，`completeTask` 根据当前数据决定是否需要修改。

《Structure and Interpretation of Computer Programs》（简称 SICP）的 [JavaScript 对照版关于局部状态的讨论](https://sicp.sourceacademy.org/chapters/3.1.1.html)也用多次操作的结果说明状态的作用。本文使用待办任务观察同一件事：前面执行过什么，会影响后来怎样工作。这个现象本身很正常，问题是我们怎样管理它。

## 副作用具体发生在哪里

原来的完成函数中有这一行：

```js
task.completed = true;
```

这是函数内部的一条语句，完整任务实现位于 `mutable-tasks.mjs`。它把已有任务对象的完成字段改为 `true`。操作返回以后，`listTasks` 还能读到这次变化，所以这次执行留下了返回值之外的影响。

任务数组虽然藏在模块里，这次修改仍是完成函数的副作用。是否让外部代码直接看见某个变量，与操作是否改变后续工作依靠的数据，是两个不同问题。

打印信息也是一种副作用。`console.log` 会把内容写到终端；写文件则会改变程序之外保存的字节。它们可能正是功能需要完成的工作。

需要分清的是作用的范围。修改内存任务、保存文件、显示成功提示，可以是同一次用户操作里的三项工作，但它们各有发生时机，也可能分别失败。仅在返回值中写一个 `ok`，不会自动把已经发生的变化合并成可以统一撤销的一件事。

## 数据的所有者负责什么

在当前程序里，`createTaskList` 每次创建一份独立任务数据，返回列出与完成两个方法。对象上提供的函数也叫方法。调用者通过方法提出操作，由任务实现决定是否接受。

任务数据的所有者需要承担几项实际工作：保存当前那份数据，按任务规则处理修改，交回不会意外改写内部数据的结果，并让下一次操作读到已经接受的变化。

这是一种代码职责，不是 JavaScript 为变量附加的特殊身份。把变量命名为 `owner`，或者写一个 `const`，都不会自动形成这项约束。`const` 约束变量能否重新指向另一个值；其中对象的字段仍可能被修改。这里原来的数组与任务对象就是例子。

我们通过接口让修改集中发生：页面取得任务副本用于显示，完成操作仍交给任务模块。页面上的副本记录的是读取时的数据，不能因为它也有 `completed` 字段，就把它当成另一份可以独立决定真实进度的任务列表。

如果程序里存在两份独立任务列表，它们各自的内存状态本来互不影响。等它们写入同一处保存位置时，事情会改变。后面会运行这个例子，观察为什么“每份数据都封装好了”还不够。

## 合法数据与正确操作，不是同一个要求

首先要说明任务数据在操作前后应当满足什么。例如，编号唯一、标题是字符串、完成标记是 `true` 或 `false`。这种需要在操作前后保持成立的条件，通常叫**不变量**。

本例假设初始任务已经满足这些条件，操作只改变目标任务的完成标记。它没有实现读取损坏文件或处理重复编号的功能。返回副本也只复制当前三个简单字段，嵌套对象需要另行决定。

但数据合法，不等于操作就符合约定。某次保存失败后，任务被标为完成，编号和标题可能仍然完全合法；如果我们承诺“失败时保留原任务”，这次操作依然违反了承诺。

因此，还要说明具体操作的前后关系：首次完成改变目标任务，再次完成不改变；编号错误或任务不存在时保留所有任务；保存失败以后，当前数据究竟采用旧版本还是新版本。

最后一项没有脱离需求的统一答案。它取决于界面向使用者表达什么，保存是不是这次操作成功的必要条件，以及失败以后有没有继续保存的办法。我们会先固定一份规则，再判断实现是否遵守。

## 加入保存需求，先写出成功和失败的含义

内存中的任务数据用于当前程序继续工作。文件中的任务数据用于程序之外保存结果。现在有了两处需要考虑的数据，更新其中一处，并不等于另一处也已更新。

本篇为保存版选定以下规则：

| 情况 | 保存版应当做什么 |
| --- | --- |
| 编号非法或任务不存在 | 保留当前数据，不调用保存函数 |
| 首次完成有效任务 | 准备新数据，保存正常结束后采用它 |
| 保存函数报告失败 | 返回 `save-failed`，不采用新数据 |
| 当前任务已经完成 | 返回成功且 `changed: false`，不再次保存 |
| 使用者修改返回任务 | 保留内部当前数据 |

这里“当前数据”特指任务模块认可、供后续操作使用的内存数据。保存失败时文件是否已经改变，还要看保存函数做过什么，表格没有作出文件必然不变的承诺。

保存版保留原来的成功结果、编号要求和不存在的失败原因，同时增加了 `save-failed`。使用任务的页面需要理解这个新增原因，不能继续只按“编号非法或任务不存在”两种情况处理所有失败。

创建保存版任务列表时，会保留初始数据的副本，不自动写文件。初始数据中已完成的任务，再次完成也不会触发保存。这是当前约定的范围：保存发生在新的任务变化时，不能把一次 `changed: false` 当作“所有初始数据一定已经写进文件”的证明。

## 保存函数怎样与任务操作配合

为了比较顺序，我们把执行保存的函数交给任务模块，需要保存时再调用它。函数本身可以作为参数传递，这与上一篇把函数放进处理器表的能力相同。

本例选择一份简单的保存接口：保存函数接收完整任务副本，所有工作在返回前完成，成功时不交回额外结果，也就是返回 `undefined`；失败时抛出错误。`undefined` 在这里表示没有额外返回值。

**同步执行**指当前操作完成后才返回继续下一步。本文的保存函数必须这样工作，不能返回后才启动或继续保存，也不在执行期间重新调用同一任务列表的修改方法。它应按收到的任务内容执行保存。后面处理异步操作时，需要重新设计等待和错误传播，不能直接套用本篇顺序。

选择哪一份保存函数，由创建任务列表的代码决定。任务计算本身不用知道文件名、目录或者测试里怎样制造失败。这样可以分别观察任务规则和外部动作，而不必每次都依靠真的磁盘故障。

下面的实现会用到 `try` 与 `catch`。`try` 内的代码按顺序执行；出现抛出的错误时，正常执行中断，进入对应 `catch`。`catch` 可以把错误转换为我们约定的失败结果，但不会自动撤销此前执行过的语句。

## 先改当前数据，再保存，会留下什么

下面是 `unsafe-save.mjs` 的完整内容。它是针对当前“保存失败时不接受修改”约定构造的反例：

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

// 反例：已修改当前任务，再尝试保存。失败返回值不能撤销之前的修改。
export function createUnsafeTaskList(saveSnapshot, initialTasks = defaultTasks) {
  if (typeof saveSnapshot !== "function") {
    throw new TypeError("需要提供在返回前完成的保存函数");
  }
  const taskList = createTaskList(initialTasks);
  function completeTask(id) {
    const result = taskList.completeTask(id);
    if (result.ok === false || result.changed === false) {
      return result;
    }
    try {
      const saveResult = saveSnapshot(taskList.listTasks());
      if (saveResult !== undefined) {
        return { ok: false, reason: "save-failed" };
      }
    } catch {
      return { ok: false, reason: "save-failed" };
    }
    return result;
  }
  return { listTasks: taskList.listTasks, completeTask };
}
```

创建时先检查保存功能是不是函数。传入不合适的值会抛出 `TypeError`，这是表示操作与值不匹配的一类 JavaScript 错误。创建成功以后，完成操作先调用原来的任务函数，随后才尝试保存。

关键问题在于 `taskList.completeTask(id)` 已经修改了当前任务。保存函数再抛错，`catch` 只是交回 `{ ok: false, reason: "save-failed" }`，任务不会因此自动变回未完成。

`retry-demo.mjs` 提供一个受控制的保存函数：第一次调用故意抛错，之后调用则把任务副本收进数组，表示本次模拟保存成功。数组记录的是演示结果，不是真实文件。

反例得到：

```text
先改当前数据再保存
第一次： {"ok":false,"reason":"save-failed"}
失败后当前数据： true
再次尝试： {"ok":true,"changed":false,"task":{"id":"t1","title":"写周报","completed":true}}
保存调用次数： 1
成功保存份数： 0
```

第二次完成返回 `changed: false`，保存代码便直接跳过。于是第一次保存失败后，再点一次完成，可能既不会恢复旧任务，也不会重新保存。成功结果里的“没有新变化”，被错误地当成了“不需要再处理上次保存失败”。

这说明重试也需要纳入约定。只检查第一次有没有进入 `catch`，看不到后来为什么再也没有发出保存。

## 先改后存，也可以是另一份明确的产品规则

有些功能允许用户先看到编辑结果，暂时没有保存也继续保留。假设我们的待办程序也选择这种方式，那么保存失败后保留已完成状态，就可以符合新的产品规则。

但需要同时告诉使用者这份状态还没有保存，并提供继续保存的办法。内存里的完成标记和保存进度是两种信息，仅有 `completed: true` 不能表达“完成了，但上次保存失败”。

例如可以另外记录“未保存”“保存失败”，让重试针对保存动作，而不是依赖再次完成任务来触发。这样的方案还要决定什么时候清除标记、重复保存是否允许、退出前怎样处理尚未保存的数据。

这些是另一套合理的设计方向，本例没有实现这些标记和界面。前面的代码之所以是反例，是因为它不符合我们已经选定的失败约定，也没有实现另一套保存进度规则。比较顺序时，必须先知道希望使用者看到什么。

## 把计算与接受修改分成两步

回到本篇选定的规则，可以先问一个更小的问题：给定当前任务和编号，如果要完成它，新任务数据应该是什么？

回答这个问题时，不一定要立即改变当前任务。可以创建一份新数据，在上面计算结果，再把建议交给状态所有者决定是否采用。

只根据明确输入计算结果、不改变已有外部数据、也不执行文件或输出动作的函数，通常叫**纯函数**。这里比较“结果相同”指数据字段内容相同，不要求每次返回的是同一个对象。SICP 的[赋值与计算结果的讨论](https://sicp.sourceacademy.org/chapters/3.1.3.html)提供了区分计算与状态变化的分析背景。

纯函数可以创建并修改刚刚建立的局部对象。关键是调用者原来持有的数据没有被改写，也没有在计算之外执行保存或提示。读取会变化的外部数据，也会让结果不再只由明确输入决定。

本例先用 `copyTasks` 为三个简单字段创建副本。下面是 `copy-tasks.mjs` 的完整代码：

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

`for ... of` 逐项读取任务，`push` 把新对象放进结果数组。输入里的任务对象没有被拿来直接修改。这里继续保留合法、唯一编号和简单字段的初始数据条件。

## 计算函数交回什么，才足够供所有者判断

`plan-completion.mjs` 返回两部分：`result` 是使用任务功能的代码应该收到的操作结果，`nextTasks` 是建议采用的新任务数据。

编号错误、任务不存在或已经完成时，没有需要采用的新数据，`nextTasks` 为 `null`。这里的 `null` 表示没有这份建议，不表示当前任务列表被清空。只有成功且确实改变任务时，所有者才会使用它。

完整实现如下：

```js
import { copyTasks } from "./copy-tasks.mjs";

export function planCompletion(tasks, id) {
  if (typeof id !== "string" || id.trim() === "") {
    return { result: { ok: false, reason: "invalid-id" }, nextTasks: null };
  }

  const nextTasks = copyTasks(tasks);
  let target;
  for (const task of nextTasks) {
    if (task.id === id) {
      target = task;
      break;
    }
  }
  if (target === undefined) {
    return { result: { ok: false, reason: "not-found" }, nextTasks: null };
  }

  const changed = target.completed === false;
  target.completed = true;
  const result = {
    ok: true,
    changed,
    task: { id: target.id, title: target.title, completed: target.completed },
  };
  if (changed === false) {
    return { result, nextTasks: null };
  }
  return { result, nextTasks };
}
```

函数修改的是 `copyTasks` 刚刚产生的任务对象。原任务不会因为计算了一次完成结果，就已经变为完成。即使最后决定不采用，新数据也可以直接丢开，当前数据仍然存在。

结果里的 `task` 又单独组装了一份副本。这样，调用者修改操作结果，不会顺带修改等待采用的 `nextTasks`。操作结果供调用者读取和整理，建议采用的数据则由所有者决定是否作为当前任务，两者不能意外改到彼此。

计算函数本身不记住上一次调用。再次把原任务交给它，仍会计算出首次完成；把建议的新任务交给它，才会得到重复完成的结果。负责让下一次操作看见哪份数据的，是状态所有者。

## 由所有者采用新数据，保留原来的任务接口

先不加入保存，只把内存操作改为“先计算再采用”。`task-list.mjs` 的完整内容如下：

```js
import { defaultTasks } from "./initial-tasks.mjs";
import { copyTasks } from "./copy-tasks.mjs";
import { planCompletion } from "./plan-completion.mjs";

export function createTaskList(initialTasks = defaultTasks) {
  let tasks = copyTasks(initialTasks);
  function listTasks() {
    return copyTasks(tasks);
  }
  function completeTask(id) {
    const proposal = planCompletion(tasks, id);
    if (proposal.result.ok && proposal.result.changed) {
      tasks = proposal.nextTasks;
    }
    return proposal.result;
  }
  return { listTasks, completeTask };
}
```

这里使用 `let tasks`，因为变量需要在接受修改时指向新数组。`tasks = proposal.nextTasks` 是认可新状态的位置。只有计算成功且确实有变化时，代码才执行这一步。

对使用者来说，仍然是 `listTasks()` 和 `completeTask(id)`。两种内存实现处理同一组操作后，返回结果和最终任务数据一致；原地修改版继续保留在下载包里，便于对照。

`calculation-demo.mjs` 展示计算和采用的区别：

```text
计算后的原数据： false
建议采用的新数据： true
采用前： false
完成结果： {"ok":true,"changed":true,"task":{"id":"t1","title":"写周报","completed":true}}
采用后： true
```

这项拆分没有让整个程序失去状态。它把“不改变当前数据的计算”和“决定改变当前数据的动作”放到了不同位置。后者仍然有副作用，并且是任务功能需要的副作用。

## 先保存新数据，再决定采用

现在把保存步骤放在计算与采用之间。下面是 `save-before-adopt.mjs` 的完整实现：

```js
import { defaultTasks } from "./initial-tasks.mjs";
import { copyTasks } from "./copy-tasks.mjs";
import { planCompletion } from "./plan-completion.mjs";

export function createSavedTaskList(saveSnapshot, initialTasks = defaultTasks) {
  if (typeof saveSnapshot !== "function") {
    throw new TypeError("需要提供在返回前完成的保存函数");
  }
  let tasks = copyTasks(initialTasks);
  function listTasks() {
    return copyTasks(tasks);
  }
  function completeTask(id) {
    const proposal = planCompletion(tasks, id);
    if (proposal.result.ok === false || proposal.result.changed === false) {
      return proposal.result;
    }
    try {
      const saveResult = saveSnapshot(copyTasks(proposal.nextTasks));
      if (saveResult !== undefined) {
        return { ok: false, reason: "save-failed" };
      }
    } catch {
      return { ok: false, reason: "save-failed" };
    }
    tasks = proposal.nextTasks;
    return proposal.result;
  }
  return { listTasks, completeTask };
}
```

需要看清三步顺序：计算 `proposal`，调用保存函数，最后才让 `tasks` 指向新数组。出现错误或保存函数交回了不符合约定的额外返回值时，代码直接返回 `save-failed`，不会走到采用语句。

给保存函数的是 `nextTasks` 的又一份副本。保存函数即使保留这个参数，后来也不能通过它改写已经采用的内部任务。使用者拿到的操作结果和列表，也继续保持副本约定。

传入副本保护的是任务模块的内存数据。保存函数仍然有责任保存收到的正确内容；如果它自己改写标题再写出，或者根本没有保存却正常返回，任务模块无法仅靠这个接口证明外部结果正确。

本例还约定保存函数在返回前结束工作。返回其他值会被当作保存失败，这个检查能够拒绝不符合当前接口的返回形式，却不会撤销保存函数已经做过的事情。处理需要等待的保存操作，是下一篇需要重新设计的部分。

## 失败后重试，什么时候能够继续工作

回到第一次保存尚未修改外部数据就抛错的受控演示，新方案得到：

```text
先保存新数据再采用
第一次： {"ok":false,"reason":"save-failed"}
失败后当前数据： false
再次尝试： {"ok":true,"changed":true,"task":{"id":"t1","title":"写周报","completed":true}}
保存调用次数： 2
成功保存份数： 1
```

第一次失败后，所有者还认可旧任务。再次完成时，计算仍会产生一份需要采用的新数据，因此再次发起保存。保存函数这次正常返回，才接受新状态。

这符合本篇选定的内存失败规则，也修复了前面“重试跳过保存”的具体问题。它没有证明任意外部动作都可以安全地重复。后面会看到，如果第一次保存实际上已经写过数据，错误结果还可能留下不同情况。

| 阶段 | 当前任务数据 | 外部保存结果 |
| --- | --- | --- |
| 计算新任务 | 仍是旧数据 | 尚未调用保存 |
| 保存函数执行期间 | 仍是旧数据 | 由保存函数执行过程决定 |
| 保存正常返回后采用 | 变为新数据 | 按保存函数自己的约定处理成功 |
| 保存报告失败 | 保留旧数据 | 不能单凭失败结果判断是否改过 |

“内存保留旧数据”和“外部完全没变化”必须分别检查。它们依靠不同代码，也可能需要不同的失败恢复办法。

## 在真实临时文件里观察保存

到这里可以接上一次真实文件写入。`file-demo.mjs` 使用 Node.js 提供的文件功能，在系统临时目录中创建本次专用目录，演示结束后删除它，只操作自己创建的演示文件。

保存函数的核心写法如下。这是文件演示中的局部片段，`file` 指向本次创建的临时 `tasks.json`：

```js
const normal = createSavedTaskList(function saveSnapshot(tasks) {
  writeFileSync(file, JSON.stringify(tasks));
});
```

`JSON.stringify` 把任务数组转换成 JSON 文本；JSON 是一种表示数据的文本格式。`writeFileSync` 把文本写入文件，调用在操作结束后才返回。Node.js 的[同步文件接口说明](https://nodejs.org/download/release/v25.9.0/docs/api/fs.html#synchronous-api)描述了这种执行和错误方式。

演示再使用 `readFileSync` 读取文本，使用 `JSON.parse` 还原任务数据，观察保存结果。正常保存的输出是：

```text
正常保存： {"ok":true,"changed":true,"task":{"id":"t1","title":"写周报","completed":true}}
读回文件中的完成标记： true
```

随后保存函数故意写向一个没有父目录的位置。这次由真实文件接口报告路径不存在的错误，测试里核对错误代码为 `ENOENT`。任务模块交回失败，并保留当前数据：

```text
真实路径错误： {"ok":false,"reason":"save-failed"}
路径错误后当前数据： false
```

真实文件检查说明，这组临时路径下正常写入和路径失败时发生了什么。同步文件操作会等待执行结束，真实应用还需要考虑这段等待对使用过程的影响；本文没有测量界面响应时间。

## 保存已经改过文件，再报错怎么办

`file-demo.mjs` 还有一个合成的保存函数。它先把一小段不完整文本写入文件，再主动抛错：

```js
const partial = createSavedTaskList(function saveSnapshot() {
  writeFileSync(file, '[{"id":');
  throw new Error("合成的写入部分内容后失败");
});
```

这仍是文件演示里的片段。`throw` 抛出错误，`new Error` 创建用于说明失败的错误对象。这里主动安排先写后错，用来观察有过外部修改后再报告失败的情况，不声称操作系统在这次执行里真的发生了中途故障。

运行得到：

```text
写入部分内容后报错： {"ok":false,"reason":"save-failed"}
报错后当前数据： false
文件仍为合法 JSON： false
```

当前任务没有采用新数据，但文件已经被改写，而且不再是一份完整任务 JSON。保存函数抛错时，任务模块没有执行任何把文件恢复成旧内容的操作，`catch` 也不会替它恢复。

测试还安排了另一种情况：先写入完整的新任务，再主动抛错。此时文件里已经是完成后的任务，内存仍然是未完成。外部修改是否发生、发生到哪一步，仅凭 `save-failed` 都无法确定。

如果程序需要“失败后文件也保留原样”，就要给文件保存机制提出更强的要求，并验证它怎样做到。如果还要求进程突然退出、断电或多处同时写入时行为明确，问题又会继续扩大。后续持久化章节才会讨论保存、替换、重新读取与恢复，本篇没有完成那些保证。

这里有价值的结论很具体：先保存再采用，控制了哪一行会改变内存认可的数据；文件里的改变由另一项操作负责。一次返回失败，不代表这两处数据都回到了旧状态。

## 两个所有者保存同一份任务，会怎样

前几篇要求不同任务列表实例互不影响，这对独立数据是好事。现在假设两份列表其实代表同一份待办进度，又把完整任务数据写到同一处保存位置，问题就不同了。

某个时刻完整任务数据的副本，通常叫**快照**。`two-owners-demo.mjs` 使用一个外部变量保存最后收到的快照，模拟两份列表共同写入同一处。它没有使用真实文件。

两份列表都从 `t1`、`t2` 未完成开始。第一份完成 `t1`，保存的是 `[true, false]`；第二份的内存还不知道这项修改，完成 `t2` 时保存的是 `[false, true]`。

输出为：

```text
第一份列表保存后： [true,false]
第二份列表保存后： [false,true]
第一份列表仍认为 t1 已完成： true
```

两次保存都正常返回，各自也都按“先保存再采用”的顺序执行。问题来自第二份列表使用了自己那份旧数据，完整保存时把第一份的进度盖掉。第一份列表继续认可的内存，与外部最后保存的数据已经不同。

这不需要两个操作同时执行，演示里就是先完成第一项，再完成第二项。虽然按顺序执行，第二份列表仍然在用自己的旧副本；先后保存不会自动让它取得第一份列表的新进度。

对于同一份任务进度，可以让使用它的页面共同使用一个任务所有者，通过它的接口提出修改和重新读取。互不相关的两组任务则可以各有自己的所有者与保存位置。选择要对应真实数据关系，不能仅凭“各自变量是私有的”判断已经隔开。

如果确实要让不同窗口或进程分别维护副本，还需要规定怎样发现旧数据、怎样接受或拒绝更新。我们没有在本篇实现这种协作机制。当前所有者的保证建立在按顺序调用、保存函数不重新发起本列表修改的条件下；加入等待或多处写入时，需要重新检查这些条件。

## 保存、记录日志和显示提示，需要分别确定失败含义

假设任务已经保存，所有者也接受了新数据，页面准备显示“完成成功”。如果显示提示的代码又出了错，任务本身已经完成，不能仅因为提示失败就推断任务没有改变。

同理，假设还要记录一条操作日志，也就是保存供以后查看的执行记录。日志写入失败是否应该让任务操作失败，要由功能约定决定。有些程序把日志作为必须完成的工作，另一些只记录能够记录的部分；这两种选择会影响执行顺序和错误处理。

可以把需要的事实分别写出来：任务数据是否接受，任务保存是否正常结束，提示是否显示，日志是否记录。使用者究竟需要知道哪些事实，再决定返回什么，而不是先把它们统称为一个成功标记。

本例的保存版只负责任务计算、调用保存函数和接受数据，页面提示与日志没有混进任务实现。它们是这里用来讨论职责的假设需求，下载包没有实现真实页面或日志文件。

分开以后，任务计算不需要知道提示文字，页面也不需要知道怎样复制内部任务。遇到失败时，可以先确定失败的是哪一项工作，再根据那项工作的约定处理。这与上一篇讨论边界的方法一致：指出谁知道什么、负责哪种变化。

## 原地修改与复制新数据，怎样选择

如果程序只在内存中完成任务，没有后续保存步骤，原来的实现很直接：先检查编号、找到任务，再改完成标记，最后返回结果。错误输入在修改之前已经被拒绝，当前需求可以用这份短实现满足。

先计算再采用，增加了计算结果、下一份数据和所有者接受三个概念，也多了几个文件。当前实现每次收到格式合法的编号，先复制整个任务列表，再查找目标；即使编号不存在，也会产生这份临时副本。数据很多时，这些分配与复制值得测量。

本篇没有为这些开销做性能测试，也没有声称纯函数总是比原地修改快。复制让当前数据与建议修改更容易区分，代价是额外的数据和组织工作。

如果确实需要在接受修改前执行可能失败的动作，先准备新数据提供了一个明确的暂存位置。失败时不采用它，便不需要再反向推算任务原来是什么。计算规则也可以单独检查，不必同时写文件。

另一个可比较的方向，是先记录旧数据，修改后保存，失败时恢复内存。这需要决定修改期间是否允许其他代码观察，以及恢复时是否会覆盖别的变化。它也不能自动撤销保存函数已经造成的文件修改。对当前例子，可以用它作为练习，比较自己必须维护的顺序和条件。

因此，我会根据新增需求选择：只有简单内存操作时保留短实现；需要延后接受修改、单独检查计算或明确失败后的当前数据时，考虑先计算再采用。无论选择哪种，都应当把保存和重试的约定写清楚，不能只改几行顺序就认为全部问题已解决。

## 运行示例，核对每一种实际结果

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

验证日期为 **2026 年 10 月 15 日**，运行环境为 macOS arm64、Node.js **25.9.0**。Node.js 是在浏览器外执行 JavaScript 的运行环境。示例只使用它提供的功能，没有第三方依赖，不需要安装依赖包。

```sh
node calculation-demo.mjs
node retry-demo.mjs
node file-demo.mjs
node two-owners-demo.mjs
node --test state.test.mjs file.test.mjs
```

第一个演示观察原数据、建议数据与后来采用的数据。第二个演示使用可控制的首次失败，比较保存顺序对重试的影响。第三个演示创建专用临时文件，检查正常保存、真实路径错误和合成的部分写入后报错，结束时清理本次目录。第四个演示用外部变量模拟两份完整快照互相覆盖。

最后一个命令运行 **29 项测试**，本次全部通过。测试把输入、执行与预期结果写成可以重复运行的检查，范围如下：

| 检查对象 | 具体观察 |
| --- | --- |
| 完成计算 | 输入不变，相同输入结果内容一致，新数据与结果任务互不共享对象 |
| 两种内存实现 | 成功、重复完成、编号错误、任务不存在、返回副本与列表顺序一致 |
| 保存顺序 | 保存时仍读到旧内存，正常返回后采用，抛错时不采用，受控失败后可重试 |
| 保存接口 | 错误返回形式被拒绝，创建时不保存，没有新变化时不保存 |
| 外部修改 | 保存后抛错不会自动撤销外部快照或文件 |
| 真实临时文件 | 正常写入、缺少父目录、主动写入部分内容后报错、完整写入后报错 |
| 两个独立所有者 | 后一份旧快照会覆盖先前任务进度 |

计算检查还使用了冻结的输入数组和任务对象，帮助发现是否误改输入；“冻结”就是让这些对象不能再被普通写入修改。测试里的任务只有三个简单字段，逐个冻结它们即可检查当前数据结构。这里只用它检查行为，没有把冻结作为正式实现所依靠的机制。

下载包共 15 个文件。`mutable-tasks.mjs` 保留上一章的集中数组实现，创建函数体相同；`plan-completion.mjs` 负责计算，`task-list.mjs` 负责内存采用，两种保存顺序各有独立实现。演示、测试和说明也包含在包内，可以直接运行。

初始任务必须已经合法且编号唯一，保存函数必须按本篇接口在返回前完成、不重新发起本列表的修改。检查支持的是这些条件下的输入与实现，不包含进程突然退出、断电、多进程同时写入或完整的重新读取与恢复。

## 再设计一个操作，写出失败后的数据

可以继续为任务增加“修改标题”。先约定编号和新标题的要求，再决定相同标题算不算有新变化。计算函数根据当前任务和新标题产生结果与下一份数据，由所有者决定是否保存和采用。

列出需要观察的例子：合法修改、空标题、任务不存在、保存前失败、外部已经改过以后再报错，以及重复提交同一标题。分别写出原内存、建议数据、后来认可的数据和外部保存结果，不只检查返回的 `ok`。

也可以实现前面提到的“先改后存，失败时恢复内存”，与本篇方案比较。这是读者练习，示例包没有预先实现标题修改或恢复版本。要说明的是恢复哪一份数据、哪些操作可能看到中间结果，以及外部文件仍有哪些未解决的条件。

到这里，我们可以具体说明状态属于谁、副作用发生在哪一步，以及失败后哪些数据有保证。[下一篇：错误传播与异步边界](https://hex4c59.cc/articles/software-design-06-async-errors/)会继续引入需要等待的操作，讨论 Promise、错误处理与等待期间的新请求，以及这些变化怎样影响本篇的执行顺序。

## 来源与实现范围

本篇使用 SICP JavaScript 对照版关于状态与赋值的讨论，以及 Node.js 25.9.0 同步文件接口文档，资料核对到 **2026 年 10 月 15 日**。相关链接放在对应概念与文件操作旁。

任务规则、计算与采用的拆分、保存接口和失败策略是本文的设计选择。运行证据来自下载包中的合成数据、受控失败与临时文件；它们说明当前例子的行为，并没有把一个保存顺序扩展成完整的持久化保证。

