Lexical 序列化与反序列化完全指南:HTML 与 JSON 双向转换的节点级控制
2026/9/13 15:12:33 网站建设 项目流程

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 提供的 HTML(Lexical → HTMLHTML → Lexical)和 JSON(EditorState.toJSON()与节点importJSON/exportJSON)两条序列化通道:通过exportDOM/importDOMhtml配置属性、updateFromJSON等节点级 API,你可以精确控制每个自定义节点在两种格式中的表示。读完本文,你将能够为自定义节点实现完整、可逆、向后兼容的序列化逻辑,并处理扩展样式(如内联 CSS)的高保真往返。

HTML 序列化:与外部编辑器交换数据的主通道

HTML 序列化当前主要用于通过复制粘贴功能在 Lexical 与非 Lexical 编辑器(如 Google Docs、Quip)之间传输数据,该能力由@lexical/clipboard提供;同时,@lexical/html包还提供了通用的Lexical → HTMLHTML → 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如何表示为 HTML:

exportDOM(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()取代数字优先级)、结构化 schema(BlockSchema/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将样式表内联,然后忽略STYLESCRIPT标签,遍历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的返回值是一个映射,键为小写的(DOM)Node.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: Array<LexicalNode>) => Array<LexicalNode>; forChild?: DOMChildConversion; node: null | LexicalNode | Array<LexicalNode>; }; type DOMChildConversion = ( lexicalNode: LexicalNode, parentLexicalNode: LexicalNode | null | undefined, ) => LexicalNode | null | undefined;

@lexical/code是这套设计价值的绝佳例证。GitHub 用 HTML<table>元素表示复制代码的结构。如果把所有 HTML<table>元素都解释为字面意义的表格,那么从 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的导入映射还为tdtr提供了 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
importDOMexportDOM的关键区别

importDOMexportDOM允许在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 样式属性的序列化与反序列化。这是一个非常常见的需求,下面给出处理最常见用例的配方。

首先覆盖基础 TextNode:

const 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这几个可能携带样式的标签转换。patchStyleConversionforChild钩子中读取元素的style属性(背景色、颜色、字体族、字重、字号、文本装饰),将它们拼成内联style字符串并通过setStyle写回文本节点,从而实现“HTML 内联样式 → TextNode.style”的保真导入。导出方向无需额外代码:由于没有新增序列化属性,直接复用 TextNode 的exportDOM(它会把style字段输出到元素上)即可完成往返。

JSON 序列化:持久化与跨编辑器迁移

JSON 通道主要用于把EditorState序列化为快照以持久化存储,或在编辑器实例之间迁移。它在「节点 → 可移植 JSON 对象」的意义上与 HTML 通道平行,但形态更严格(type+version+ 节点自有字段)。

提示:如果你的自定义节点使用带NodeState$config,那么exportJSONimportJSONupdateFromJSON都会自动为你生成。扁平的状态键会被提升到序列化节点的顶层,其余键嵌套在'$'下——参见 Flat serialization with$config和 legacy-property upgrade recipe。

Lexical -> JSON:生成快照

要由EditorState生成 JSON 快照,可以调用EditorState对象上的toJSON()方法:

const editorState = editor.getEditorState(); const json = editorState.toJSON();

或者,如果想生成EditorState的字符串化版本,可以直接使用JSON.stringify

const 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 表示,但自定义节点需要自行定义。

下面是HeadingNodeexportJSON示例:

export type SerializedHeadingNode = Spread< { tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; }, SerializedElementNode >; exportJSON(): SerializedHeadingNode { return { ...super.exportJSON(), tag: this.getTag(), }; }

TextNode 的exportJSON是另一个可直接对照的实现:它返回detailformatmodestyletext等自有字段,再通过...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字段与LexicalNodegetType()一致至关重要。

建议在importJSON中使用updateFromJSON方法,以简化实现并让基类能继续扩展。

下面是HeadingNodeimportJSON示例:

static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { return $createHeadingNode().updateFromJSON(serializedNode); } updateFromJSON( serializedNode: LexicalUpdateJSON<SerializedHeadingNode>, ): 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并设置textformatdetailmodestyle

注意:此方法使用的输入类型在一般情况下并不健全(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方法应忽略typeversion,以支持子类化与代码复用。理想情况下,你应当只以向后兼容的方式演进类型(新字段可选),和/或为你的类中存储版本号使用一个唯一命名的属性。总体而言,最好的做法是让几乎所有属性都是可选的,并由节点为每个属性提供默认值。这样可以减少样板代码,并产生更小的 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),仅供参考

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

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

立即咨询