概述
lexical是一款facebook基于JavaScript开发的网页端文本编辑框架,具备高拓展性架构,以高可靠性、易用性以及性能表现为核心设计思想。本身不与任何框架绑定,可独立于React、Vue使用(不过由于facebook与React的亲和性,lexical有React版)。使用者可在其基础上建立属于自己的独一无二的文本编辑器
目前在Meta内部,每天lexical通过Facebook、Workplace、Messenger、WhatsApp、Instagram等产品服务千百万用户,稳定性及可靠性值得信赖
lexical的核心包(上图左侧部分)只有22kb,其余能力以plugin形式提供。框架支持延迟加载,plugin可以在用户真正操作编辑器的时候再加载,这样能获得比较好的性能表现。
能力
如果直接用浏览器的原生接口实现文本编辑器,那将是件比较复杂的工作。lexical提供了一条更快捷的途径,让开发者根据不同需求开发不同类型的文本编辑器,下面是几个简单的场景:
- 纯文本编辑器,但又比单纯的
<textarea>更复杂,比如有@能力,自定义表情包,链接以及话题标签 - 富文本编辑器,用于博客、社交、聊天应用的内容编辑
- 用于
CMS系统的的所见即所得编辑器
目前lexical仅提供web版,但开发团队后期会提供native版
核心概念
这是一张lexical架构图,涉及到许多核心概念及其之间的关系,例如state、transform、listener、plugin等,下面我们将对这些概念做简单介绍
Editor实例
Editor实例是连接一切的核心,是我们使用lexical的最核心对象。我们将可被编辑(contenteditable)的DOM元素与编辑器实例绑定,并且在实例上绑定事件监听和指令。更重要的是,需要通过实例来更新EditorState,Editor实例用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》。传递给
update和read的函数必须是同步函数,在这里能获取到完整Editor States的地方。获取方式是用过带$前缀的函数,如$getRoot、$createTextNode等,这些$函数只能在update和read函数内部使用,否则会报运行时错误
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结构如下:
在这个例子里,因为内容格式的需要,其html结构不得不按照一种嵌套的方式来组织。作为对比,lexical会将信息映射为Editor States:
通过调用调用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中的关键概念,其对应了底层数据模型。最底层的Node是LexicalNode,以此为基础又派生出了5个Node:
RootNodeLineBreakNodeElementNodeTextNodeDecoratorNode
其中lexical开发包暴露给开发者的是以下3个Node:
ElementNodeTextNodeDecoratorNode
下面我们对这几个Node做下介绍
节点类型
RootNode
在EditorState中仅有一个RootNode,是节点树最顶端的节点,其代表contenteditable元素自身。RootNode没有父元素以及兄弟元素。为了避免selection问题,lexical严禁直接向RootNode插入文本节点
LineBreakNode
在lexical中永远不用\n,取而代之的是LineBreakNode,这样可以抹平浏览器及操作系统之间的差异
ElementNode
通常作为其他节点的父元素出现,如ParagraphNode、HeadingNode、LinkNode
TextNode
作为整个节点树最末端的叶子节点,有几个文本特有的属性:
format:bold、italic、underline、strikethrough、code、subscript、superscriptmode:token:不可变节点,不能修改其内容和一次性全部删除segmented:可以被一次性全部删除
style:用于内联css
DecoratorNode
用于在编辑器中插入任意视图(组件)的包装器节点。
节点属性
支持给节点添加自定义属性,但必须是可以JSON序列化的,对于函数、Symbol、Map、Set这类数据不能作为属性。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提供了基于ElementNode、TextNode、DecoratorNode进行自定义节点的能力
lexical内部的RootNode和ParagraphNode就是基于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开发包提供了ParagraphNode、HeadingNode、QuoteNode、List等内置节点,但如果你想自定义一个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
一般情况下,转换只需要执行一次,但由于脏检查机制,可能会产生连带影响。我们有必要关注判断条件,以免转换陷入死循环:
// 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'
});
这里有三个示例可供参考:
指令(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版接口,不依赖任何框架,下面是一段示例代码(为了编写方便,我们用到了react。lexical提供了专门的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实现一个实际的富文本编辑器,详见《快速打造你自己的富文本编辑器》