☰
深入理解 @nteract/commutable:Jupyter Notebook 的不可变内存模型与操作库
2026/10/10 2:40:13 网站建设 项目流程
  • 开发工具
  • 数据科学

【免费下载链接】archived-desktop-app

The old electron based nteract notebook

项目地址:https://gitcode.com/gh_mirrors/nt/archived-desktop-app
点击查看免费下载

导读

@nteract/commutable是 nteract 生态中负责"用不可变数据结构表示 Jupyter Notebook 文档"的核心库,它把磁盘上的.ipynb(nbformat JSON)解析为以 ImmutableJSRecord/Map/List为骨架的内存模型,并将一切文档修改封装为"输入旧版本、返回新版本"的纯操作。本文以该包自述文档为主线,结合仓库源码(packages/commutable)与测试用例,讲解其设计原则、安装用法、内存数据模型、nbformat v3/v4 双向转换机制,帮助你在自己的 Notebook 应用中复刻这套可靠的状态管理方案。

一、设计原则:为"实用撤销(Practical Undo)"而生的不可变文档模型

Commutable 将 Jupyter Notebook 文档视为一系列不可变快照——每个快照都是文档在某个时间点的完整状态。这一设计源自 Tom MacWright 关于 practical undo 的著名论述,其四条核心原则在 packages/commutable/README.md 中明确给出:

  • Notebook 文档不可变:文档的表示从不原地修改(never mutated in-place)。
  • 修改被封装为操作:每一次变更都是一个函数,接收上一个版本、返回一个新版本,旧版本原封不动。
  • 历史是一串状态列表:一端是过去(the past),另一端是现在(the present),通过一个索引可以回溯到任意"撤销状态"。
  • 修改会丢弃未来:一旦文档被修改,之前缓存的任何"未来状态"(redo 分支)都会被抛弃。

这四条原则决定了 commutable 的 API 形态:所有操作(如appendCell、deleteCell)都返回一个新的 notebook 对象,而不是在旧对象上打补丁。这样的模型天然支持:

  1. 无痛撤销/重做:把每次操作后的新版本压入历史栈即可,无需记录 diff;
  2. UI 状态隔离:编辑器、预览器可以各自持有同一文档的不同版本而互不干扰;
  3. 并发与传输安全:不可变数据可以安全地在多个模块、线程间共享引用。

从源码看,这个模型建立在 ImmutableJS 中的immutable: ^4.0.0-rc.12依赖),并通过uuid(^8.0.0)为每个单元格生成全局唯一的CellId。

二、安装

该包以独立的 npm 包形式发布,包名为@nteract/commutable(当前仓库内版本为7.5.1),可以使用 Yarn 或 npm 安装:

# 使用 yarn $ yarn add @nteract/commutable
# 使用 npm $ npm install --save @nteract/commutable

安装后,包的入口为lib/index.js(类型声明lib/index.d.ts),源码入口为src/index.ts。从 packages/commutable/src/index.ts 可以看到,公共 API 按职责分五个模块统一导出:

  • primitives:底层类型与工具(CellId、MediaBundle、换行符规范化、多行字符串转换等);
  • structures:notebook 记录与单元格结构操作(appendCell、insertCellAt、deleteCell等);
  • outputs:四种输出类型的不可变 Record 与磁盘格式互转;
  • cells:三种单元格类型的不可变 Record 工厂;
  • notebook:顶层解析/序列化入口(parseNotebook、fromJS、toJS、stringifyNotebook)。

三、快速上手:用emptyMarkdownCell创建空 Markdown 单元格

README 给出了一段非常直观的用法示例:在 nteract 桌面应用中创建一个新的空 Markdown 单元格,并用它渲染预览组件。

import { emptyMarkdownCell } from "@nteract/commutable"; export default () => ( <MarkdownPreview id="a-random-cell-id" cell={emptyMarkdownCell} editorFocused={false} /> );

这里的emptyMarkdownCell是一个"零参数创建"的不可变单元格对象。它在 packages/commutable/src/structures.ts 中被定义为:

export const createMarkdownCell = makeMarkdownCell; export const emptyMarkdownCell = createMarkdownCell();

也就是说,emptyMarkdownCell是makeMarkdownCell()无参调用的结果。查看 packages/commutable/src/cells.ts 中makeMarkdownCell的默认值定义,可以知道这个空单元格的实际结构:

