ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Lexical 序列化与反序列化完全指南:HTML 与 JSON 双向转换的节点级控制

Lexical 序列化与反序列化完全指南:HTML 与 JSON 双向转换的节点级控制 Lexical 序列化与反序列化完全指南HTML 与 JSON 双向转换的节点级控制【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical导读Lexical 在内存中维护编辑器的状态并随用户输入实时更新。要把它传输给其他编辑器或持久化存储就需要将状态转换为可移植的序列化格式。本文基于 packages/lexical-website/docs/serialization/serialization.md 展开系统讲解 Lexical 提供的 HTMLLexical → HTML与HTML → Lexical和 JSONEditorState.toJSON()与节点importJSON/exportJSON两条序列化通道通过exportDOM/importDOM、html配置属性、updateFromJSON等节点级 API你可以精确控制每个自定义节点在两种格式中的表示。读完本文你将能够为自定义节点实现完整、可逆、向后兼容的序列化逻辑并处理扩展样式如内联 CSS的高保真往返。HTML 序列化与外部编辑器交换数据的主通道HTML 序列化当前主要用于通过复制粘贴功能在 Lexical 与非 Lexical 编辑器如 Google Docs、Quip之间传输数据该能力由lexical/clipboard提供同时lexical/html包还提供了通用的Lexical → HTML与HTML → Lexical转换工具。核心入口函数定义在 packages/lexical-html/src/index.ts 中。Lexical - HTML按选择范围导出从编辑器生成 HTML 时可以传入一个 selection 对象来限定到某个选区或传入null转换整个编辑器import {$generateHtmlFromNodes} from lexical/html; const htmlString $generateHtmlFromNodes(editor, selection | null);从源码看$generateHtmlFromNodes会校验运行环境headless 环境需要 JSDOM 之类的浏览器实现随后在编辑器作用域内创建一个div容器并调用内部的$generateDOMFromNodes最后返回容器的innerHTML见 packages/lexical-html/src/index.ts。它必须在活跃的编辑器作用域内调用——即editor.update(...)、editor.read(...)或editor.getEditorState().read(callback, {editor})中。提示面向新代码可以考虑使用DOMRenderExtension来替代或补充在每个节点类上定义exportDOM。它允许你在中间件式的链中以“per-node-class或全局”方式声明$exportDOM/$createDOM/$updateDOM/$decorateDOM/$getDOMSlot/$shouldExclude/$shouldInclude/$extractWithChild覆盖项并且能跨扩展干净地组合。同一份声明既作用于编辑器内的协调reconciliation也作用于 HTML 导出无需维护两条并行的代码路径。相关实现与类型见 packages/lexical-html/src/DOMRenderExtension.ts 和 packages/lexical-html/src/types.ts。控制节点导出LexicalNode.exportDOM()通过在节点上添加exportDOM()方法你可以控制一个LexicalNode如何表示为 HTMLexportDOM(editor: LexicalEditor): DOMExportOutput当把编辑器状态转换为 HTML 时Lexical 会遍历当前编辑器状态或其选中的子集并对每个节点调用exportDOM将其转换为HTMLElement。有时在节点转换为 HTML 之后还需要进行一些后处理。为此DOMExportOutput暴露了 “after” API允许exportDOM指定一个在转换为HTMLElement之后运行的函数export type DOMExportOutput { after?: (generatedElement: ?HTMLElement) ?HTMLElement, element?: HTMLElement | null, };如果exportDOM返回值的element属性为 null该节点将不会出现在序列化输出中。源码中$appendNodesToHTML正是据此决定是否将元素追加到输出容器packages/lexical-html/src/index.ts若element为空则返回 false 并跳过对于after回调HTMLElement 需要先挂入 DOM 树再通过replaceWith原地替换而 DocumentFragment 则须在after之后再交给父级避免片段被取空后写入已脱离的节点。HTML - Lexical解析 DOM 生成节点提示面向新代码可以考虑使用DOMImportExtension来替代或补充每个节点类上的static importDOM()。它用类型化选择器sel.tag(...)、sel.css(...)、中间件式规则用$next()取代数字优先级、结构化 schemaBlockSchema/InlineSchema/ListSchema/TableSchema、可配置的文本空白处理ImportWhitespaceConfig以及用于跨规则通信的类型化上下文系统取代了DOMConversionMap机制还提供默认开启的 DOM 预处理链默认行为是样式表内联。rich-text、list、link、table、code、horizontal-rule 等均有按包分发的 bundle。可搭配ClipboardImportExtension将粘贴内容路由到新管线。相关实现位于 packages/lexical-html/src/import/DOMImportExtension.ts。在浏览器环境中解析 HTML 字符串并生成 Lexical 节点import {$generateNodesFromDOM} from lexical/html; editor.update(() { // 在浏览器中可以使用原生 DOMParser API 解析 HTML 字符串。 const parser new DOMParser(); const dom parser.parseFromString(htmlString, textHtmlMimeType); // 拿到 DOM 实例后生成 LexicalNodes 就很简单了。 const nodes $generateNodesFromDOM(editor, dom); // 选中根节点 $getRoot().select(); // 在选区处插入这些节点。 $insertNodes(nodes); });$generateNodesFromDOM的实现packages/lexical-html/src/index.ts会先调用$inlineStylesFromStyleSheetsDOM将样式表内联然后忽略STYLE与SCRIPT标签遍历document.body的子节点逐个转换为 Lexical 节点最后展开内部的 artificial nodes。如果你的运行环境是 headless 模式可以使用 JSDOM 完成同样的工作import {createHeadlessEditor} from lexical/headless; import {$generateNodesFromDOM} from lexical/html; // 一旦从 HTML 生成 LexicalNodes就可以用这些节点初始化一个编辑器实例。 const editorNodes [] // 你在编辑器上注册的任何自定义节点 const editor createHeadlessEditor({ ...config, nodes: editorNodes }); editor.update(() { // 在 headless 环境中可以用 JSDom 之类的包解析 HTML 字符串。 const dom new JSDOM(htmlString); // 拿到 DOM 实例后生成 LexicalNodes 就很简单了。 const nodes $generateNodesFromDOM(editor, dom.window.document); // 选中根节点 $getRoot().select(); // 在选区处插入这些节点。 const selection $getSelection(); selection.insertNodes(nodes); });提示请记住状态更新是异步的因此紧接着执行editor.getEditorState()可能不会返回期望的内容。要避免这一点可以在editor.update中传入discrete: true进行离散更新。控制节点导入LexicalNode.importDOM()通过在LexicalNode上添加importDOM()方法你可以控制一个HTMLElement如何表示为 Lexical 节点static importDOM(): DOMConversionMap | null;importDOM的返回值是一个映射键为小写的DOMNode.nodeName值为一个指定了转换函数及转换优先级priority的对象。这样LexicalNodes就能声明自己可以转换哪些类型的 DOM 节点以及它们的转换相对优先级。这在“带特定属性的 DOM 节点应被解释为一种LexicalNode否则应表示为另一种LexicalNode”的场景中非常有用。type DOMConversionMap Record string, (node: HTMLElement) DOMConversion | null ; type DOMConversion { conversion: DOMConversionFn; priority: 0 | 1 | 2 | 3 | 4; }; type DOMConversionFn (element: HTMLElement) DOMConversionOutput | null; type DOMConversionOutput { after?: (childLexicalNodes: ArrayLexicalNode) ArrayLexicalNode; forChild?: DOMChildConversion; node: null | LexicalNode | ArrayLexicalNode; }; type DOMChildConversion ( lexicalNode: LexicalNode, parentLexicalNode: LexicalNode | null | undefined, ) LexicalNode | null | undefined;lexical/code是这套设计价值的绝佳例证。GitHub 用 HTMLtable元素表示复制代码的结构。如果把所有 HTMLtable元素都解释为字面意义的表格那么从 GitHub 粘贴的代码在 Lexical 中就会变成 Lexical TableNode。相反CodeNode声明它同样可以处理table元素。真实实现位于 packages/lexical-code-core/src/CodeNode.ts与文档示例一致class CodeNode extends ElementNode { ... static importDOM(): DOMConversionMap | null { return { ... table: (node: Node) { if (isGitHubCodeTable(node as HTMLTableElement)) { return { conversion: convertTableElement, priority: 3, }; } return null; }, ... }; } ... }如果导入的table与预期的 GitHub 代码 HTML 结构不符就返回 null让该节点由更低优先级的转换来处理。此外CodeNode的导入映射还为td、tr提供了 no-op 转换优先级同为 3确保代码表格内部的单元格不会被回退成普通表格节点packages/lexical-code-core/src/CodeNode.ts。与exportDOM类似importDOM也暴露了允许对转换后的节点进行后处理的 API。转换函数返回DOMConversionOutput它可以指定一个对每个转换后的子节点运行的函数forChild或一个在全部子节点转换完成后只运行一次的函数after。关键区别在于forChild对当前节点每个深度嵌套的子节点都会运行而after只在该节点及其所有子节点转换完成后运行一次。从源码看转换的匹配过程如下packages/lexical-html/src/index.ts按小写nodeName从编辑器的_htmlConversions中取出全部候选转换依次调用其匹配函数选择优先级最高者同等优先级下取后注册的——通常是应用自定义节点或HTMLConfig[import]的转换随后对 DOM 子树递归执行$createNodesFromDOM未转换的块级 DOM 节点的连续内联子节点会被包装进段落或 artificial node以保证结果节点树的块级结构合法。通过html属性统一配置导入与导出CreateEditorArgs中的html属性提供了另一种配置 HTML 导入/导出行为的方式无需子类化或节点替换。它包含两个属性import—— 与importDOM类似控制 HTML 元素如何转换为LexicalNodes。区别在于它不是直接在每个LexicalNode上定义转换而是提供一个可以在编辑器初始化时轻松覆盖的配置。export—— 与exportDOM类似自定义LexicalNodes如何序列化为 HTML。通过html.export用户可以集中地为各种节点指定转换提供灵活的覆盖机制无需扩展或替换具体的LexicalNodes。与importDOM和exportDOM的关键区别importDOM和exportDOM允许在LexicalNode类内部直接定义高度定制、节点专属的转换而html属性支持更广泛的、编辑器级的配置。它适合以下场景一致的转换Consistent Transformations希望不同节点间有统一的导入/导出行为而不必逐个调整每个节点。无需子类化No Subclassing Required导入导出逻辑的覆盖在编辑器配置层面完成简化定制并减少大量子类化需求。类型定义type HTMLConfig { export?: DOMExportOutputMap; // 可选映射定义节点如何导出为 HTML。 import?: DOMConversionMap; // 可选记录定义 HTML 如何转换为节点。 };适用示例仓库中的富文本示例examples/react-rich入口为 examples/react-rich/src/App.tsx即是在真实编辑器中配置节点与主题、进而影响导入导出行为的完整参考实现。处理扩展 HTML 样式高保真往返的 ExtendedTextNode 配方由于 TextNode 是所有 Lexical 包包括纯文本场景的基础在它内部处理富文本逻辑是不合适的。这就需要在 JSON - HTML 之间实现全保真时覆盖 TextNode 来处理 HTML/CSS 样式属性的序列化与反序列化。这是一个非常常见的需求下面给出处理最常见用例的配方。首先覆盖基础 TextNodeconst initialConfig: InitialConfigType { namespace: editor, theme: editorThemeClasses, onError: (error: any) console.log(error), nodes: [ ExtendedTextNode, { replace: TextNode, with: (node: TextNode) new ExtendedTextNode(node.__text), withKlass: ExtendedTextNode, }, ListNode, ListItemNode, ] };然后创建一个新的 Extended Text Node 插件import { $applyNodeReplacement, $isTextNode, DOMConversion, DOMConversionMap, DOMConversionOutput, NodeKey, TextNode, SerializedTextNode, LexicalNode } from lexical; export class ExtendedTextNode extends TextNode { constructor(text: string, key?: NodeKey) { super(text, key); } static getType(): string { return extended-text; } static clone(node: ExtendedTextNode): ExtendedTextNode { return new ExtendedTextNode(node.__text, node.__key); } static importDOM(): DOMConversionMap | null { const importers TextNode.importDOM(); return { ...importers, code: () ({ conversion: patchStyleConversion(importers?.code), priority: 1 }), em: () ({ conversion: patchStyleConversion(importers?.em), priority: 1 }), span: () ({ conversion: patchStyleConversion(importers?.span), priority: 1 }), strong: () ({ conversion: patchStyleConversion(importers?.strong), priority: 1 }), sub: () ({ conversion: patchStyleConversion(importers?.sub), priority: 1 }), sup: () ({ conversion: patchStyleConversion(importers?.sup), priority: 1 }), }; } static importJSON(serializedNode: SerializedTextNode): TextNode { return $createExtendedTextNode().updateFromJSON(serializedNode); } isSimpleText() { return this.__type extended-text this.__mode 0; } // 不需要在此添加 exportJSON因为我们没有增加任何新属性 } export function $createExtendedTextNode(text: string ): ExtendedTextNode { return $applyNodeReplacement(new ExtendedTextNode(text)); } export function $isExtendedTextNode(node: LexicalNode | null | undefined): node is ExtendedTextNode { return node instanceof ExtendedTextNode; } function patchStyleConversion( originalDOMConverter?: (node: HTMLElement) DOMConversion | null ): (node: HTMLElement) DOMConversionOutput | null { return (node) { const original originalDOMConverter?.(node); if (!original) { return null; } const originalOutput original.conversion(node); if (!originalOutput) { return originalOutput; } const backgroundColor node.style.backgroundColor; const color node.style.color; const fontFamily node.style.fontFamily; const fontWeight node.style.fontWeight; const fontSize node.style.fontSize; const textDecoration node.style.textDecoration; return { ...originalOutput, forChild: (lexicalNode, parent) { const originalForChild originalOutput?.forChild ?? ((x) x); const result originalForChild(lexicalNode, parent); if ($isTextNode(result)) { const style [ backgroundColor ? background-color: ${backgroundColor} : null, color ? color: ${color} : null, fontFamily ? font-family: ${fontFamily} : null, fontWeight ? font-weight: ${fontWeight} : null, fontSize ? font-size: ${fontSize} : null, textDecoration ? text-decoration: ${textDecoration} : null, ] .filter((value) value ! null) .join(; ); if (style.length) { return result.setStyle(style); } } return result; } }; }; }这个配方的核心思路是ExtendedTextNode.importDOM()先继承TextNode.importDOM()的所有转换器...importers再用patchStyleConversion包装code/em/span/strong/sub/sup这几个可能携带样式的标签转换。patchStyleConversion在forChild钩子中读取元素的style属性背景色、颜色、字体族、字重、字号、文本装饰将它们拼成内联style字符串并通过setStyle写回文本节点从而实现“HTML 内联样式 → TextNode.style”的保真导入。导出方向无需额外代码由于没有新增序列化属性直接复用 TextNode 的exportDOM它会把style字段输出到元素上即可完成往返。JSON 序列化持久化与跨编辑器迁移JSON 通道主要用于把EditorState序列化为快照以持久化存储或在编辑器实例之间迁移。它在「节点 → 可移植 JSON 对象」的意义上与 HTML 通道平行但形态更严格typeversion 节点自有字段。提示如果你的自定义节点使用带NodeState的$config那么exportJSON、importJSON和updateFromJSON都会自动为你生成。扁平的状态键会被提升到序列化节点的顶层其余键嵌套在$下——参见 Flat serialization with$config和 legacy-property upgrade recipe。Lexical - JSON生成快照要由EditorState生成 JSON 快照可以调用EditorState对象上的toJSON()方法const editorState editor.getEditorState(); const json editorState.toJSON();或者如果想生成EditorState的字符串化版本可以直接使用JSON.stringifyconst editorState editor.getEditorState(); const jsonString JSON.stringify(editorState);控制节点导出LexicalNode.exportJSON()通过在节点上添加exportJSON()方法你可以控制一个LexicalNode如何表示为 JSON。务必通过调用super来扩展父类的序列化例如{ ...super.exportJSON(), /* your other properties */ }。export type SerializedLexicalNode { type: string; version: number; }; exportJSON(): SerializedLexicalNode当把编辑器状态转换为 JSON 时Lexical 会遍历当前编辑器状态并对每个节点调用exportJSON将其转换为表示该节点 JSON 对象的SerializedLexicalNode。Lexical 的内置节点已经定义了 JSON 表示但自定义节点需要自行定义。下面是HeadingNode的exportJSON示例export type SerializedHeadingNode Spread { tag: h1 | h2 | h3 | h4 | h5 | h6; }, SerializedElementNode ; exportJSON(): SerializedHeadingNode { return { ...super.exportJSON(), tag: this.getTag(), }; }TextNode 的exportJSON是另一个可直接对照的实现它返回detail、format、mode、style、text等自有字段再通过...super.exportJSON()附带基类字段见 packages/lexical/src/nodes/LexicalTextNode.ts。控制节点导入LexicalNode.importJSON()通过在节点上添加importJSON()方法你可以控制一个LexicalNode如何从 JSON 反序列化回节点。export type SerializedLexicalNode { type: string; version: number; }; importJSON(jsonNode: SerializedLexicalNode): LexicalNode这个方法的工作方式与exportJSON相反。Lexical 使用 JSON 对象上的type字段来决定映射到哪个 Lexical 节点类因此保持type字段与LexicalNode的getType()一致至关重要。建议在importJSON中使用updateFromJSON方法以简化实现并让基类能继续扩展。下面是HeadingNode的importJSON示例static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { return $createHeadingNode().updateFromJSON(serializedNode); } updateFromJSON( serializedNode: LexicalUpdateJSONSerializedHeadingNode, ): this { return super.updateFromJSON(serializedNode).setTag(serializedNode.tag); }简化导入LexicalNode.updateFromJSON()updateFromJSON是 Lexical 0.23 引入的方法用于简化importJSON的实现基类可以借此暴露它“根据 JSON 设置节点全部属性”的代码供任何子类复用。上面的 ExtendedTextNode 示例中importJSON一行$createExtendedTextNode().updateFromJSON(serializedNode)即完整继承了 TextNode 的字段解析逻辑。TextNode 本身的实现packages/lexical/src/nodes/LexicalTextNode.ts依次调用super.updateFromJSON并设置text、format、detail、mode、style。注意此方法使用的输入类型在一般情况下并不健全not sound但只要子类只向 JSON 添加可选属性它就是安全的。即使不健全只要你的importJSON在调用updateFromJSON之前不把节点向上转型upcast库内的用法就是安全的。export type SerializedExtendedTextNode Spread // UNSAFE. 该属性不是可选的 { newProperty: string }, SerializedTextNode ;export type SerializedExtendedTextNode Spread // SAFE. 该属性是可选的 { newProperty?: string }, SerializedTextNode ;原因在于可能发生向更通用类型的转型例如const serializedNode: SerializedTextNode { /* ... */ }; const newNode: TextNode $createExtendedTextNode(); // 这能通过类型检查但如果 updateFromJSON 要求 newProperty 存在运行时就会失败 newNode.updateFromJSON(serializedNode);版本化与破坏性变更需要特别注意的是应避免对 JSON 对象中的既有字段做破坏性变更尤其当向后兼容是编辑器的重要考量时。因此我们建议使用 version 字段来区分节点功能增改过程中产生的不同版本。以下是 Lexical 基础TextNode类的序列化类型定义import type {Spread} from lexical; // Spread 是一个 TypeScript 工具类型允许我们将属性展开到 // 基础 SerializedLexicalNode 类型之上。 export type SerializedTextNode Spread { detail: number; format: number; mode: TextModeType; style: string; text: string; }, SerializedLexicalNode ;如果要对上述TextNode做修改务必不要删除或改动既有属性否则可能造成数据损坏。正确做法是改为添加新的可选属性字段export type SerializedTextNode Spread { detail: number; format: number; mode: TextModeType; style: string; text: string; // 我们新增的字段 newField?: string, }, SerializedLexicalNode ;扁平 version 属性的风险updateFromJSON方法应忽略type和version以支持子类化与代码复用。理想情况下你应当只以向后兼容的方式演进类型新字段可选和/或为你的类中存储版本号使用一个唯一命名的属性。总体而言最好的做法是让几乎所有属性都是可选的并由节点为每个属性提供默认值。这样可以减少样板代码并产生更小的 JSON。不再推荐使用version的原因在于它无法与子类组合。考虑如下继承层级class TextNode { exportJSON() { return { /* ... */, version: 1 }; } } class ExtendedTextNode extends TextNode { exportJSON() { return { ...super.exportJSON() }; } }如果TextNode升级到version: 2那么这个版本和新序列化会通过super.exportJSON()调用传播到ExtendedTextNode但这样就没有地方为ExtendedTextNode存储自己的版本了反之亦然。如果ExtendedTextNode显式指定了version那么即使基类 JSON 表示发生了变化基类的版本也会被忽略class TextNode { exportJSON() { return { /* ... */, version: 2 }; } } class ExtendedTextNode extends TextNode { exportJSON() { // 父类的布局已改变但版本信息丢失了 return { ...super.exportJSON(), version: 1 }; } }于是就会出现这种情况因为包升级导致基类版本变化ExtendedTextNode可能存在两个拥有相同版本的 JSON 布局。如果确实存在不兼容的表示最好选择一个新的 type。这基本上是唯一能强制旧配置失败的方式因为importJSON实现通常不做运行时校验而是危险地假设值的类型正确。也存在其他支持可组合版本号的方案比如嵌套父类数据或在每个子类中为版本属性选用不同名称。但实践中只要序列化被正确解析显式版本号通常是多余的因此推荐使用更简单的方式——以大部分可选属性构成的扁平表示。小结通道导出方向导入方向编辑器级统一配置说明HTMLexportDOM()/$generateHtmlFromNodesstatic importDOM()/$generateNodesFromDOMhtml.export/html.import用于与非 Lexical 编辑器复制粘贴交换需在浏览器或 JSDOM 环境下运行JSONexportJSON()/EditorState.toJSON()static importJSON()updateFromJSON()—节点类各自实现用于持久化与跨编辑器迁移type字段必须与getType()一致无论走哪条通道两条基本原则贯穿始终导出时通过super继承父类序列化导入时优先使用updateFromJSON复用基类解析逻辑同时保持序列化结构的向后兼容新增可选字段、避免依赖扁平version是保证长期数据安全的关键。【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进