新一代富文本编辑器框架lexical入门

8,988 阅读7分钟

概述

lexical是一款facebook基于JavaScript开发的网页端文本编辑框架,具备高拓展性架构,以高可靠性、易用性以及性能表现为核心设计思想。本身不与任何框架绑定,可独立于ReactVue使用(不过由于facebookReact的亲和性,lexicalReact版)。使用者可在其基础上建立属于自己的独一无二的文本编辑器

目前在Meta内部,每天lexical通过FacebookWorkplaceMessengerWhatsAppInstagram等产品服务千百万用户,稳定性及可靠性值得信赖

image.png

lexical的核心包(上图左侧部分)只有22kb,其余能力以plugin形式提供。框架支持延迟加载,plugin可以在用户真正操作编辑器的时候再加载,这样能获得比较好的性能表现。

能力

如果直接用浏览器的原生接口实现文本编辑器,那将是件比较复杂的工作。lexical提供了一条更快捷的途径,让开发者根据不同需求开发不同类型的文本编辑器,下面是几个简单的场景:

  • 纯文本编辑器,但又比单纯的<textarea>更复杂,比如有@能力,自定义表情包,链接以及话题标签
  • 富文本编辑器,用于博客、社交、聊天应用的内容编辑
  • 用于CMS系统的的所见即所得编辑器

目前lexical仅提供web版,但开发团队后期会提供native

核心概念

image.png

这是一张lexical架构图,涉及到许多核心概念及其之间的关系,例如statetransformlistenerplugin等,下面我们将对这些概念做简单介绍

Editor实例

Editor实例是连接一切的核心,是我们使用lexical的最核心对象。我们将可被编辑(contenteditable)的DOM元素与编辑器实例绑定,并且在实例上绑定事件监听和指令。更重要的是,需要通过实例来更新EditorStateEditor实例用createEditor()函数来创建

主题

lexical支持通过自定义主题的方式来实现样式定制,可以给每种DOM设置自己的className,然后通过css文件来定义样式。需要注意的是,lexical没有不提供默认的样式,如果没有设置对应的className,那么其dom元素不会有任何的class,也就不会有任何的样式。有时候一些功能需要js代码与css样式配合使用,例如斜体、删除线等

配置主题:

import {createEditor} from 'lexical';  
  
const editor = createEditor({  
    namespace: 'MyEditor',  
    theme: {  
        ltr: 'ltr',  
        rtl: 'rtl',  
        paragraph: 'editor-paragraph' 
    }
});

css中这样设置:

.ltr {  
    text-align: left;  
}  
  
.rtl {  
    text-align: right;  
}  
  
.editor-placeholder {  
    color: #999;  
    overflow: hidden;  
    position: absolute;  
    top: 15px;  
    left: 15px;  
    user-select: none;  
    pointer-events: none;  
}  
  
.editor-paragraph {  
    margin: 0 0 15px 0;  
    position: relative;  
}

许多核心节点都可以配置,这是一个更复杂的主题:

const exampleTheme = {
    ltr: 'ltr',
    rtl: 'rtl',
    paragraph: 'editor-paragraph',
    quote: 'editor-quote',
    heading: {
        h1: 'editor-heading-h1',
        h2: 'editor-heading-h2',
        h3: 'editor-heading-h3',
        h4: 'editor-heading-h4',
        h5: 'editor-heading-h5',
        h6: 'editor-heading-h6'
    },
    list: {
        nested: {
            listitem: 'editor-nested-listitem'
        },
        ol: 'editor-list-ol',
        ul: 'editor-list-ul',
        listitem: 'editor-listItem',
        listitemChecked: 'editor-listItemChecked',
        listitemUnchecked: 'editor-listItemUnchecked'
    },
    hashtag: 'editor-hashtag',
    image: 'editor-image',
    link: 'editor-link',
    text: {
        bold: 'editor-textBold',
        code: 'editor-textCode',
        italic: 'editor-textItalic',
        strikethrough: 'editor-textStrikethrough',
        subscript: 'editor-textSubscript',
        superscript: 'editor-textSuperscript',
        underline: 'editor-textUnderline',
        underlineStrikethrough: 'editor-textUnderlineStrikethrough'
    },
    code: 'editor-code',
    codeHighlight: {
        atrule: 'editor-tokenAttr',
        attr: 'editor-tokenAttr',
        boolean: 'editor-tokenProperty',
        builtin: 'editor-tokenSelector',
        cdata: 'editor-tokenComment',
        char: 'editor-tokenSelector',
        class: 'editor-tokenFunction',
        'class-name': 'editor-tokenFunction',
        comment: 'editor-tokenComment',
        constant: 'editor-tokenProperty',
        deleted: 'editor-tokenProperty',
        doctype: 'editor-tokenComment',
        entity: 'editor-tokenOperator',
        function: 'editor-tokenFunction',
        important: 'editor-tokenVariable',
        inserted: 'editor-tokenSelector',
        keyword: 'editor-tokenAttr',
        namespace: 'editor-tokenVariable',
        number: 'editor-tokenProperty',
        operator: 'editor-tokenOperator',
        prolog: 'editor-tokenComment',
        property: 'editor-tokenProperty',
        punctuation: 'editor-tokenPunctuation',
        regex: 'editor-tokenVariable',
        selector: 'editor-tokenSelector',
        string: 'editor-tokenSelector',
        symbol: 'editor-tokenProperty',
        tag: 'editor-tokenProperty',
        url: 'editor-tokenOperator',
        variable: 'editor-tokenVariable'
    }
};