字段默认值说明
cell_type"markdown"单元格类型,固定为 markdown
source""单元格源码,创建时为空字符串
metadataImmutableMap({ nteract: ImmutableMap({ transient: ImmutableMap({ deleting: false }) }) })默认元数据,内置 nteract 的 transient 标记
attachmentsundefinedMarkdown 附件(图片等),默认为空

同理,structures.ts还导出了emptyCodeCell(空代码单元格)、emptyNotebook(空 notebook)以及一个特殊的monocellNotebook——一个只含单个空代码单元格的新 notebook,非常适合"新建空白笔记"场景。

创建带内容的单元格

如果你需要创建带内容的单元格,examples.md(packages/commutable/docs/examples.md)给出了更完整的用法:所有属性都有合理默认值,因此只需传入你要覆盖的字段。

import { makeCodeCell } from "@nteract/commutable"; const codeCell = makeCodeCell({ source: "print(1)" });

类似的工厂函数还有makeMarkdownCell(Markdown 单元格)和makeRawCell(原始单元格)。以 packages/commutable/src/cells.ts 中的makeCodeCell为例,其默认结构为:

{ cell_type: "code", execution_count: null, metadata: ImmutableMap({ jupyter: ImmutableMap({ source_hidden: false, outputs_hidden: false }), nteract: ImmutableMap({ transient: ImmutableMap({ deleting: false }) }), }), source: "", outputs: ImmutableList(), }

值得注意的细节:cells.ts中所有单元格工厂都经由normalizedSourceCellRecord包装——在创建的同时把source字段做换行符规范化(normalizeLineEndings),将\r\n统一替换为\n,确保跨平台一致(见 packages/commutable/src/primitives.ts 的normalizeLineEndings实现)。

四、内存数据模型:cellOrder + cellMap 的索引式组织

与磁盘上的数组式cells列表不同,commutable 的内存模型把"顺序"与"内容"拆成两个字段,这是为了让 UI 增删单元格时不需要整表重建。核心结构定义在 packages/commutable/src/structures.ts:

export interface NotebookRecordParams { cellOrder: ImmutableList<CellId>; // 按出现顺序排列的 CellId 列表 cellMap: ImmutableMap<CellId, ImmutableCell>; // CellId -> 不可变单元格 nbformat_minor: number; nbformat: number; metadata: ImmutableMap<string, any>; }

各字段语义(与 packages/commutable/docs/overview.md 的描述一致):

  • cellOrder:单元格 ID 的有序列表,顺序即文档中单元格的排列顺序;
  • cellMap:把每个CellId映射到对应的不可变单元格对象;
  • nbformat/nbformat_minor:notebook 遵循的 nbformat 版本号;
  • metadata:notebook 顶层元数据(如kernel_info、language_info)。

CellId是 uuid-v4 字符串,由createCellId()生成(见 packages/commutable/src/primitives.ts)。解析 notebook 时,包会自动为每个单元格生成或保留唯一 ID,UI 层用这个 ID 稳定地引用单元格——这正是"可撤销、可重排"模型的基础。

单元格结构的核心操作

structures.ts基于 cellOrder/cellMap 提供了一系列"纯函数式"操作,它们全部返回新版本:

函数作用
appendCell(cellStructure, cell, id?)在末尾追加一个单元格,id 缺省时自动生成 UUID
appendCellToNotebook(immnb, cell)在 notebook 记录上追加单元格(内部用withMutations批量更新)
insertCellAt(notebook, cell, cellId, index)在指定索引插入单元格
insertCellAfter(notebook, cell, cellId, priorCellId)在某个已存在单元格之后插入
deleteCell(notebook, cellId)从 cellMap 中移除并过滤 cellOrder
removeCell(notebook, cellId)已废弃(弃用警告),等价于deleteCell
markCellDeleting / markCellNotDeleting设置/清除单元格的 transient "deleting" 标记,用于支持可撤销的删除动效

markCellDeleting与markCellNotDeleting的行为由测试 packages/commutable/tests/structures.spec.ts 验证:它们通过setIn(["metadata", "nteract", "transient", "deleting"])写入true/false。而toJS序列化时会跳过处于 deleting 状态的单元格(见 v4.ts 的toJS实现中的 filter 逻辑),从而让"先标记删除、再撤销、最后落盘"的交互成为可能。

五、transient 数据:与 Jupyter 生态兼容的 UI 私有字段

commutable 的内存格式在每个单元格的 metadata 中内置了一个nteract.transient命名空间(packages/commutable/docs/overview.md 专门解释了这一点):

metadata.nteract.transient

它的作用是为 nteract 系界面保存 UI 相关的瞬时状态(如上面提到的deleting标记)。关键的设计考量是:

