Lexical NodeState Style 示例深度解析:用 NodeState 与 DOM 扩展统一管理任意节点的内联样式
2026/9/12 15:51:34 网站建设 项目流程

Lexical NodeState Style 示例深度解析:用 NodeState 与 DOM 扩展统一管理任意节点的内联样式

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

导读

本示例(examples/node-state-style)演示了 Lexical 新一代扩展体系中三个核心能力的协同用法:用NodeState(createState在任意节点上挂载自定义状态、用DOMRenderExtension统一覆盖节点的 DOM 创建($decorateDOM)与 HTML 导出($exportDOM)行为、用DOMImportExtension在导入 HTML 时捕获任意内联style属性。读完本文,你将掌握如何为 TextNode 等任意节点实现一套"可读写、可序列化、可渲染、可导出、可导入"的完整样式子系统,并能复刻本示例中的 Style Tree 可视化调试面板。


1. 示例概览与运行方式

该示例位于仓库的 examples/node-state-style 目录,其 README 给出的运行方式为:

pnpm i && pnpm run dev

此外 package.json 还提供了 monorepo 内联运行、构建与预览脚本:

"scripts": { "dev": "vite", "monorepo:dev": "vite -c vite.config.monorepo.ts", "build": "tsc && vite build", "preview": "vite preview" }
  • dev:直接启动 Vite 开发服务器;
  • monorepo:dev:使用 vite.config.monorepo.ts 在仓库根工作区中解析各@lexical/*本地包,适合在 Lexical monorepo 内联调试;
  • build:先执行tsc类型检查,再执行vite build产出产物;
  • preview:本地预览构建结果。

示例依赖以0.50.0版本为核心的lexical@lexical/html@lexical/extension@lexical/react@lexical/rich-text@lexical/history@lexical/selection@lexical/utils,UI 层使用@ark-ui/react(Tabs、Combobox、Splitter、TreeView)、shiki(HTML/JSON 高亮)、lucide-react(图标)与prettier(格式化),类型侧使用csstype提供 CSS 属性类型。

启动后,编辑器下方有三个 Tab:Style Tree(节点树 + 样式编辑器)、HTML$generateHtmlFromNodes导出结果)、JSONeditorState.toJSON()序列化结果),它们由 App.tsx 中的 Ark UI Tabs 组装。


2. 核心 API 全景:NodeState、DOMRenderExtension、DOMImportExtension

本示例之所以"小而全",是因为它恰好覆盖了三条新一代扩展链路:

2.1 NodeState:给任意节点挂载"自定义字段"

NodeState 由 packages/lexical/src/LexicalNodeState.ts 提供,核心 API 为:

  • createState(key, valueConfig):创建StateConfigkey在同一节点上必须局部唯一,开发模式下重复 key 会报错;valueConfig支持parseunparseisEqualdefaultValue等钩子(源码 L340-L360)。
  • $getState(node, stateConfig, version):读取状态。version默认为NODE_STATE_LATEST(内部先node.getLatest()),也可传NODE_STATE_DIRECT直接读取当前对象上存储的值(不要求处于 editor state 上下文)(源码 L362-L394)。
  • $setState(node, stateConfig, valueOrUpdater):写入状态,支持直接值或(prev) => next更新函数;使用更新函数时,若stateConfig.isEqual(prev, value)为真则不会将节点标记为 dirty(源码 L420-L459)。
  • $getStateChange(node, prevNode, stateConfig):比较两个版本节点的状态差异,返回[value, prevValue]null,专门用于实现updateDOM类场景(源码 L396-L418)。

NodeState 的价值在于:它把"节点上的自定义数据"从LexicalNode的子类字段中解放出来,无需新建节点类型即可给 TextNode、ElementNode 甚至 DecoratorNode 附加任意状态,且自动参与 dirty 标记、序列化(toJSON)、撤销重做等既有机制。

2.2 DOMRenderExtension:统一覆盖"创建与导出"

packages/lexical-html/src/DOMRenderExtension.ts 是实验性扩展,允许通过configExtension(DOMRenderExtension, { overrides: [...] })注册一组domOverride,每个 override 可针对特定节点类型(或'*'通配)覆盖两个钩子:

  • $decorateDOM(nextNode, prevNode, dom):节点更新到 DOM 时被调用,用于将状态增量地写入 DOM 元素;
  • $exportDOM(node, $next):节点导出为 HTML 时被调用,$next()调用后续导出逻辑,返回{element, after?}

$next()机制保证 override 是"包装"而非"替换",多个 override 与节点的默认exportDOM形成责任链,本示例正是依赖这一点在保留核心导出能力的前提下追加样式。

2.3 DOMImportExtension:在 HTML 导入时捕获内联样式

packages/lexical-html/src/import/DOMImportExtension.ts 提供新的 HTML 导入管线:通过defineImportRule定义匹配规则(match+$import),规则按列表顺序求值,"谁先不调用$next()谁决定结果",类似中间件。本示例用一条通配规则捕获所有带style属性的元素,属于该管线的典型用法。


3. 用 createState 定义可序列化的 StyleObject 状态

示例的核心状态定义集中在 styleState.ts:

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

3.1 状态值的类型设计

StyleObject由 csstype 的PropertiesHyphenFallback派生而来,只保留值为string的属性(刻意简化,不处理数组/数字型值):

export type StyleObject = Prettify<{ [K in keyof PropertiesHyphenFallback]?: | undefined | Extract<PropertiesHyphenFallback[K], string>; }>;

这保证了该状态天然具备类型安全:$setStyleProperty(node, 'text-shadow', value)之类的调用在编译期即可校验属性名与值类型。NO_STYLE = Object.freeze({})作为不可变空对象,用来表达"无样式"。

3.2 parse / unparse / isEqual:让状态"可序列化、可比较"

三个钩子决定了状态如何与字符串互相转换、如何判断相等:

function parse(v: unknown): StyleObject { return typeof v === 'string' ? getStyleObjectFromRawCSS(v) : NO_STYLE; } function unparse(style: StyleObject): string { const styles: string[] = []; for (const [k, v] of Object.entries(style)) { if (k && v) { styles.push(`${k}: ${v};`); } } return styles.sort().join(' '); }
  • parse借助 Lexical 自带的getStyleObjectFromCSS把 CSS 字符串解析为对象(getStyleObjectFromRawCSS);
  • unparse将对象反向拼成排序后的k: v;字符串,用于导出到 DOM 的style属性;
  • isEqual进行深度比较(源码 L88-L115),这是$setState判断"值是否真的变了、是否要标记 dirty"的依据。

3.3 便捷访问器

示例在createState之上封装了一套$前缀安全函数(styleState.ts L123-L170):

  • $getStyleObject(node)/getStyleObjectDirect(node):分别对应NODE_STATE_LATESTNODE_STATE_DIRECT读取(后者不经过getLatest(),用于 StyleViewPlugin 的只读面板);
  • $setStyleObject(node, valueOrUpdater):批量设置;
  • $setStyleProperty(node, prop, value):设置单个属性,支持函数式更新,值相等时返回原对象避免无谓 dirty;
  • $removeStyleProperty(node, prop):删除单个属性。

4. DOMRenderExtension:用 overrides 接管创建与导出

4.1 注册方式

在 StyleStateExtension 中通过configExtension注册:

configExtension(DOMRenderExtension, { overrides: [ domOverride([TextNode], { /* TextNode 专属:导出清理 */ }), domOverride('*', { /* 通配:样式应用与导出 */ }), ], }),