Editor States

页面DOM内容的底层数据模型用Editor States表示,Editor States由两部分组成:

  • 节点树
  • Selection对象

Editor States一旦被创建就不能直接修改,只能通过editor.update(() => {...})函数来更新,我们可以通过editor.getEditorState().read(() => {...})函数获取当前的Editor States

想要深度了解update原理,可以阅读这篇文章《Lexical state updates—— A deep dive into how Lexical updates its state》。

传递给updateread的函数必须是同步函数,在这里能获取到完整Editor States的地方。获取方式是用过带$前缀的函数,如$getRoot$createTextNode等,这些$函数只能在updateread函数内部使用,否则会报运行时错误

update函数里的操作默认情况下是异步的,这就导致执行完不能直接获取到最新的Editor States,这在有些场景下是个问题,例如:

editor.update(() => {  
    // 操作 state...  
});  
  
saveToDatabase(editor.getEditorState().toJSON());

原本的目的是操作完Editor States后将其存储到数据库里,但第5行的获取Editor States会先于数据更新执行,导致getEditorState获取到的是旧数据,解决这个问题需要设置discrete

editor.update(() => {  
    // manipulate the state...  
+ }, {discrete: true});  
  
saveToDatabase(editor.getEditorState().toJSON());

Editor States本身是JSON格式,可以通过editor.parseEditorState()来解析并通过editor.setEditorState()返回给编辑器

const editorState = editor.parseEditorState(editorStateJSONString);  
editor.setEditorState(editorState);

Editor States可以被克隆(支持自定义selection),常见的场景是设定编辑器的内容,同时不设置任何的selection,例如:

// Passing `null` as a selection value to prevent focusing the editor  
editor.setEditorState(editorState.clone(null));

如果想知道Editor States何时发生变化,可以利用事件监听来实现:

editor.registerUpdateListener(({ editorState }) => {
    // The latest EditorState can be found as `editorState`.
    // To read the contents of the EditorState, use the following API:

    editorState.read(() => {
        // Just like editor.update(), .read() expects a closure where you can use
        // the $ prefixed helper functions.
    });
});

之所以采用Editor States,其中一个原因是html在处理富文本时过于灵活(其实这也是我们采用lexical而不是直接用浏览器原生ContentEditable编辑模式的原因,关于ContentEditable的痛点,可见《Why ContentEditable is Terrible》),比如下面几行的渲染效果是一样的(在ContentEditable模式下浏览器经常会插入无用的垃圾标签):

<i><b>Lexical</b></i>  
<i><b>Lex<b><b>ical</b></i>  
<b><i>Lexical</i></b>

ContentEditable编辑模式下,即便是换行这种操作也会有不同的结果:

<p>Lex<br/>ical</p> <!--插入 br 标签--> 
<p>Lex</p></p>ical</p> <!--分割 p 标签-->

尽管我们有办法将其转换成一种“标准”格式,但这涉及到DOM操作以及额外的渲染,为了克服这种问题,我们采用了“虚拟树”(Editor States)的概念,将内容结构与内容格式进行了解耦,比如这个例子:

<p>
    Why did the JavaScript developer go to the bar?
    <b>Because he couldn't handle his <i>Promise</i>s</b>
</p>

html结构如下:

image.png

在这个例子里,因为内容格式的需要,其html结构不得不按照一种嵌套的方式来组织。作为对比,lexical会将信息映射为Editor States

image.png

通过调用调用editor.getEditorState()函数可以获取当前最新的Editor States,在update函数里我们可以认为Editor States是可以被修改的,而在update之后,Editor States是不可变的,可以视为一份“快照”。

DOM渲染