  1. Jupyter nbformat 的 metadata 字段不要求强类型,任何 UI 都可以安全地忽略不认识的自定义字段;
  2. 因此,用户用 nteract 交互产生的 transient 数据,换到其他 Jupyter UI 打开时不会破坏文档可用性——未知字段被天然忽略。

换句话说,commutable 在追求内存模型便利性的同时,保持了与 Jupyter 文件格式的双向兼容:写入的.ipynb依然是合法文档。

六、nbformat 双向转换:从.ipynb字符串到不可变模型

packages/commutable/src/notebook.ts 提供了一组顶层转换入口,覆盖"字符串 ↔ JSON ↔ 不可变模型"的完整链路:

函数职责
parseNotebook(notebookString)把 notebook 的字符串形式解析为 JSON 对象,并使用freezeReviver对解析结果逐层Object.freeze,产出只读 JSON
fromJS(notebook)把 JSON(v3 或 v4)转换为不可变内存模型;若传入的是非 notebook 的Immutable.Record或未知格式,会抛出明确的TypeError
toJS(immnb)把不可变模型转回 v4 的 JSON 表示(仅支持 nbformat 3/4)
stringifyNotebook(notebook)把 JSON 序列化为格式化的字符串(JSON.stringify(notebook, null, 2)),即写盘用的.ipynb文本

其中fromJS的分发逻辑值得细看(notebook.ts 第 32-61 行):先排除Record误用,再用v4.isNotebookV4/v3.isNotebookV3类型守卫判断版本,若都无法识别则根据nbformat字段给出nbformat vX.Y not recognized或This notebook format is not supported的错误。

v4 转换:规范化与 ID 策略

nbformat v4 是当前.ipynb的主流格式。在 packages/commutable/src/v4.ts 中:

  • fromJS:把cells数组逐个经createImmutableCell转换(markdown/code/raw 三种类型,未知类型抛Cell type ... unknown),再通过appendCell组装成 cellOrder/cellMap。一个重要的版本策略是hasCellId(nbformat === 4 && nbformat_minor >= 5):只有当 notebook 为 4.5 及以上版本时,才保留磁盘上的单元格id;更早的版本则由 nteract 自动生成 UUID。
  • toJS:把 cellOrder/cellMap 还原为cells数组,过滤掉处于 deleting 状态的单元格;若源文档带单元格 ID(4.5+),序列化时会回写cell["id"]。
  • 元数据规范化:createImmutableMetadata会把tags字段统一转换为Immutable.Set(符合 nbformat 中 tags 必须是字符串数组的规范,异常值退化为空 Set)。
  • Markdown 附件:createImmutableAttachments把附件里的多行字符串(MultiLineString)降维为单字符串,测试 packages/commutable/tests/v4.spec.ts 中验证了附件在cellToJS往返后仍然保留。

v3 转换:旧格式的兼容性桥接

nbformat v3 是较老的笔记本格式,其结构与 v4 差异很大(用worksheets数组、pyout/pyerr输出类型、input字段、heading单元格等)。packages/commutable/src/v3.ts 做了完整的适配:

  • 工作表合并:v3 的worksheets中所有单元格被平铺合并进同一个 cellOrder/cellMap;
  • 字段重命名:cell.input→source,cell.prompt_number→execution_count,output.stream→ 标准name(stderr保留,其余一律视为stdout);
  • 媒体类型映射:VALID_MIMETYPES表把 v3 的短字段名(text、latex、png、jpeg、svg、html、javascript、json、pdf)映射为 v4 的完整 MIME 类型;
  • heading 单元格降级:v3 的heading单元格被转换成 v4 的markdown单元格,并用level生成对应数量的#前缀——例如level: 2的 heading 每行会被加上##前缀。

转换完成后,v3 notebook 统一以nbformat: 4的内存模型呈现,上层 UI 无需感知来源版本。

七、单元格与输出:强类型的不可变 Record 家族

三种单元格

packages/commutable/src/cells.ts 定义了三种单元格的强类型接口与 Record 工厂:

