第 05 篇

状态与副作用:谁能修改数据,失败后保留什么

沿用待办事项程序,解释状态、副作用与数据所有者,比较直接修改、先计算再采用和两种保存顺序,用重试及临时文件观察失败后的实际数据。

Hex4C59 23 分钟阅读 更新于 2026-10-17

待办事项程序已经把任务数据留在自己的模块里,其他代码通过列出和完成任务的函数使用它。上一篇讨论了哪些代码适合放在一起,以及内部数组换成 Map 时,怎样让调用者继续按原接口使用。

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

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

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

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

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

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

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

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 对照版关于局部状态的讨论也用多次操作的结果说明状态的作用。本文使用待办任务观察同一件事:前面执行过什么,会影响后来怎样工作。这个现象本身很正常,问题是我们怎样管理它。

副作用具体发生在哪里 #

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

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 的完整内容。它是针对当前“保存失败时不接受修改”约定构造的反例:

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 提供一个受控制的保存函数:第一次调用故意抛错,之后调用则把任务副本收进数组,表示本次模拟保存成功。数组记录的是演示结果,不是真实文件。

反例得到:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 表示没有这份建议,不表示当前任务列表被清空。只有成功且确实改变任务时,所有者才会使用它。

完整实现如下:

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 的完整内容如下:

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 展示计算和采用的区别:

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

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

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

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

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 的又一份副本。保存函数即使保留这个参数,后来也不能通过它改写已经采用的内部任务。使用者拿到的操作结果和列表,也继续保持副本约定。

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

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

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

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

先保存新数据再采用
第一次: {"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:

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

JSON.stringify 把任务数组转换成 JSON 文本;JSON 是一种表示数据的文本格式。writeFileSync 把文本写入文件,调用在操作结束后才返回。Node.js 的同步文件接口说明描述了这种执行和错误方式。

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

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

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

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

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

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

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

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

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

运行得到:

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

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

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

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

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

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

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

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

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

输出为:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

下载完整示例包,解压后进入 software-design-05-examples 目录。

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

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。

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

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

来源与实现范围 #

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

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

一起讨论

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

正在读取讨论…

在 GitHub 查看与管理讨论 ↗

文章讨论