lexical会将不同版本的Editor States作对比,在渲染内容时只对不同的地方做处理,这类似虚拟树,可以提供能好的性能

事件监听、节点转换、指令

除了触发updates,我们主要会用到事件监听、节点转换、指令和lexical打交道。这些都要通过editor实例,并且其接口统一带有register前缀。这些register函数会返回一个解绑函数,例如下面这段代码展示了如何监听update事件并解绑:

const unregisterListener = editor.registerUpdateListener(({editorState}) => {  
    // 发生了update
    console.log(editorState);  
});  
  
// 移除事件监听 
unregisterListener();

lexical中,指令是可以连接一切的通信系统,我们通过createCommand()函数创建自定义指令标签,用editor.registerCommand(handler, priority)函数注册指令,用editor.dispatchCommand(command, payload)函数触发指令。lexical会在内部对按键或其它信号做处理,其传递类似浏览器中的事件传递。

Node

Node作为EditorState的一部分,是整个lexical中的关键概念,其对应了底层数据模型。最底层的NodeLexicalNode,以此为基础又派生出了5个Node

  • RootNode
  • LineBreakNode
  • ElementNode
  • TextNode
  • DecoratorNode

其中lexical开发包暴露给开发者的是以下3个Node

  • ElementNode
  • TextNode
  • DecoratorNode

下面我们对这几个Node做下介绍

节点类型

RootNode

EditorState中仅有一个RootNode,是节点树最顶端的节点,其代表contenteditable元素自身。RootNode没有父元素以及兄弟元素。为了避免selection问题,lexical严禁直接向RootNode插入文本节点

LineBreakNode

lexical中永远不用\n,取而代之的是LineBreakNode,这样可以抹平浏览器及操作系统之间的差异

ElementNode

通常作为其他节点的父元素出现,如ParagraphNodeHeadingNodeLinkNode

TextNode

作为整个节点树最末端的叶子节点,有几个文本特有的属性:

  • formatbolditalicunderlinestrikethroughcodesubscriptsuperscript
  • mode
    • token:不可变节点,不能修改其内容和一次性全部删除
    • segmented:可以被一次性全部删除
  • style:用于内联css
DecoratorNode

用于在编辑器中插入任意视图(组件)的包装器节点。

节点属性

支持给节点添加自定义属性,但必须是可以JSON序列化的,对于函数、SymbolMapSet这类数据不能作为属性。lexical里习惯将属性名称以双下划线__作为前缀,表示其私有及不可直接访问性质。

如果你希望添加一个可以被更改和访问的属性,需要创建get*()set*()方法,在这两个方法内还需要规范使用内部函数getWritable()getLatest()以确保lexical内部系统数据的一致性。除此之外,每一个节点都需要有static getType()方法以及static clone()方法,前者在重建节点(复制粘贴)时会用到,后者在创建EditorState快照时会用到,这是一个示例:

class MyCustomNode extends SomeOtherNode {
    __foo: string;
    static getType(): string {
        return 'custom-node';
    }
    static clone(node: MyCustomNode): MyCustomNode {
        return new MyCustomNode(node.__foo, node.__key);
    }
    constructor(foo: string, key?: NodeKey) {
        super(key);
        this.__foo = foo;
    }
    setFoo(foo: string) {
        // getWritable() creates a clone of the node
        // if needed, to ensure we don't try and mutate
        // a stale version of this node.
        const self = this.getWritable();
        self.__foo = foo;
    }
    getFoo(): string {
        // getLatest() ensures we are getting the most
        // up-to-date value from the EditorState.
        const self = this.getLatest();
        return self.__foo;
    }
}

自定义节点

lexical提供了基于ElementNodeTextNodeDecoratorNode进行自定义节点的能力

lexical内部的RootNodeParagraphNode就是基于ElementNode创建的

ElementNode

下面是一个拓展ElementNode的示例:

import { ElementNode, LexicalNode } from 'lexical';

export class CustomParagraph extends ElementNode {
    static getType(): string {
        return 'custom-paragraph';
    }
    static clone(node: CustomParagraph): CustomParagraph {
        return new CustomParagraph(node.__key);
    }
    createDOM(): HTMLElement {
        // Define the DOM element here
        const dom = document.createElement('p');
        return dom;
    }
    updateDOM(prevNode: CustomParagraph, dom: HTMLElement): boolean {
        // Returning false tells Lexical that this node does not need its
        // DOM element replacing with a new copy from createDOM.
        return false;
    }
}

通常创建自定义节点的开发人员还需要提供一些以$开头的工具函数,以便使用者可以方便的创建、校验这些自定义节点,例如:

