Lexical 是一个可拓展的富文本编辑器框架。它最初是在 Meta 团队内部使用,于今年 4 月份正式开源。截至今天,Lexical 在 Github 上已经有 12.3k Star,它在开源富文本领域是一颗冉冉升起的新星。
值得一提的是,Lexical 宣称它是富文本编辑器框架,而不仅是富文本编辑器。你可以将 Lexical 视为一个文本编辑器引擎,而不是一个开箱即用的单一富文本编辑器。Lexical 核心功能是不依赖于任何框架的,无论你的项目是基于 React、Vue、亦或者是 Angular 等前端框架开发,都能够引入它来增加富文本编辑能力。虽说 Lexical 不依赖任何前端框架,但官网提供的示例却是由 React 编写。这也就意味着,如果你的项目不是使用 React 开发,那么引入 Lexical 的成本就略有增加。
Lexical 被视为另一款知名开源富文本编辑器 Draft.js 的替代品,同时它还借鉴了其他优秀富文本编辑的优点,可谓是站在了巨人的肩膀上。对于热爱富文本编辑器开发的同学来说,Lexical 有不少值得我们学习的地方。下面我就通过一个简单的示例,由浅入深逐步剖析它的工作原理。
// html template
/*
<div id="editor" contenteditable="true"></div>
<button id="bold">bold</button>
*/
import {createEditor, FORMAT_TEXT_COMMAND } from 'lexical';
import { registerRichText } from '@lexical/rich-text';
const config = {
namespace: 'MyEditor'
};
const editor = createEditor(config);
const contentEditableElement = document.getElementById('editor');
editor.setRootElement(contentEditableElement);
registerRichText(editor)
const boldElement = document.getElementById('bold');
boldElement.addEventListener('click', function() {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
});
通过这段代码我们实现了一个简单的富文本,当选中输入的文字点击加粗按钮后,文字会变成粗体效果。初始化一个富文本的步骤很简单:先创建一个编辑器实例,然后和一个 contenteditable DOM 元素关联起来。这里我们重点看下加粗效果是如何实现的,也就是这一行关键代码:
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
编辑器实例发出一个格式化文本的命令,并在参数中指明是要加粗文本。Lexical 内部已经注册好了一些常用的命令,以便接收其他地方发送的命令并进行相应处理。命令系统是 Lexical 很重要的特性,它主要有以下两个作用:
-
暴露常用 API 供开发者调用。Lexical 内部已经注册好了一些常用命令,只需要发送相关命令就可以进行富文本操作。
-
方便定制化开发。比如你想要实现自己的格式化文本的命令,可以自己对该命令进行注册添加自己的事件处理函数。
关于命令的使用,我们来看一个简短的示例:
const HELLO_WORLD_COMMAND = createCommand();
editor.registerCommand(
HELLO_WORLD_COMMAND,
(payload: string) => {
console.log(payload); // Hello World!
return false;
},
LowPriority,
);
editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');
大概就是先创建一个命令,然后注册这个命令,也就是给命令添加相应的处理函数。后续在想调用的时候发送这个命令即可。那么注册命令时添加的监听函数是如何被触发的呢?dispatchCommand 函数最终会触发 triggerCommandListeners 函数的执行,下面给出该函数的关键代码:
for (let i = 4; i >= 0; i--) {
for (let e = 0; e < editors.length; e++) {
const currentEditor = editors[e];
const commandListeners = currentEditor._commands;
const listenerInPriorityOrder = commandListeners.get(type);
if (listenerInPriorityOrder !== undefined) {
const listenersSet = listenerInPriorityOrder[i];
if (listenersSet !== undefined) {
const listeners = Array.from(listenersSet);
const listenersLength = listeners.length;
for (let j = 0; j < listenersLength; j++) {
if (listeners[j](payload, editor) === true) {
return true;
}
}
}
}
}
}
上述代码有有两处需要稍作解释,第一个是最外层 for 循环为什么需要执行 5 次?第二个是为什么监听函数返回为 true 时,就结束了 for循环的执行?
最外层的 for 循环会执行 5 次,这是因为每个命令有 5 个可选的优先级,分别为:
export const COMMAND_PRIORITY_EDITOR = 0;
export const COMMAND_PRIORITY_LOW = 1;
export const COMMAND_PRIORITY_NORMAL = 2;
export const COMMAND_PRIORITY_HIGH = 3;
export const COMMAND_PRIORITY_CRITICAL = 4;
注册命令的时候需要提供优先级参数,比如前面关于命令的示例代码中的 LowPriority 参数。我们看看 registerCommand 函数做了什么就很容易理解了:
registerCommand<P>(
command: LexicalCommand<P>,
listener: CommandListener<P>,
priority: CommandListenerPriority,
): () => void {
const commandsMap = this._commands;
if (!commandsMap.has(command)) {
commandsMap.set(command, [
new Set(),
new Set(),
new Set(),
new Set(),
new Set(),
]);
}
const listenersInPriorityOrder = commandsMap.get(command);
const listeners = listenersInPriorityOrder[priority];
listeners.add(listener as CommandListener<unknown>);
}
所有注册的命令都存储在编辑器实例上的 _commands 属性上。它是一个 Map 结构,以每个命令的名字作为 key,值是一个长度为 5 的数组,其中每个元素都是一个 Set 结构。根据注册命令时提供的优先级,给对应的元素项添加监听函数。
为什么监听函数执行结果为 true,就结束了 for 循环的执行呢?这是因为给命令注册回调函数的时候是有优先级的,监听函数按优先级高低依次执行。回调函数返回为 true 的时候就表示该命令已经被执行过了,不需要后续的监听函数再执行了。
弄清楚了命令的监听函数是如何被触发的,我们继续看看格式化文本这个命令,在它内部是如何注册的。也就是探究文本加粗效果具体是如何实现的。
editor.registerCommand<TextFormatType>(
FORMAT_TEXT_COMMAND,
(format) => {
const selection = $getSelection();
selection.formatText(format);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
监听函数里面的 formatText 函数本身并没有给文本节点添加加粗效果,它主要做了两件事:
-
更新文本节点的 format 属性。
-
将需要改动的节点标记为脏节点收集起来。
-
更新节点集合 _nodeMap。
这里我们又接触到了 Lexical 的一个新概念:节点。节点是 Lexical 中的一个核心概念。它既组成了富文本编辑器的可视内容,也是 Lexical 内部存储富文本内容的一种数据模型。Lexical 有一个最基础的核心节点 LexicalNode,它被其他的几个基本节点继承:
-
RootNode。通常一个应用只有一个 RootNode,它代表着富文本编辑器的根节点。
-
LineBreakNode。在 Lexical 中我们用 LineBreakNode 代表换行,它主要用来屏蔽不同浏览器、不同操作系统在换行时的差异。
-
ElementNode。用作其他节点的父节点,可以是块节点,也可是内联节点。
-
TextNode。一种包含文本的叶子节点。它包含一些文本相关的属性如 format、style 等等。
-
DecoratorNode。一种包裹节点,能够在编辑器中插入任意视图或组件。
因为本文主要围绕文本加粗示例来展开,我们重点看一下 TextNode 节点的结构:
node = {
"__type": "text",
"__parent": "2",
"__key": "1",
"__text": "ab",
"__format": 1,
"__style": "",
"__mode": 0,
"__detail": 0
}
调用 formatText 函数以后,节点的 _format 属性值由 0 变为了 1,表示该文本节点带有加粗效果。脏节点的收集则是在另一个函数进行:
function internalMarkNodeAsDirty(node: LexicalNode): void {
const latest = node.getLatest();
const parent = latest.__parent;
const dirtyElements = editor._dirtyElements;
if (parent !== null) {
internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements);
}
const key = latest.__key;
editor._dirtyType = HAS_DIRTY_NODES;
if ($isElementNode(node)) {
dirtyElements.set(key, true);
} else {
editor._dirtyLeaves.add(key);
}
}
将一个节点标记为脏节点的流程比较简单:首先检查其父节点是否存在,如果存在将其父节点也标记为脏节点。然后根据当前节点是否为元素节点,分别存储在编辑器实例上的不同属性上。最后我们还说到了 _nodeMap,它是 Editor State的一个属性。
Editor State 是能够描述显示在富文本编辑器中 DOM 信息的底层数据模型。它主要由两部分组成:节点树和选区对象。节点树就是用 _nodeMap 来表示的,我们来看看对选中文字进行加粗操作以后,_nodeMap有什么变化,操作前:
在执行操作前,Lexical 用三个节点表示富文本编辑器显示的内容,分别是根节点、段落节点和文本节点。每个节点都有一些属性,比如 _children 属性表示子节点索引,_text 属性表示节点的文本内容。执行加粗效果后,_nodeMap 结构如下:
执行加粗效果后,一个文本节点被分割成了两个,没有选中的部分保持之前的格式,选中的部分添加对应的格式信息。
我们再来总结下,formatText 函数只是更新了Lexical 的相关数据模型,并未触发 DOM 更新。依据更新后的 _nodeMap 触发渲染新的 DOM,这一步主要是在函数 reconcileNode 里面进行的。相关的函数调用如下:
function reconcileRoot() {
reconcileNode('root', null);
}
function reconcileNode() {
const prevNode = activePrevNodeMap.get(key);
let nextNode = activeNextNodeMap.get(key);
const isDirty =
treatAllNodesAsDirty ||
activeDirtyLeaves.has(key) ||
activeDirtyElements.has(key);
if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
const replacementDOM = createNode(key, null, null);
parentDOM.replaceChild(replacementDOM, dom);
destroyNode(key, null);
return replacementDOM;
}
if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
const prevChildren = prevNode.__children;
const nextChildren = nextNode.__children;
const childrenAreDifferent = prevChildren !== nextChildren;
if (childrenAreDifferent || isDirty) {
reconcileChildrenWithDirection(prevChildren, nextChildren, nextNode, dom);
}
}
reconcile 函数从根节点开始,逐步递归检查子节点,然后把节点的变更转换成 DOM 变更。在这个过程中,Lexical 做了一些优化。比如它存储了操作前和操作后的两个 nodeMap,然后在 reconcile 过程中会比对变化前和变化后的节点,以及检查该节点是否是脏节点来决定是否重新渲染。脏节点的收集在前文已有介绍,这里我们看下是节点如何渲染的,也就是 DOM 结构是如何更新的:
nextNode.updateDOM(prevNode, dom, activeEditorConfig)
function updateDOM(
prevNode: TextNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
const nextText = this.__text;
let innerDOM = dom;
setTextContent(nextText, innerDOM, this);
}
function setTextContent(
nextText: string,
dom: HTMLElement,
node: TextNode,
): void {
const firstChild = dom.firstChild;
firstChild.nodeValue = text;
}
文本节点渲染的过程很简单,就是将新的节点值设置到对应的 DOM 节点即可。
本文通过一个简单的富文本操作示例,初步介绍了 Lexical 的整个工作流程,旨在帮助大家快速理解 Lexical 的主要工作机制。Lexical 的功能十分丰富,因篇幅所限本文没法做到面面俱到,感兴趣的可以去体验官网上提供的示例。