  • CodeCell:cell_type: "code",字段含execution_count(number | null)、source、outputs: ImmutableList<ImmutableOutput>、metadata;
  • MarkdownCell:cell_type: "markdown",字段含source、可选attachments(ImmutableMap<string, MimeBundle<string>>)、metadata;
  • RawCell:cell_type: "raw",字段含source、metadata。

三者共同构成联合类型ImmutableCell = ImmutableMarkdownCell | ImmutableCodeCell | ImmutableRawCell,而CellType = "raw" | "markdown" | "code"用于类型分发。

四种输出

packages/commutable/src/outputs.ts 定义了 nbformat 的四种输出类型,全部为不可变 Record:

输出类型关键字段用途
execute_resultexecution_count、data: MediaBundle、metadata代码执行成功后的富文本结果
display_datadata: MediaBundle、metadata显式display()产生的数据
streamname: "stdout" \| "stderr"、text标准输出/错误流
errorename、evalue、traceback: ImmutableList<string>异常信息

其中createImmutableOutput负责把磁盘 JSON 转成对应 Record,遇到未知output_type时宁可抛错也不静默处理(Output type ... not recognized),从测试 packages/commutable/tests/v4.spec.ts 的outputToJS用例可以看出四种类型的往返转换都被覆盖。

Media Bundle:多 MIME 类型的数据容器

单元格输出的核心载荷是Media Bundle——以 MIME 类型为键的字典,用于在同一份数据上承载多种渲染方式(如text/plain、text/html、image/png、application/json、Vega/Vega-Lite 等)。packages/commutable/src/primitives.ts 定义了磁盘格式OnDiskMediaBundle与内存格式MediaBundle两套类型,并提供了两组转换:

  • createFrozenMediaBundle:磁盘 → 内存。逐 key 处理,核心逻辑包括:
    • Vega 系列 MIME(application/vnd.vega...json)被JSON.stringify成字符串;
    • 普通字符串直接透传;
    • 多行字符串(字符串数组)经demultiline拼接为单字符串(除非 key 是application/*+json);
    • 对象载荷经deepFreeze递归冻结(对 JSON 根就是字符串的边界情况有专门防护)。
  • createOnDiskMediaBundle:内存 → 磁盘。当前实现直接原样返回(源码注释说明remultiline重构成本高,已留 TODO 待做可配置的格式化开关),Vega JSON 载荷则依赖createFrozenMediaBundle阶段的字符串化。

多行字符串:为 Git 友好而生的细节

primitives.ts中还有一对常被忽略但很关键的工具:

export function demultiline(s: string | string[]): string; export function remultiline(s: string | string[]): string[];

nbformat 磁盘格式允许source/text以字符串数组形式存在,目的是让 Git/GitHub 的逐行 diff 更清晰。commutable 在解析时用demultiline把数组拼接为单字符串便于内存操作,在序列化时(v4.ts 的codeCellToJS等)用remultiline再切回按行数组——remultiline使用s.split(/(.*?(?:\r\n|\n))/g)保留换行符,确保写盘后仍能按行 diff。不过源码注释也提醒:当stdout数据量极大时remultiline会有性能开销,因此 stream 输出在outputToJS中目前不做多行重构(有对应测试"stream output does not reformat multiline string"锁定该行为)。

八、总结

@nteract/commutable的价值不在于"又一个不可变库",而在于它为Jupyter Notebook 文档这一特定领域量身定制了模型:

  • 以不可变快照为文档形态,天然支撑撤销/重做与历史管理;
  • 以 cellOrder + cellMap 双索引组织单元格,让 UI 的增删改查保持在常数级操作;
  • 以 nbformat v3/v4 双向转换 + transient 元数据命名空间,在获得内存模型便利的同时不破坏.ipynb文件的生态兼容性;
  • 以强类型 Record 家族覆盖 cells 与 outputs,让编译期即可捕获格式错误。

如果你想在自己的 Notebook 应用(编辑器、预览器、协作后端)中实现可靠的状态管理与撤销系统,直接复用 packages/commutable/src 中fromJS/toJS/appendCell/insertCellAt/deleteCell这套 API,并参考 packages/commutable/tests下的测试用例来验证行为,是最稳妥的路径。该包以 BSD-3-Clause 许可开源,可放心集成。

  • 开发工具
  • 数据科学

【免费下载链接】archived-desktop-app

The old electron based nteract notebook

项目地址:https://gitcode.com/gh_mirrors/nt/archived-desktop-app
点击查看免费下载

相关推荐

上一篇:Chameleon 静态资源处理实战:mvvm-file-loader 文件加载器配置与多端构建原理
下一篇:Node.js 4.5.0 (LTS) 版本解析:v4.x 生命周期中的 Buffer API 回溯与基础组件升级

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询