export function $createCustomParagraphNode(): CustomParagraph {  
    return new CustomParagraph();  
}  
  
export function $isCustomParagraphNode(node: LexicalNode | null | undefined): node is CustomParagraph {  
    return node instanceof CustomParagraph;  
}
TextNode
export class ColoredNode extends TextNode {
    __color: string;
    constructor(text: string, color: string, key?: NodeKey): void {
        super(text, key);
        this.__color = color;
    }
    static getType(): string {
        return 'colored';
    }
    static clone(node: ColoredNode): ColoredNode {
        return new ColoredNode(node.__text, node.__color, node.__key);
    }
    createDOM(config: EditorConfig): HTMLElement {
        const element = super.createDOM(config);
        element.style.color = this.__color;
        return element;
    }
    updateDOM(
        prevNode: ColoredNode,
        dom: HTMLElement,
        config: EditorConfig,
    ): boolean {
        const isUpdated = super.updateDOM(prevNode, dom, config);
        if (prevNode.__color !== this.__color) {
            dom.style.color = this.__color;
        }
        return isUpdated;
    }
}
export function $createColoredNode(text: string, color: string): ColoredNode {
    return new ColoredNode(text, color);
}
export function $isColoredNode(node: LexicalNode | null | undefined): node is ColoredNode {
    return node instanceof ColoredNode;
}
DecoratorNode
export class VideoNode extends DecoratorNode<ReactNode> {
    __id: string;
    static getType(): string {
        return 'video';
    }
    static clone(node: VideoNode): VideoNode {
        return new VideoNode(node.__id, node.__key);
    }
    constructor(id: string, key?: NodeKey) {
        super(key);
        this.__id = id;
    }
    createDOM(): HTMLElement {
        return document.createElement('div');
    }
    updateDOM(): false {
        return false;
    }
    decorate(): ReactNode {
        return <VideoPlayer videoID={this.__id} />;
    }
}
export function $createVideoNode(id: string): VideoNode {
    return new VideoNode(id);
}
export function $isVideoNode(
    node: LexicalNode | null | undefined,
): node is VideoNode {
    return node instanceof VideoNode;
}

节点覆盖(Node Overrides)

lexical开发包提供了ParagraphNodeHeadingNodeQuoteNodeList等内置节点,但如果你想自定义一个ParagraphNode并替换掉内置的节点,那该如何实现呢?首先我们以ParagraphNode为基础创建出一个自定义节点class。但如何告知lexical采用自己的自定义节点呢?这时节点覆盖(Node Overrides)就能发挥作用了,该功能支持你将节点做替换:

const editorConfig = {
    ...
    nodes = [
        // Don't forget to register your custom node separately!
        CustomParagraphNode,
        {
            replace: ParagraphNode,
            with: (node: ParagraphNode) => {
                return new CustomParagraphNode();
            }
        }
    ]
}

这里有一个完整的开发示例

节点转换(Node Transforms)

节点转换是最有效率的修改EditorState的机制。以场景为例,如果用户输入的单词是congrats,那么就将这个单词的颜色设为蓝色,此时我们就可以通过节点转换来实现

节点转换的语法是:

editor.registerNodeTransform<T: LexicalNode>(Class<T>, T): () => void

之所以比较高效,是因为多个转换只会导致一次DOM reconcile

image.png

一般情况下,转换只需要执行一次,但由于脏检查机制,可能会产生连带影响。我们有必要关注判断条件,以免转换陷入死循环:

