Lexical NodeState 全面指南:在任意节点上添加可序列化状态
2026/9/13 4:35:33 网站建设 项目流程

Lexical NodeState 全面指南:在任意节点上添加可序列化状态

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

NodeState 是 Lexical v0.26.0 引入的一套 API,允许开发者以即插即用的方式为任意节点附加状态,且该状态自动参与 reconciliation(协调)、history(历史/撤销重做)与 JSON 序列化。本文以官方文档 node-state.md 为主线,结合 LexicalNodeState.ts 源码与 node-state-style 示例 的完整实现,讲解 StateConfig 的创建、读写 API、序列化格式、$config扁平化迁移方案、copy-on-write 效率机制及能力边界,帮助你掌握这套零样板(zero-boilerplate)的节点状态方案。

为什么需要 NodeState

在 v0.26.0 之前,想要在节点上存储额外数据,开发者通常需要:

  1. 继承LexicalNode/TextNode等基类,声明__property实例变量;
  2. 手工编写constructorcloneafterCloneFromexportJSONimportJSONupdateFromJSON一整套样板代码;
  3. 自行保证序列化、克隆、粘贴(copy + paste)时数据不丢失。

NodeState 解决了这一痛点:你的应用可以定义任意 key,将其存储在任何节点上,且 JSON 序列化是自动完成的。这意味着很多场景下你甚至不需要自定义节点子类——状态可以直接挂在现有节点上。

特别地,NodeState 允许你在RootNode上存储文档级元数据(例如文档标题、作者、自定义属性),这在旧版本中完全无法实现。

与相关 API 的组合使用

官方文档明确指出,将 NodeState 与以下 API 组合,大多数编辑器定制需求都能满足,无需走到 Node Customization 那一步:

  • Listeners:监听状态变化、节点变更;
  • Transforms:在节点变换流程中读写状态;
  • DOMRenderExtension:渲染与 HTML 导出;
  • DOMImportExtension:HTML 导入。

即使你确实在子类化节点,用 NodeState 代替额外属性存储数据也更高效,并且免去在 constructor、updateFromJSON、exportJSON 中的大量样板代码。

核心 API 详解

NodeState 的公共 API 由三个函数组成:createState$getState$setState,外加一个辅助函数$getStateChange。它们的实现位于 LexicalNodeState.ts(如$getState$setState),并统一从lexical包导出。

createState:定义状态的 key 与配置

createState创建一个StateConfig,它定义了 NodeState 值的key配置

const questionState = createState('question', { parse: (v) => (typeof v === 'string' ? v : ''), });

key 必须是局部唯一的:同一节点上不能使用两个具有相同字符串 key 的不同StateConfig。从源码看,开发模式下$checkCollision会在$setState时检测冲突并抛出错误(LexicalNodeState.ts):

$setState: State key collision %s detected in %s node with type %s and key %s. Only one StateConfig with a given key should be used on a node.

StateConfig的构造函数(LexicalNodeState.ts)会缓存三个关键值:

  • defaultValuestateValueConfig.parse(undefined)的结果,只计算一次;
  • isEqual:默认使用Object.is
  • unparse:默认是透传(假定值是 JSON 可序列化的)。
parse 函数的两个职责

官方文档强调parse必填项,它有双重作用:

  1. 类型安全 + 运行时安全地解析 JSON 序列化后的值
  2. 当被传入undefined(或任何无效值)时,返回默认值——这个默认值可以是undefinednull或你选择的任何值。

上面的例子中,question 必须是字符串,默认值是空字符串''

非原始值类型的可选配置:unparse / isEqual / resetOnCopyNode

StateValueConfig接口(LexicalNodeState.ts)定义了四个字段:

配置项必填默认值说明
parse解析 JSON 值到类型 V;传入undefined时必须返回默认值
unparse透传(假定值可 JSON 序列化)将 V 转回 JSON 值;当 V 不是 JSON 可序列化类型时必填(如DateMapSet
isEqualObject.is相等性判断;当 V 是数组/对象时建议改用fast-deep-equal之类工具,以便省略等于默认值的键
resetOnCopyNodefalse当节点通过$copyNode复制(非克隆)时,是否将该值重置为默认值

源码注释还给出一个针对非原始值的高级示例——存储 ISO 日期:

const isoDateState = createState('isoDate', { parse: (v): null | Date => { const date = typeof v === 'string' ? new Date(v) : null; return date && !isNaN(date.valueOf()) ? date : null; }, isEqual: (a, b) => a === b || (a && b && a.valueOf() === b.valueOf()), unparse: (v) => v && v.toString(), });

官方建议:为常用数据类型构建一个小型可复用 parse 函数库,或使用能生成这类函数的库(如 zod、ArkType、Effect、Valibot 等),尤其是处理非原始类型时。

$getState:读取状态

$getState从给定节点读取 NodeState 值;如果该 key 从未在此节点上设置过,则返回默认值:

const question = $getState(pollNode, questionState);

$getState的签名还接受可选的第三个参数version(LexicalNodeState.ts):

  • NODE_STATE_LATEST(默认):读取前先调用node.getLatest(),符合 Lexical 只操作节点最新版本的习惯;
  • NODE_STATE_DIRECT:直接读取当前这个节点实例上存储的状态,不调用getLatest()。适合读取某个历史版本节点,或在updateDOM等场景中使用。

源码中定义的这两个常量分别为字符串'direct''latest'(LexicalNodeState.ts)。

$getStateChange:高效比较两份状态

如果你需要判断同一节点的两个版本之间状态是否发生变化,$getStateChange比分别调用两次$getState再手动比较更高效(LexicalNodeState.ts):

const change = $getStateChange(node, prevNode, questionState); // change === null 表示无变化; // 否则为 [value, prevValue]

它内部对两个节点都使用NODE_STATE_DIRECT读取,并用stateConfig.isEqual判断相等性。典型用途是实现updateDOM,也可用于 update listener 或 mutation listener。

$setState:写入状态

$setState在给定节点上设置 NodeState 值(LexicalNodeState.ts):

const question = $setState( pollNode, questionState, 'Are you planning to use NodeState?', );

第三个参数是ValueOrUpdater<V>,与 React 的useStatesetter 完全一致——可以是值,也可以是(prevValue) => nextValue更新函数:

const toggle = createState('toggle', {parse: Boolean}); // 直接设置 $setState(node, toggle, true); // 使用更新函数 $setState(node, toggle, (prev) => !prev);

一个重要细节:当使用更新函数且新值与旧值相等(按isEqual判断)时,节点及其 NodeState 不会被标记为 dirty(LexicalNodeState.ts),从而避免无谓的 reconciliation 与历史记录写入。

为节点类封装 setter 方法

源码提供了StateValueOrUpdater类型别名,方便你在自定义节点类上封装类型安全的 setter:

const fooState = createState("foo", { parse: ... }); class MyClass extends TextNode { setFoo(valueOrUpdater: StateValueOrUpdater<typeof fooState>): this { return $setState(this, fooState, valueOrUpdater); } }

序列化格式

默认:聚合在 NODE_STATE_KEY('$')下

只要某节点存在非默认值的状态,就会被序列化到单个NODE_STATE_KEY(值等于字符串'$')下的 record 中:

{ "type": "poll", "$": { "question": "Are you planning to use NodeState?" } }

toJSON()的实现细节(LexicalNodeState.ts):

  • 已知状态(knownState)中等于默认值的键会被删除delete state[stateConfig.key]);
  • 不等于默认值的键通过unparse转换后写入;
  • 扁平键(flatKeys)会被提升到顶层;
  • '$'record 为空则不输出。

高级场景:非 JSON 可序列化的值

默认假定解析后的值可直接 JSON 序列化。但高级场景中你可能使用DateMapSet等需要转换的值——此时必须通过unparse定义"值 → JSON"的转换,详见上文 非原始值类型的可选配置。注意undefined 无法序列化为 JSON,因此如果你的类型 V 包含undefined,它应同时被视为默认值;同样,如果 V 是函数类型,使用$setState时必须用更新函数形式,因为函数值与更新函数无法区分。

扁平序列化:$config+flat: true

