- 开发工具
- 数据科学
【免费下载链接】archived-desktop-app
The old electron based nteract notebook
导读
@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 对象,而不是在旧对象上打补丁。这样的模型天然支持:
- 无痛撤销/重做:把每次操作后的新版本压入历史栈即可,无需记录 diff;
- UI 状态隔离:编辑器、预览器可以各自持有同一文档的不同版本而互不干扰;
- 并发与传输安全:不可变数据可以安全地在多个模块、线程间共享引用。
从源码看,这个模型建立在 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 | "" | 单元格源码,创建时为空字符串 |
metadata | ImmutableMap({ nteract: ImmutableMap({ transient: ImmutableMap({ deleting: false }) }) }) | 默认元数据,内置 nteract 的 transient 标记 |
attachments | undefined | Markdown 附件(图片等),默认为空 |
同理,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标记)。关键的设计考量是:
- Jupyter nbformat 的 metadata 字段不要求强类型,任何 UI 都可以安全地忽略不认识的自定义字段;
- 因此,用户用 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_result | execution_count、data: MediaBundle、metadata | 代码执行成功后的富文本结果 |
display_data | data: MediaBundle、metadata | 显式display()产生的数据 |
stream | name: "stdout" \| "stderr"、text | 标准输出/错误流 |
error | ename、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 根就是字符串的边界情况有专门防护)。
- Vega 系列 MIME(
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
相关推荐
终极性能调优指南:AtlasOS如何通过电源管理实现Windows系统极致优化
终极性能调优指南:AtlasOS如何通过电源管理实现Windows系统极致优化 在Windows系统优化的世界里,性能与功耗的平衡一直是技术爱好者面临的永恒挑战
操作系统隐私合规Odin编程语言内存模型:深入理解内存序与原子操作的完整指南
Odin编程语言内存模型:深入理解内存序与原子操作的完整指南 Odin编程语言的内存模型和原子操作是现代并发编程的核心概念。作为一门系统级编程语言,Odin提供
编程语言编译器语言运行时深入理解 go-containerregistry `mutate` 包:镜像不可变接口下的可变操作实战指南
深入理解 go containerregistry mutate 包:镜像不可变接口下的可变操作实战指南 导读 本文以 k3d 仓库 vendored 的 gi
云原生容器编排
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考