// When a TextNode changes (marked as dirty) make it bold
editor.registerNodeTransform(TextNode, textNode => {
    // Important: Check current format state
    if (!textNode.hasFormat('bold')) {
        textNode.toggleFormat('bold');
    }
}

通常情况下次序并不重要,下面这个代码会在两次转换后结束:

// Plugin 1
editor.registerNodeTransform(TextNode, textNode => {
    // This transform runs twice but does nothing the first time because it doesn't meet the preconditions
    if (textNode.getTextContent() === 'modified') {
        textNode.setTextContent('re-modified');
    }
})
// Plugin 2
editor.registerNodeTransform(TextNode, textNode => {
    // This transform runs only once
    if (textNode.getTextContent() === 'original') {
        textNode.setTextContent('modified');
    }
})
// App
editor.addListener('update', ({ editorState }) => {
    const text = editorState.read($textContent);
    // text === 're-modified'
});

这里有三个示例可供参考:

  1. Emojis
  2. AutoLink
  3. HashtagPlugin

指令(Commands)

lexical中指令是一个很常用的功能,它提供了一套事件机制,在工具栏或复杂Plugin(如TablePlugin)中经常会用到

LexicalCommands.ts可以查询到所有现存的指令,如果你想自定义一个指令,那么需要用到createCommand函数:

const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand();

editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');

editor.registerCommand(
    HELLO_WORLD_COMMAND,
    (payload: string) => {
        console.log(payload); // Hello World!
        return false;
    },
    LowPriority,
);

指令可以在任何地方被dispatch几乎全部的内部核心指令都是在LexicalEvents.ts

如果不再需要指令监听,那么一定记得及时清理:

const removeListener = editor.registerCommand(
    COMMAND,
    (payload) => boolean, // Return true to stop propagation.
    priority,
);
// ...
removeListener(); // Cleans up the listener.

插件(Plugin)

不同于大多数框架,lexical不给插件定义任何特定的协议,所谓的插件其实就是一个接收Editor实例的函数,这个函数返回一个清理函数。插件内的全部工作都是通过Editor实例调用指令(Commands)、转换(Transforms)、节点等接口实现的

示例

lexical提供Vanilla JS版接口,不依赖任何框架,下面是一段示例代码(为了编写方便,我们用到了reactlexical提供了专门的react组件,使用更简单,但这里我们用的是Vanilla JS版接口):

.editor-wrapper {
    border: 2px solid gray;
}
#lexical-state {
    width: 100%;
    height: 300px;
}
.custom_quote_class_name {
    margin: 0;
    margin-left: 20px;
    margin-bottom: 10px;
    font-size: 15px;
    color: rgb(101, 103, 107);
    border-left-color: rgb(206, 208, 212);
    border-left-width: 4px;
    border-left-style: solid;
    padding-left: 16px;
}
import React, { useEffect, useRef } from 'react';
import reactDom from 'react-dom/client';
import { registerDragonSupport } from '@lexical/dragon';
import { createEmptyHistoryState, registerHistory } from '@lexical/history';
import {
    HeadingNode, QuoteNode, registerRichText, $createHeadingNode, $createQuoteNode
} from '@lexical/rich-text';
import { mergeRegister } from '@lexical/utils';
import {
    createEditor, $createParagraphNode, $createTextNode, $getRoot
} from 'lexical';
import './styles.css';

function App() {
    const editorRef = useRef();
    const stateRef = useRef();
    useEffect(() => {
        const initialConfig = {
            namespace: 'Vanilla JS Demo',
            // 注册节点 @lexical/rich-text
            nodes: [HeadingNode, QuoteNode],
            onError: error => {
                throw error;
            },
            theme: {
                // 给引用节点增加样式classname,样式在css文件中定义
                quote: 'custom_quote_class_name'
            }
        };
        const editor = createEditor(initialConfig);
        editor.setRootElement(editorRef.current);

        // 注册plugin
        mergeRegister(
            registerRichText(editor),
            registerDragonSupport(editor),
            registerHistory(editor, createEmptyHistoryState(), 300),
        );

        editor.update(() => {
            const root = $getRoot();
            if (root.getFirstChild() !== null) {
                return;
            }

            const heading = $createHeadingNode('h1');
            heading.append($createTextNode('这是一段标题'));
            root.append(heading);
            const quote = $createQuoteNode();
            quote.append(
                $createTextNode('这是一段引用'),
            );
            root.append(quote);
            const paragraph = $createParagraphNode();
            paragraph.append(
                $createTextNode('一个段落'),
                $createTextNode('lexical').toggleFormat('code'),
                $createTextNode('.'),
                $createTextNode(' 这里是 '),
                $createTextNode('加粗文案').toggleFormat('bold'),
                $createTextNode(' 这里是 '),
                $createTextNode('斜体').toggleFormat('italic'),
                $createTextNode(' 格式.'),
            );
            root.append(paragraph);
        }, { tag: 'history-merge' });

        editor.registerUpdateListener(({ editorState }) => {
            // 显示editorState内容
            stateRef.current.value = JSON.stringify(editorState.toJSON(), undefined, 2);
        });
    }, []);
    return (
        <div>
            <div id='app'>
                <div>
                    <h1>Lexical Basic - Vanilla JS</h1>
                    <div className='editor-wrapper'>
                        <div id='lexical-editor' contentEditable ref={editorRef} />
                    </div>
                    <h4>Editor state:</h4>
                    <textarea id='lexical-state' ref={stateRef} />
                </div>
            </div>
        </div>
    );
}

const root = reactDom.createRoot(document.getElementById('main'));
root.render(<App />);

接下来将会详细介绍通过lexical实现一个实际的富文本编辑器,详见《快速打造你自己的富文本编辑器