在节点的$config中声明StateConfig并设置flat: true时,该 key 会被提升到序列化 JSON 的顶层,而不是嵌套在'$'之下。这与旧式节点把值存为__property实例变量时的 JSON 形状完全一致,因此存量 payload 在节点迁移到 NodeState 后仍能无损往返(round-trip)

例如,一个继承TextNodeColoredNode,声明了扁平的color状态:

$config() { return this.config('colored', { extends: TextNode, stateConfigs: [{flat: true, stateConfig: colorState}], }); }

序列化结果为:

{ "type": "colored", "text": "hello", "color": "red" }

而同一节点上非扁平(默认)的状态则会出现在'$'下:

{ "type": "colored", "text": "hello", "$": { "color": "red" } }

两种情况下,只有当当前值不等于其parse函数返回的默认值时才会输出该 key(见 Efficiency)。

⚠️注意:不要复用一个超类已经在序列化的扁平 key(例如TextNode上的text)。

$config/stateConfigs的完整用法见官方文档 nodes.mdx - Creating custom nodes with $config and NodeState,示例包括:扩展ElementNode、扩展TextNode、扩展DecoratorNode(扁平id状态)、跨抽象基类共享$config等。

将传统 JSON 属性升级为 NodeState

一个以__property实例变量存储数据、手工编写exportJSON/importJSON/updateFromJSON的节点,可以借助flat: true迁移到 NodeState而不改变序列化 JSON 形状

迁移前——带__color属性与手写序列化的ColoredNode

export type SerializedColoredNode = Spread< {color?: string}, SerializedTextNode >; export class ColoredNode extends TextNode { __color: string; constructor(text: string = '', color: string = DEFAULT_COLOR, key?: NodeKey) { super(text, key); this.__color = color; } static getType(): string { return 'colored'; } static clone(node: ColoredNode): ColoredNode { return new ColoredNode(node.__text, node.__color, node.__key); } static importJSON(serializedNode: SerializedColoredNode) { return new ColoredNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode: SerializedColoredNode) { const self = super.updateFromJSON(serializedNode); self.__color = typeof serializedNode.color === 'string' ? serializedNode.color : DEFAULT_COLOR; return self; } exportJSON(): SerializedColoredNode { return { ...super.exportJSON(), color: this.__color === DEFAULT_COLOR ? undefined : this.__color, }; } }

迁移后——线上 JSON 不变,零手写序列化:

const colorState = createState('color', { parse: (v) => (typeof v === 'string' ? v : DEFAULT_COLOR), }); export class ColoredNode extends TextNode { $config() { return this.config('colored', { extends: TextNode, stateConfigs: [{flat: true, stateConfig: colorState}], }); } }

迁移后不再需要exportJSONimportJSONupdateFromJSONcloneafterCloneFrom中的任何覆写:$config自动安装cloneimportJSON,而LexicalNode/TextNode基类上的exportJSON/updateFromJSON/afterCloneFrom已经支持 NodeState 往返。读取与写入改为:

// 读 const color = $getState(node, colorState); // 写 $setState(node, colorState, 'red');

💡无缝迁移要点:保持 JSON key 名称不变——对扁平状态而言,就是createState的第一个参数。从非扁平 NodeState 迁移到扁平 NodeState 同样可行:'$'NODE_STATE_KEY)下的状态在配置为扁平时仍会被解析,且当两者同时存在时,扁平值优先

效率机制

Copy-on-Write(写时复制)

NodeState 采用copy-on-write方案管理每个节点的状态:如果没有任何状态发生变化,NodeState 实例会在该节点的多个版本之间共享

官方文档补充的关键细节:

在给定的 reconciliation 周期中,Lexical 节点第一次通过getWritable被标记 dirty 时会创建该节点的新实例,旧版本的所有属性都被设置到新实例上。NodeState 作为单个属性存储,在 NodeState 本身被标记为可写之前,不会复制其内部状态

从源码看,这一机制由NodeState.getWritable实现:仅当关联节点不同时才浅拷贝knownStateMap,并复用sharedNodeState;而$setState内部先判断值是否变化,再调用$getWritableNodeState触发真正的写入(LexicalNodeState.ts)。这意味着"节点被克隆但状态未变"的开销极小。

序列化时省略默认值

