Editor State
Editor State 编辑器状态就是用于描述编辑器的抽象数据模型的一份实例化数据。
方法 editor.getEditorState() 可以获取编辑器当前的状态

Editor State 编辑器状态主要由两部分组成
- 节点树
_nodeMap:记录了编辑器的内容(由各种节点构成),以映射 Map 这种数据结构来列出
其中只有一个根节点,是key为root的节点
其他节点的key都是一个数字提示
各节点之间通过
__next、__parent、__prev属性共同构成了一个节点树结构根节点的
__next、__parent、__prev属性都是null - 选择范围
_selection:记录了编辑器中被框选中的部分,如果没有选中任何东西则为null

方法 editorState.clone() 可以基于原有的编辑器状态 editorState 生成一份拷贝,该方法可以传递 null 作为参数,以便获取无选区的 editor state 拷贝
Editor State 编辑器状态有两个阶段/生命周期
- updating state 正处于更新中:此时的 state 可以认为是 mutable 可变动的
这时的 state 也可以称为 pending state 待应用的状态,即将在更新中应用到页面 DOM 元素上,一般是指在editor.update(callback)的回调函数中操作/生成的的 Editor State - locked state 已锁定:此时的 state 是 immutable 不可变动
已锁定的 state 是指那些已经应用到编辑器上的,它们不能在变动了,可以将这些 state 看作编辑器的一个快照
可编辑性
Lexical 编辑器有两种状态:只读模式 read mode 和 编辑模式 edit mode(默认模式)
说明
实际上 Lexical 是通过设置 root element(根节点所对应的页面上的 DOM 元素)的 contenteditable 属性为 "false" 或 "true" 字符串以实现不同模式切换
可以在创建编辑器实例时,通过配置对象的属性 editable 进行可编辑模式的预设
const editor = createEditor({
editable: true,
// ...
})
也可以在创建编辑器实例后,通过调用方法 editor.setEditable(Boolean) 进行设置
注意
虽然可以直接操作页面上的 DOM 元素来设置/切换编辑器的可编辑性,例如 rootElement.contenteditable='false',但是通过该方式是无法触发 registerEditableListener 侦听器作出响应的。
必须通过编辑器实例的方法 editor.setEditable(boolean) 来设置/切换其可编辑性,才可以触发侦听器作出响应
通过编辑器的方法 editor.isEditable()(返回一个布尔值)可以获取当前编辑器的可编辑状态
说明
用方法 editor.setEditable(false) 将编辑器切换到只读模式时,并没有改变页面 RootElement 根元素的 contenteditable 的值,即用户依然可以与页面上的 DOM 元素交互(输入文字),只是 Lexical 并不会对其作出响应(取消了侦听用户事件)
更新
一般通过方法 editor.update(callback) (以编程的方式手动)执行更新,在回调函数 callback 中可以调用 Lexical 提供的一些以 $ 开头的函数,可以更简单地操作/更新 Editor State 编辑器状态
其实当执行更新期间,当前的 editor state 编辑器状态会被克隆一份,并作为更新的起点 starting point,这就是 Lexical 所采用的一种称为 double-buffering 双缓冲 的技术。
原来的 source state 是不变的,依然用于描述当前页面的 DOM 元素,而克隆生成的 editor state 则被用于修改操作,可以将它称为 pending state
之后就会将 pending state 应用到编辑器时,它会与 source state 进行比对 diff,触发页面的必要 DOM 元素变化
执行更新是一个异步过程,这样就允许将多个更新整合到一起,再触发一次页面的 DOM 元素重绘,以提高性能。
当更新成功时就会生成一个新的 editor state,那么这个 editor state 就不可变了
以下代码示例是通过 editor.update() 方法,以编程式的方式向编辑器中插入一个内容为 Hello world 的段落
import {$getRoot, $getSelection, $createParagraphNode} from 'lexical';
// 在 editor.update(callback) 的回调函数中可以使用一些以 `$` 为前缀的方法(由 lexical 提供的内置方法)
// 这些方法一般**不能**在回调函数之外的地方使用,否则会抛出错误
// 因为这些方法一般是对 editor state 进行修改
// 而只有在 editor.update(callback) 的回调函数中才可以对 editor state 进行修改(触发更新)
// 而不能在其他地方直接更改 editor state
editor.update(() => {
// 从当前的 editor state 中获取根节点 RootNode
const root = $getRoot();
// 从当前的 editor state 中获取选区 RangeSelection
const selection = $getSelection();
// 创建一个 ParagraphNode 段落节点
const paragraphNode = $createParagraphNode();
// 创建一个 TextNode 文本节点
// 并将文本内容设置为 Hello world
const textNode = $createTextNode('Hello world');
// 将 textNode 文本节点设置为 paragraphNode 段落节点的最后一个(直接)子节点
paragraphNode.append(textNode);
// 将 paragraphNode 段落节点设置为 root 根节点的最后一个(直接)子节点
root.append(paragraphNode);
});
另一个更改编辑器状态的方法是通过 editor.setEditorState(anotherState)
该方法接受一个参数 anotherState,它是一个 editor state 编辑器状态,用以覆盖编辑器当前的状态
一般是在编辑器初始化时 retrieve 恢复之前保存的内容
// 假设 editorStateJSONString 是从远端服务器获取的数据
// 它是之前编辑器状态的序列化 JSON string 形式
// 将 JSON string 反序列化,得到一个 editor state 编辑器状态
const editorState = editor.parseEditorState(editorStateJSONString);
// 用给定的编辑器状态 editorState 覆盖编辑器当前的内容
editor.setEditorState(editorState);
序列化
在编辑器运行时,Lexical 将内容只保存在内存中,这样便于及时响应用户的输入交互,但是这导致内容无法持久化。
考虑到持久化的需求,其实 Editor State 编辑器状态是支持 serializable 序列化(转换为 HTML 或 JSON 格式)和 deserialize 反序列化的,这样就便于将编辑器的状态/内容进行保存或传递。
HTML 格式
HTML 是网页的通用的格式,它也是 Lexical 和其他编辑器之间实现内容的复制/粘贴时优先采用的格式,具体可查看 @lexical/clipboard 模块
使用 $generateHtmlFromNodes() 方法将 Editor State 序列化为 HTML 格式
// 参考 https://lexical.dev/docs/api/modules/lexical_html#generatehtmlfromnodes
import { $generateHtmlFromNodes } from '@lexical/html';
const htmlString = $generateHtmlFromNodes(editor, selection | null);
// 第二个参数用于设置所需转换的内容区域
// 可以是 `selection` 选区
// 或是 `null` 表示转换所有内容
在序列化时 Lexical 会遍历 Editor State 的所有节点,并分别调用每一种节点的 LexicalNode.exportDOM() 方法,将该类型的节点转换为相应的 HTMLElement,最后将节点「拼凑」起来就得到编辑器状态的序列化结果。
exportDOM(editor: LexicalEditor): DOMExportOutput
该方法的返回值类型是 DOMExportOutput 对象
// 其中 DOMExportOutput 对象的 element 属性就是转换生成的 HTMLElement
// 但是 element 属性值也可以是 null,这样该节点在序列化为 HTML 时就会「丢失」不见了
export type DOMExportOutput = {
after?: (generatedElement: ?HTMLElement) => ?HTMLElement,
element?: HTMLElement | null,
};
说明
有时候需要对转换生成的 HTMLElement 进一步处理,Lexical 为此提供了一个 API,通过在 exportDOM() 方法的返回值(一个 DOMExportOutput 对象)中设置 after 方法,即可实现对 HTMLElement 的「后处理」
使用 $generateNodesFromDOM() 方法将 HTML 反序列化为 Editor State 编辑器状态
import { $getRoot,$insertNodes } from 'lexical'
import { $generateNodesFromDOM } from '@lexical/html';
// ⚠️ 更新编辑器内容的操作需要在方法 editor.update(callback) 的回调函数中进行
editor.update(() => {
// 基于给定的编辑器 editor(所采用的数据模型)将 DOM 实例转换为相应的一系列节点
// 返回的是一个节点列表
const nodes = $generateNodesFromDOM(editor, dom);
// 使用 $getRoot() 方法获取根节点
// 并将其选中(其实就是全选编辑器的所有内容),这会生成一个选区
$getRoot().select();
// 使用 $insertNodes(nodes) 方法(默认行为)将节点插入到选区的最后
// 💡 这不会覆盖编辑器原有的内容
$insertNodes(nodes);
})
类似地,在反序列化时 Lexical 会根据 DOM 实例中每一个 HTMLElement 类型,调用相应类型节点的 LexicalNode.importDOM() 方法,将该 HTMLElement 转换为相应类型的节点,最后返回一个包含所有节点的数组
所以可以通过设置节点的 importDOM() 方法,来控制如何将 HTMLElement 转换为相应的节点
static importDOM(): DOMConversionMap | null;
该方法返回的是一个DOMConversionMap 类型的值(表示转换成功)或 null(表示该 DOM 与当前的节点不匹配,转换失败,会被更低级的转换函数进行处理)。
DOMConversionMap 类型的数据结构是一个映射。
type NodeName = string;
export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
NodeName,
(node: T) => DOMConversion<T> | null
>;
它的键 NodeName 是一个 DOM 的名称(小写)
它的值是一个函数,该函数返回一个 DOMConversion 对象
该对象包含了属性 conversion(属性值就是转换函数 DOMConversionFn)和属性 priority(属性值是 0 | 1 | 2 | 3 | 4 中任意一个值,表示转换的优先级)。
export type DOMConversion = {
conversion: DOMConversionFn, // 转换函数
priority: 0 | 1 | 2 | 3 | 4, // 转换优先级别
};
// 转换函数
export type DOMConversionFn<T extends HTMLElement = HTMLElement> = (
element: T,
parent?: Node,
// 预格式化标志,类似于 <pre> 标签
// 表示内容是否已经经过了格式化
// 如果为 true 则保留节点内容中的换行符、空格符等
preformatted?: boolean,
) => DOMConversionOutput | null;
其中属性 priority 表示转换的优先级,可以让具有附加条件的转换函数设置更高的优先级
例如需要同时满足为锚标签 <a> 且具 target="_blank" 属性(只有两者同时匹配)的 DOM 才进行转换;而仅仅满足为锚标签 <a> 的 DOM 就可实现转换的函数就设置最低的优先级,作为一种 fallback 回退/兜底策略。
说明
由于原生的 HTMLElement 类型是有限的,而编辑器的节点可以是多样的,如果转换函数具有优先级就优先,那么就可以让不同的节点与同一种 HTMLElement 相互转换成为可能,这样就可以极大地释放前端编辑器的潜在能力,不再被 DOM 的有限的节点类型所约束
类似地,有时候需要对转换所得的节点进一步处理,Lexical 为此提供了一个 API,通过在 importDOM() 方法的返回值(一个 DOMConversionOutput 对象)中设置 after 方法和 forChild 方法,即可实现对节点的「后处理」
export type DOMConversionOutput = {
// after 方法在转换生成节点后执行一次
// 它接受所有子节点 `childLexicalNodes` 作为入参
after?: (childLexicalNodes: Array<LexicalNode>) => Array<LexicalNode>;
// forChildren 方法会在转换生成节点后,分别对各后代节点(包含内嵌的子孙节点)执行
forChild?: DOMChildConversion;
node: LexicalNode | null; // 转换生成的 Lexical 节点
preformatted?: boolean;
};
// 对后代节点执行的函数
// 入参是当前所遍历的后代节点,以及它的父节点
// 最后返回一个节点或 null
export type DOMChildConversion = (
lexicalNode: LexicalNode,
parentLexicalNode: LexicalNode | null | undefined,
) => LexicalNode | null;
提示
一般将 importDOM() 方法定义为某个节点(类)的静态方法,所以可以在 Class 类上直接调用,这样可以直接传入一个 HTMLElement 就获得一个相应的节点
说明
一般 HTML 是以字符串的形式保存在远端服务器上,而前面所使用的方法 $generateNodesFromDOM() 所接受的参数是 DOM 实例,所以需要将 HTML 字符串转换为 DOM 实例
在前端页面可以使用浏览器提供的原生解析器 DOMParser
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString, textHtmlMimeType);
// 解析器的方法 `parseFromString()` 将字符串解析为一个 DOM Document
// 它接受两个参数
// 第一个参数是需要解析的字符串
// 第二个参数是要返回的 MIME 媒体类型,对于 HTML 则是 `text/html`
// 具体参考 https://developer.mozilla.org/zh-CN/docs/Web/API/DOMParser
如果在后端等无浏览器实例 headless 模式下,则可以使用一些前端库来实现,如 JSDOM。
Lexical 也提供了一个相应的模块 @lexical/headless 以便在无浏览器实例的模式下创建一个 headless editor 「无头」编辑器
import { createHeadlessEditor } from '@lexical/headless';
import { $generateNodesFromDOM } from '@lexical/html';
// Once you've generated LexicalNodes from your HTML
// you can now initialize an editor instance with the parsed nodes.
const editorNodes = [] // Any custom nodes you register on the editor
const editor = createHeadlessEditor({ ...config, nodes: editorNodes });
editor.update(() => {
// In a headless environment you can use a package such as JSDom to parse the HTML string.
const dom = new JSDOM(htmlString);
// Once you have the DOM instance it's easy to generate LexicalNodes.
const nodes = $generateNodesFromDOM(editor, dom.window.document);
// Select the root
$getRoot().select();
// Insert them at a selection.
const selection = $getSelection();
selection.insertNodes(nodes);
});
JSON 格式
使用 editorState.toJSON() 方法或直接使用 JSON.stringify() 方法将 Editor State 序列化为 JSON 格式
/**
*
* 将编辑器的当前状态对象序列化
*
*/
// 方法一:调用 editorState(编辑器状态对象)的方法 toJSON()
// 获取编辑器的当前状态可序列化为 JSON 格式的形式,一个 `SerializedLexicalNode` 对象
const editorState = editor.getEditorState();
editorState.toJSON();
// 方法二:直接使用 JSON.stringify() 方法,将编辑器的当前状态对象序列化为字符串
const JSONstring = JSON.stringify(editor.getEditorState());
在序列化时 Lexical 会遍历 Editor State 的所有节点,并分别调用每一种节点的 LexicalNode.exportJSON() 方法,将该类型的节点转换为相应的 JSON 序列化形式,最后将节点「拼凑」起来就得到编辑器状态的序列化结果。
所有 Lexical 的内置节点都包含了 exportJSON() 方法,例如对于 HeadingNode 标题节点的 exportJSON() 方法的源码如下
export type SerializedHeadingNode = Spread<
{
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
},
SerializedElementNode
>;
exportJSON(): SerializedHeadingNode {
return {
...super.exportJSON(),
tag: this.getTag(),
type: 'heading',
version: 1,
};
}
而对于自定的节点 custom node 则需要自定义该方法,来控制这种类型的节点如何序列化为 JSON 格式
注意
所有序列化后的节点都需要确保具有 type 字段和 version 字段 属性,以标注该 JSON 对象所对应的节点类型/名称以及版本
而 ElementNode 类型的节点,还需要包含 children 字段等属性
// `ElementNode` 节点序列化的格式
// 在 TypeScript 中由 SerializedElementNode 对其进行约束
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L40-L50
export type SerializedElementNode<
T extends SerializedLexicalNode = SerializedLexicalNode,
> = Spread<
{
children: Array<T>;
direction: 'ltr' | 'rtl' | null;
format: ElementFormatType;
indent: number;
},
SerializedLexicalNode
>;
exportJSON(): SerializedElementNode
使用 editor.parseEditorState() 方法将 JSON 反序列化为 Editor State 编辑器状态
/**
*
* 从远端服务器获取数据
* 再应用到编辑器上
*
*/
// 创建一个 editor 实例
const editor = createEditor();
// 异步获取数据
const initialEditorState = await loadContent();
// 返回的是序列化的 editor state (JSON string 的形式)
// 将字符串解析为 editor state 编辑器状态对象
const editorState = editor.parseEditorState(editorStateJSONString);
// 并设置成为编辑器的当前状态
// ⚠️ 这会覆盖编辑器原有的内容
editor.setEditorState(editorState);
类似地,在反序列化时 Lexical 会根据 JSON 对象中的 type 字段来识别并对应到相应的节点类型,并分别调用每一种节点的 LexicalNode.importJSON() 方法,将该 JSON 数据转换为相应类型的节点,最后将节点「拼凑」起来就生成了编辑器状态。
所以可以通过设置节点的 importJSON() 方法,来控制如何将 JSON 数据转换为相应的节点
export type SerializedLexicalNode = {
type: string; // 根据该字段来匹配节点类型
version: number;
};
// 接受一个相应的 JSON string 数据
// 生成一个 LexicalNode 节点实例
importJSON(jsonNode: SerializedLexicalNode): LexicalNode
例如对于 HeadingNode 标题节点的 importJSON() 方法的源码如下
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
const node = $createHeadingNode(serializedNode.tag);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
观察上面 HeadingNode 标题节点的 importJSON() 方法,可以发现它是属于静态方法,所以可以在 Class 类上直接调用,这样就可以直接传入一个 JSON 数据「片段」获得一个该类型节点的实例
提示
节点究竟属于哪个 type 类型,可以通过 LexicalNode.getType() 来获取
版本控管与兼容性
在开发编辑器时,可能需要持续对某种类型的节点的功能进行更改,但应该避免对节点(序列化形式)所对应的 JSON 对象已有的字段进行删改,因为考虑到编辑器要支持导入之前导出的(JSON)数据,即需要向下兼容。
这里推荐采用版本控管的方式,在 JSON 对象中增加一个 version 字段,以便实现对编辑器的「旧版本」和「新版本」同时支持导入导出功能。
例如以下是对于一个 TextNode 文本节点的序列化形式(类型)定义
// Spread is a Typescript utility that allows us to spread the properties
import type {Spread} from 'lexical';
// over the base SerializedLexicalNode type.
// TextNode 文本节点的序列化形式
export type SerializedTextNode = Spread<
{
detail: number;
format: number;
mode: TextModeType;
style: string;
text: string;
},
SerializedLexicalNode
>;
如果希望对 TextNode 文本节点的序列化形式进行更改(最好不要对已有字段进行删除),则采用 version 字段进行标记,以下是更改后的(类型)定义
// 版本 1
export type SerializedTextNodeV1 = Spread<
{
detail: number;
format: number;
mode: TextModeType;
style: string;
text: string;
version: 1,
},
SerializedLexicalNode
>;
// 版本 2
export type SerializedTextNodeV2 = Spread<
{
detail: number;
format: number;
mode: TextModeType;
style: string;
text: string;
// 新增加的字段
newField: string,
// 将版本号修改为 2
version: 2,
},
SerializedLexicalNode
>;
// TextNode 文本节点的序列化形式兼容两种版本
export type SerializedTextNode = SerializedTextNodeV1 | SerializedTextNodeV2;