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 之前,想要在节点上存储额外数据,开发者通常需要:
- 继承
LexicalNode/TextNode等基类,声明__property实例变量; - 手工编写
constructor、clone、afterCloneFrom、exportJSON、importJSON、updateFromJSON一整套样板代码; - 自行保证序列化、克隆、粘贴(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)会缓存三个关键值:
defaultValue:stateValueConfig.parse(undefined)的结果,只计算一次;isEqual:默认使用Object.is;unparse:默认是透传(假定值是 JSON 可序列化的)。
parse 函数的两个职责
官方文档强调parse是必填项,它有双重作用:
- 类型安全 + 运行时安全地解析 JSON 序列化后的值;
- 当被传入
undefined(或任何无效值)时,返回默认值——这个默认值可以是undefined、null或你选择的任何值。
上面的例子中,question 必须是字符串,默认值是空字符串''。
非原始值类型的可选配置:unparse / isEqual / resetOnCopyNode
StateValueConfig接口(LexicalNodeState.ts)定义了四个字段:
| 配置项 | 必填 | 默认值 | 说明 |
|---|---|---|---|
parse | 是 | — | 解析 JSON 值到类型 V;传入undefined时必须返回默认值 |
unparse | 否 | 透传(假定值可 JSON 序列化) | 将 V 转回 JSON 值;当 V 不是 JSON 可序列化类型时必填(如Date、Map、Set) |
isEqual | 否 | Object.is | 相等性判断;当 V 是数组/对象时建议改用fast-deep-equal之类工具,以便省略等于默认值的键 |
resetOnCopyNode | 否 | false | 当节点通过$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 序列化。但高级场景中你可能使用Date、Map、Set等需要转换的值——此时必须通过unparse定义"值 → JSON"的转换,详见上文 非原始值类型的可选配置。注意undefined 无法序列化为 JSON,因此如果你的类型 V 包含undefined,它应同时被视为默认值;同样,如果 V 是函数类型,使用$setState时必须用更新函数形式,因为函数值与更新函数无法区分。
扁平序列化:$config+flat: true
在节点的$config中声明StateConfig并设置flat: true时,该 key 会被提升到序列化 JSON 的顶层,而不是嵌套在'$'之下。这与旧式节点把值存为__property实例变量时的 JSON 形状完全一致,因此存量 payload 在节点迁移到 NodeState 后仍能无损往返(round-trip)。
例如,一个继承TextNode的ColoredNode,声明了扁平的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}], }); } }迁移后不再需要exportJSON、importJSON、updateFromJSON、clone、afterCloneFrom中的任何覆写:$config自动安装clone与importJSON,而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 字符串解析成StyleObject,unparse把StyleObject序列化回排序后的 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 渲染与导入:通过StyleStateExtension(defineExtension)把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),仅供参考