序列化为 JSON 时,每个 key 只在值不等于默认值时才会被存储,可以节省大量空间与带宽(特别是文档级元数据、长文本场景)。

惰性解析:只在网络边界解析

解析与序列化只在网络边界发生——即与 JSON 或 Yjs 集成时:

  • 当值来自外部源并发生变化时,只在第一次被读取时才解析
  • 非外部来源的值从不解析
  • 从未被使用的值永远不解析

源码中unknownState字段正是为这一设计服务的:JSON 导入时先原样保存为未解析的 record,getValue首次读取时才解析并移入knownState(LexicalNodeState.ts)。

能力清单与边界

当前已支持

  • 定义状态并添加到任意节点(包括 RootNode 上的文档级元数据);
  • 在节点 JSON 中自动序列化该状态,支持版本化与复制粘贴;
  • 与 reconciler 协同:状态不同的 TextNode 不会被隐式合并(见nodeStatesAreEquivalent,LexicalNodeState.ts);
  • @lexical/yjs 支持:NodeState 会像其他属性一样自动同步;
  • 未使用的 NodeState 值直接透传(pass-through):当同一份数据被多种配置使用(例如编辑器新旧版本共存、不同插件集)时,旧代码不会抹掉新代码写入的元数据——这正是unknownState设计的目的(LexicalNodeState.ts 注释);
  • 预注册系统:节点可通过$config声明期望的状态并将其序列化为顶层属性(flat);
  • 可与 DOMRenderExtension 集成(编辑器渲染与 HTML 导出);
  • 可与 DOMImportExtension 集成(HTML 导入)。

未来方向 / 已知限制

  • 尚不支持直接与 Yjs 集成:例如你不能把Y.Map作为 NodeState 值存储。

实战示例:node-state-style

官方文档末尾指向的 node-state-style 示例 展示了 NodeState 的一个高级用法:在 TextNode 上用 NodeState 存储样式对象(style object)。该示例的 README 说明它演示了如何用 NodeState 配合DOMRenderExtension覆盖任意节点的创建与导出行为,以及用DOMImportExtension在导入 HTML 时捕获任意内联style属性。

其核心代码在 styleState.ts:

1. 定义带 parse / unparse / isEqual 的完整 StateConfig——因为样式是对象(非原始类型),三个配置都用上了:

export const styleState = createState('style', { isEqual, parse, unparse, });

其中parse把 CSS 字符串解析成StyleObjectunparseStyleObject序列化回排序后的 CSS 字符串,isEqual做深比较以省略默认值。

2. 基于 $getState / $setState 封装便捷读写函数

export function $getStyleObject(node: LexicalNode): StyleObject { return $getState(node, styleState); } export function $setStyleObject<T extends LexicalNode>( node: T, valueOrUpdater: ValueOrUpdater<StyleObject>, ): T { return $setState(node, styleState, valueOrUpdater); }

示例还展示了用getStyleObjectDirect(node)读取"直接版本"状态($getState(node, styleState, 'direct'))、用更新函数实现$setStyleProperty/$removeStyleProperty、以及diffStyleObjects/mergeStyleObjects等工具。

3. 将状态接入 DOM 渲染与导入:通过StyleStateExtensiondefineExtension)把DOMRenderExtension$decorateDOM(用 diff 方式应用样式)与$exportDOM(导出样式到 HTML)以及DOMImportExtension的通配导入规则注册进编辑器(styleState.ts),并在 App.tsx 中注册该扩展。这完整印证了文档中"NodeState 可与 DOMRenderExtension / DOMImportExtension 集成"的能力项。

本地运行

pnpm i && pnpm run dev

总结

NodeState 为 Lexical 提供了一种声明式、可组合的节点状态方案:createState定义 key 与解析规则,$getState/$setState完成读写,JSON 序列化、克隆、历史记录与 Yjs 同步全部自动完成;$config+flat: true则让传统__property节点可以零形状变化地平滑迁移。配合 copy-on-write 的共享机制与惰性解析,它同时兼顾了内存效率与序列化带宽。对于希望减少节点子类化样板代码、或在任何节点(乃至 RootNode)上自由扩展数据的开发者,NodeState 是优先考虑的方案。

【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical

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

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

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

立即咨询