4.2 通配 override 的$decorateDOM:增量应用样式

核心是"把 NodeState 里的 StyleObject 增量同步到 DOM 元素"(styleState.ts L422-L434):

domOverride('*', { $decorateDOM(nextNode, prevNode, dom) { const managedDOM: HTMLElementWithManagedStyle = dom; const nextStyleObject = $getStyleObject(nextNode); const diffStyleObject = diffStyleObjects( getPreviousStyleObject(nextNode, prevNode, dom), nextStyleObject, ); managedDOM[PREV_STYLE_STATE] = nextStyleObject; if (diffStyleObject !== NO_STYLE) { setDOMStyleObject(dom.style, diffStyleObject); } }, // ... }),

几个值得注意的实现细节:

  • diff 而非全量覆盖diffStyleObjects(styleState.ts L172-L198)计算"从上一个样式对象到下一个样式对象"的变化,只把变更项(含被删除的属性,值为undefined)写入 DOM,避免每次 reconcile 都重写全部样式;
  • 在 DOM 元素上缓存上一状态PREV_STYLE_STATE = Symbol.for('styleState')(styleState.ts L275-L281)把上次 reconcile 的 StyleObject 直接挂在 DOM 元素上,而不是依赖 prevNode——因为nodeMutation: 'updated'时 prevNode 并不可靠;
  • 识别样式是否被核心逻辑覆盖styleStringChanged检查节点自带的__style(TextNode/ElementNode 上默认存在的字符串样式属性)是否变化。若变化说明上游el.style.foo = ...已经改过该属性,此时放弃增量 diff、回退到全量写入,避免把旧值又写回去(styleState.ts L283-L308)。

4.3 通配 override 的$exportDOM:导出样式到 HTML

导出时把 StyleObject 写进元素或after钩子返回的元素(styleState.ts L435-L457):

$exportDOM(node, $next) { const output = $next(); const style = $getStyleObject(node); if (output.element && style !== NO_STYLE) { if (output.after) { return { ...output, after: generatedElement => { const el = output.after ? output.after(generatedElement) : generatedElement; if (isHTMLElement(el)) { setDOMStyleObject(el.style, style); } return el; }, }; } else if (isHTMLElement(output.element)) { setDOMStyleObject(output.element.style, style); } } return output; }

它保留$next()的导出结果,仅在存在after钩子(元素在之后才真正生成)时包装该钩子,否则直接写入output.element

4.4 TextNode 专属 override:导出后的"样式清理"

针对 TextNode 的 override(styleState.ts L390-L421)处理两个历史遗留问题:

  1. 移除不必要的white-space: pre-wrap:核心导出为保留文本内相邻空格会设置pre-wrap。示例检测文本是否真的存在首部/尾部/连续空格(/^\s|\s$|\s\s/),不存在时调用el.style.removeProperty('white-space')
  2. 剥离空style=""属性:某些浏览器或 JSDOM 中removeProperty会残留空style="",或上游 override 清空唯一属性后也会残留。代码遍历result.element及其所有后代,凡style属性 trim 后为空一律removeAttribute('style'),保证导出 HTML 干净。

这个 override 展示了domOverride的"包装"哲学:$next()产出基础结果,override 只做精修,互不破坏。


5. DOMImportExtension:用一条通配规则捕获内联 style

5.1 规则定义

createStyleImportRule(styleState.ts L347-L372)是旧版constructStyleImportMap方案(逐个包装 TextNode importer)的替代品:

export function createStyleImportRule(styleMapping: StyleMapping = input => input) { return defineImportRule({ $import: (_ctx, el, $next) => { const extra = el.hasAttribute('style') ? extractExtraStyles(el) : null; const out = $next(); if (extra) { const mapped = styleMapping(extra); for (const child of out) { if ($isTextNode(child)) { $setStyleObject(child, prev => mergeStyleObjects(prev, mapped)); } } } return out; }, match: sel.any().attr('style', /\S/), name: '@lexical/examples/node-state-style/style', }); }

要点:

  • match: sel.any().attr('style', /\S/)匹配所有带非空白style属性的元素,是一条通配规则;
  • $import先调用$next()走完后续所有匹配规则(包括 CoreImportExtension 按标签驱动的节点创建),再对产出的子节点做后处理——只对TextNode注入样式,非文本节点自然跳过;
  • styleMapping参数(默认恒等映射)允许调用方对导入的样式做变换;
  • 规则名@lexical/examples/node-state-style/style在开发模式下用于诊断与冲突提示。

5.2 与核心内联格式规则的分工

extractExtraStyles(styleState.ts L319-L334)遍历el.style中每个 CSS 属性,跳过IGNORE_STYLES集合内的四项:

const IGNORE_STYLES: Set<keyof StyleObject> = new Set([ 'font-weight', 'text-decoration', 'font-style', 'vertical-align', ]);

这四项正是核心内联格式规则(bold/italic/underline/strikethrough)已经处理的属性。示例只捕获"额外样式",把粗体、斜体等格式交给核心机制,避免状态重复与互相覆盖。createStyleImportRule的注释明确说明这是"DOMImportExtension 原生方案",替代旧版构造constructStyleImportMap时"逐个包装 TextNode importer"的 workaround。

5.3 注册与优先级

StyleStateExtensiondependencies中注册(styleState.ts L374-L383):

dependencies: [ CoreImportExtension, configExtension(DOMImportExtension, { rules: [createStyleImportRule()], }), configExtension(DOMRenderExtension, { overrides: [...] }), ],

规则注释指出:该通配规则以最低优先级注册在规则数组末尾match是通配,具体处理靠$import体内的$isTextNode判断),因此CoreImportExtension的按标签规则仍然主导节点创建,样式捕获只是"装饰层"。


6. 命令层:PATCH_TEXT_STYLE_COMMAND 与选中文本样式补丁

6.1 命令定义

示例定义了一个编辑器级命令(styleState.ts L219-L221):

export const PATCH_TEXT_STYLE_COMMAND = createCommand< StyleObject | ((prevStyles: StyleObject) => StyleObject) >('PATCH_TEXT_STYLE_COMMAND');

并在StyleStateExtension.register中将其与$patchSelectedTextStyle绑定(styleState.ts L462-L468):

register: editor => editor.registerCommand( PATCH_TEXT_STYLE_COMMAND, $patchSelectedTextStyle, COMMAND_PRIORITY_EDITOR, ),

6.2 补丁逻辑

$patchSelectedTextStyle(styleState.ts L245-L273):

  • 无当前 selection 时回退到$getPreviousSelection()的克隆并$setSelection恢复;
  • 值为函数时直接作为 updater,值为对象时包装为mergeStyleObjects(prev, obj)合并;
  • 折叠(collapsed)选区且焦点在 TextNode 上时只更新该节点;否则用$forEachSelectedTextNode遍历所有选中文本节点逐一更新。

6.3 工具栏触发

工具栏中的"Toggle Text Style"按钮(ToolbarPlugin.tsx L144-L160)在isStyled为假时派发一个text-shadow样式:

editor.dispatchCommand( PATCH_TEXT_STYLE_COMMAND, isStyled ? () => NO_STYLE : { 'text-shadow': '1px 1px 2px red, 0 0 1em blue, 0 0 0.2em blue', }, );

isStyled$selectionHasStyle()(styleState.ts L227-L243)驱动——它利用 caret range 的getTextSlices()iterNodeCarets('root')检测选区中是否有任意文本节点携带非空样式。工具栏其余按钮(加粗/斜体/下划线/删除线)直接使用核心FORMAT_TEXT_COMMAND,撤销/重做按钮使用UNDO_COMMAND/REDO_COMMAND,活跃态则由ToolbarExtension内的 signal(isBoldisItalic等)通过useExtensionSignalValue提供给 React(ToolbarPlugin.tsx L41-L79)。


7. StyleViewPlugin:可视化调试任意节点的样式

StyleViewPlugin(StyleViewPlugin.tsx)把上文所有能力"可视化"出来:

  • 节点树:用 Ark UI TreeView 渲染整个EditorState的节点树(Root/Element/Text/Decorator 分别使用不同图标),点击节点可回填 editor selection($setSelectionFromCaretRange);
  • 样式面板:右侧面板展示当前选中节点的 StyleObject(通过getStyleObjectDirect直接读取节点对象上的状态),每一行是一个"属性名 + 内联可编辑值",可删除、可新增;
  • 新增属性CSSPropertyComboBoxdocument.body.style枚举所有浏览器支持的 CSS 属性名(转为 kebab-case)并提供自动补全(StyleViewPlugin.tsx L604-L666);
  • 编辑值:每个属性值是一个嵌套的纯文本 Lexical 编辑器(StyleValueEditor),在 300ms 防抖或失焦/回车时通过editor.update调用$setStyleProperty,并以skip-dom-selectionskip-scroll-into-view两个 update tag 避免干扰主编辑器选区(StyleViewPlugin.tsx L360-L505)。

同时,ShikiViewPlugin 用$generateHtmlFromNodes实时生成 HTML、用editorState.toJSON()生成 JSON,再经 prettier 格式化与 shiki(nord主题)高亮,让你能直观对照"NodeState 样式 → DOM 导出 → 序列化 JSON"三者的一致性——这正是验证unparse/parse与 DOMRender/DOMImport 扩展是否闭合的调试利器。


8. 从源码视角理解的设计要点

结合 LexicalNodeState.ts 与示例实现,可以提炼出该模式的几个关键设计决策:

  1. 状态与 DOM 解耦:样式状态存于节点(参与历史与序列化),DOM 只是它的投影。$decorateDOM每次 reconcile 都基于"上一状态 → 下一状态"的 diff 更新,配合 DOM 元素上的Symbol.for('styleState')缓存,规避了 prevNode 不可靠的问题。
  2. 解析闭环unparse(导出为 CSS 字符串)与parse(导入时解析)互为逆运算,配合IGNORE_STYLES让"核心格式规则已处理的属性"不进入 NodeState,避免状态重复所有权。
  3. 责任链而非替换$next()贯穿$import$exportDOM,override 只是包装层。从源码结构看,这正是 DOMRenderExtension / DOMImportExtension 被设计为"可组合中间件"的原因,多个库的 override 可以叠加而不互相破坏。
  4. 命令驱动的 UI 层:样式操作统一收敛到PATCH_TEXT_STYLE_COMMAND,工具栏与(理论上的)快捷键、菜单可以复用同一套逻辑,UI 状态用 signal 而非 React state 同步,减少重复渲染。

9. 实战提示

  • 该示例同时涉及实验性 API:DOMRenderExtensionDOMImportExtension在 DOMRenderExtension.ts 与 DOMImportExtension.ts 中均标注@experimental,其形态可能随版本演进;示例锁定的lexical/@lexical/*版本为0.50.0,跨版本使用时请以当前仓库源码为准。
  • 若要在自己的编辑器复刻"任意节点附加任意 CSS 属性"能力,最小路径是:createState定义状态 →$setStyleProperty/$removeStyleProperty读写 →domOverride('*', ...)$decorateDOM/$exportDOM负责 DOM 投影与导出 →defineImportRule通配规则负责导入捕获。完整参考实现可对照 styleState.ts 阅读,核心扩展 API 文档见 lexical-html 包 与 LexicalNodeState.ts。

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

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

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

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

立即咨询