富文本编辑器 Lexical 解析

839 阅读6分钟

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 很重要的特性,它主要有以下两个作用:

  1. 暴露常用 API 供开发者调用。Lexical 内部已经注册好了一些常用命令,只需要发送相关命令就可以进行富文本操作。

  2. 方便定制化开发。比如你想要实现自己的格式化文本的命令,可以自己对该命令进行注册添加自己的事件处理函数。

关于命令的使用,我们来看一个简短的示例:

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 函数本身并没有给文本节点添加加粗效果,它主要做了两件事:

  1. 更新文本节点的 format 属性。

  2. 将需要改动的节点标记为脏节点收集起来。

  3. 更新节点集合 _nodeMap。

这里我们又接触到了 Lexical 的一个新概念:节点。节点是 Lexical 中的一个核心概念。它既组成了富文本编辑器的可视内容,也是 Lexical 内部存储富文本内容的一种数据模型。Lexical 有一个最基础的核心节点 LexicalNode,它被其他的几个基本节点继承:

  1. RootNode。通常一个应用只有一个 RootNode,它代表着富文本编辑器的根节点。

  2. LineBreakNode。在 Lexical 中我们用 LineBreakNode 代表换行,它主要用来屏蔽不同浏览器、不同操作系统在换行时的差异。

  3. ElementNode。用作其他节点的父节点,可以是块节点,也可是内联节点。

  4. TextNode。一种包含文本的叶子节点。它包含一些文本相关的属性如 format、style 等等。

  5. 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 的功能十分丰富,因篇幅所限本文没法做到面面俱到,感兴趣的可以去体验官网上提供的示例。