新一代富文本编辑器框架lexical入门
点击关注公众号,“技术干货”及时达!概述lexical是一款facebook基于JavaScript开发的网页端文本编辑框架,具备高拓展性架构,以高可靠性、易用性以及性能表现为核心设计思想。本身不与任何框架绑定,可独立于React、Vue使用(不过由于facebook与React的亲和性,lexical有React版)。使用者可在其基础上建立属于自己的独一无二的文本编辑器
目前在Meta内部,每天lexical通过Facebook、Workplace、Messenger、WhatsApp、Instagram等产品服务千百万用户,稳定性及可靠性值得信赖
image.pnglexical的核心包(上图左侧部分)只有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';
consteditor=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:0015px0;
position:relative;
}
许多核心节点都可以配置,这是一个更复杂的主题:
constexampleTheme={
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(()={
//manipulatethestate...
+},{discrete:true});
saveToDatabase(editor.getEditorState().toJSON());
Editor States本身是JSON格式,可以通过editor.parseEditorState()来解析并通过editor.setEditorState()返回给编辑器
consteditorState=editor.parseEditorState(editorStateJSONString);
editor.setEditorState(editorState);
Editor States可以被克隆(支持自定义selection),常见的场景是设定编辑器的内容,同时不设置任何的selection,例如:
//Passing`null`asaselectionvaluetopreventfocusingtheeditor
editor.setEditorState(editorState.clone(null));
如果想知道Editor States何时发生变化,可以利用事件监听来实现:
editor.registerUpdateListener(({editorState})={
//ThelatestEditorStatecanbefoundas`editorState`.
//ToreadthecontentsoftheEditorState,usethefollowingAPI:
editorState.read(()={
//Justlikeeditor.update(),.read()expectsaclosurewhereyoucanuse
//the$prefixedhelperfunctions.
});
之所以采用Editor States,其中一个原因是html在处理富文本时过于灵活(其实这也是我们采用lexical而不是直接用浏览器原生ContentEditable编辑模式的原因,关于ContentEditable的痛点,可见《Why ContentEditable is Terrible》),比如下面几行的渲染效果是一样的(在ContentEditable模式下浏览器经常会插入无用的垃圾标签):
ibLexical/b/i
ibLexbbical/b/i
biLexical/i/b
在ContentEditable编辑模式下,即便是换行这种操作也会有不同的结果:
pLexbr/ical/p!--插入br标签--
pLex/p/pical/p!--分割p标签--
尽管我们有办法将其转换成一种“标准”格式,但这涉及到DOM操作以及额外的渲染,为了克服这种问题,我们采用了“虚拟树”(Editor States)的概念,将内容结构与内容格式进行了解耦,比如这个例子:
p
WhydidtheJavaScriptdevelopergotothebar?
bBecausehecouldn'thandlehisiPromise/is/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事件并解绑:
constunregisterListener=editor.registerUpdateListener(({editorState})={
//发生了update
console.log(editorState);
});
//移除事件监听
unregisterListener();
在lexical中,指令是可以连接一切的通信系统,我们通过createCommand()函数创建自定义指令标签,用editor.registerCommand(handler, priority)函数注册指令,用editor.dispatchCommand(command, payload)函数触发指令。lexical会在内部对按键或其它信号做处理,其传递类似浏览器中的事件传递。
NodeNode作为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:用于内联cssDecoratorNode用于在编辑器中插入任意视图(组件)的包装器节点。
节点属性支持给节点添加自定义属性,但必须是可以JSON序列化的,对于函数、Symbol、Map、Set这类数据不能作为属性。lexical里习惯将属性名称以双下划线__作为前缀,表示其私有及不可直接访问性质。
如果你希望添加一个可以被更改和访问的属性,需要创建get*()、set*()方法,在这两个方法内还需要规范使用内部函数getWritable()、getLatest()以确保lexical内部系统数据的一致性。除此之外,每一个节点都需要有static getType()方法以及static clone()方法,前者在重建节点(复制粘贴)时会用到,后者在创建EditorState快照时会用到,这是一个示例:
classMyCustomNodeextendsSomeOtherNode{
__foo:string;
staticgetType():string{
return'custom-node';
}
staticclone(node:MyCustomNode):MyCustomNode{
returnnewMyCustomNode(node.__foo,node.__key);
}
constructor(foo:string,key?:NodeKey){
super(key);
this.__foo=
}
setFoo(foo:string){
//getWritable()createsacloneofthenode
//ifneeded,toensurewedon'ttryandmutate
//astaleversionofthisnode.
constself=this.getWritable();
self.__foo=
}
getFoo():string{
//getLatest()ensureswearegettingthemost
//up-to-datevaluefromtheEditorState.
constself=this.getLatest();
returnself.__foo;
}
}
自定义节点lexical提供了基于ElementNode、TextNode、DecoratorNode进行自定义节点的能力
?lexical内部的RootNode和ParagraphNode就是基于ElementNode创建的
?ElementNode下面是一个拓展ElementNode的示例:
import{ElementNode,LexicalNode}from'lexical';
exportclassCustomParagraphextendsElementNode{
staticgetType():string{
return'custom-paragraph';
}
staticclone(node:CustomParagraph):CustomParagraph{
returnnewCustomParagraph(node.__key);
}
createDOM():HTMLElement{
//DefinetheDOMelementhere
constdom=document.createElement('p');
return
}
updateDOM(prevNode:CustomParagraph,dom:HTMLElement):boolean{
//ReturningfalsetellsLexicalthatthisnodedoesnotneedits
//DOMelementreplacingwithanewcopyfromcreateDOM.
returnfalse;
}
}
通常创建自定义节点的开发人员还需要提供一些以$开头的工具函数,以便使用者可以方便的创建、校验这些自定义节点,例如:
exportfunction$createCustomParagraphNode():CustomParagraph{
returnnewCustomParagraph();
}
exportfunction$isCustomParagraphNode(node:LexicalNode|null|undefined):nodeisCustomParagraph{
returnnodeinstanceofCustomParagraph;
}
TextNodeexportclassColoredNodeextendsTextNode{
__color:string;
constructor(text:string,color:string,key?:NodeKey):void{
super(text,key);
this.__color=color;
}
staticgetType():string{
return'colored';
}
staticclone(node:ColoredNode):ColoredNode{
returnnewColoredNode(node.__text,node.__color,node.__key);
}
createDOM(config:EditorConfig):HTMLElement{
constelement=super.createDOM(config);
element.style.color=this.__color;
returnelement;
}
updateDOM(
prevNode:ColoredNode,
dom:HTMLElement,
config:EditorConfig,
):boolean{
constisUpdated=super.updateDOM(prevNode,dom,config);
if(prevNode.__color!==this.__color){
dom.style.color=this.__color;
}
returnisUpdated;
}
}
exportfunction$createColoredNode(text:string,color:string):ColoredNode{
returnnewColoredNode(text,color);
}
exportfunction$isColoredNode(node:LexicalNode|null|undefined):nodeisColoredNode{
returnnodeinstanceofColoredNode;
}
DecoratorNodeexportclassVideoNodeextendsDecoratorNodeReactNode{
__id:string;
staticgetType():string{
return'video';
}
staticclone(node:VideoNode):VideoNode{
returnnewVideoNode(node.__id,node.__key);
}
constructor(id:string,key?:NodeKey){
super(key);
this.__id=
}
createDOM():HTMLElement{
returndocument.createElement('div');
}
updateDOM():false{
returnfalse;
}
decorate():ReactNode{
returnVideoPlayervideoID={this.__id}/;
}
}
exportfunction$createVideoNode(id:string):VideoNode{
returnnewVideoNode(id);
}
exportfunction$isVideoNode(
node:LexicalNode|null|undefined,
):nodeisVideoNode{
returnnodeinstanceofVideoNode;
}
节点覆盖(Node Overrides)lexical开发包提供了ParagraphNode、HeadingNode、QuoteNode、List等内置节点,但如果你想自定义一个ParagraphNode并替换掉内置的节点,那该如何实现呢?首先我们以ParagraphNode为基础创建出一个自定义节点class。但如何告知lexical采用自己的自定义节点呢?这时节点覆盖(Node Overrides)就能发挥作用了,该功能支持你将节点做替换:
consteditorConfig={
...
nodes=[
//Don'tforgettoregisteryourcustomnodeseparately!
CustomParagraphNode,
{
replace:ParagraphNode,
with:(node:ParagraphNode)={
returnnewCustomParagraphNode();
}
}
]
}
这里有一个完整的「开发示例」
节点转换(Node Transforms)节点转换是最有效率的修改EditorState的机制。以场景为例,如果用户输入的单词是congrats,那么就将这个单词的颜色设为蓝色,此时我们就可以通过节点转换来实现
节点转换的语法是:
editor.registerNodeTransformT:LexicalNode(Class,T):()=void
之所以比较高效,是因为多个转换只会导致一次DOM reconcile
一般情况下,转换只需要执行一次,但由于脏检查机制,可能会产生连带影响。我们有必要关注判断条件,以免转换陷入死循环:
//WhenaTextNodechanges(markedasdirty)makeitbold
editor.registerNodeTransform(TextNode,textNode={
//Important:Checkcurrentformatstate
if(!textNode.hasFormat('bold')){
textNode.toggleFormat('bold');
}
}
通常情况下次序并不重要,下面这个代码会在两次转换后结束:
//Plugin1
editor.registerNodeTransform(TextNode,textNode={
//Thistransformrunstwicebutdoesnothingthefirsttimebecauseitdoesn'tmeetthepreconditions
if(textNode.getTextContent()==='modified'){
textNode.setTextContent('re-modified');
}
})
//Plugin2
editor.registerNodeTransform(TextNode,textNode={
//Thistransformrunsonlyonce
if(textNode.getTextContent()==='original'){
textNode.setTextContent('modified');
}
})
//App
editor.addListener('update',({editorState})={
consttext=editorState.read($textContent);
//text==='re-modified'
});
这里有三个示例可供参考:
EmojisAutoLinkHashtagPlugin指令(Commands)在lexical中指令是一个很常用的功能,它提供了一套事件机制,在「工具栏」或复杂Plugin(如TablePlugin)中经常会用到
在LexicalCommands.ts可以查询到所有现存的指令,如果你想自定义一个指令,那么需要用到createCommand函数:
constHELLO_WORLD_COMMAND:LexicalCommandstring=createCommand();
editor.dispatchCommand(HELLO_WORLD_COMMAND,'HelloWorld!');
editor.registerCommand(
HELLO_WORLD_COMMAND,
(payload:string)={
console.log(payload);//HelloWorld!
returnfalse;
},
LowPriority,
);
指令可以在任何地方被dispatch(几乎全部的内部核心指令都是在LexicalEvents.ts里)
如果不再需要指令监听,那么一定记得及时清理:
constremoveListener=editor.registerCommand(
COMMAND,
(payload)=boolean,//Returntruetostoppropagation.
priority,
);
//...
removeListener();//Cleansupthelistener.
插件(Plugin)不同于大多数框架,lexical不给插件定义任何特定的协议,所谓的插件其实就是一个接收Editor实例的函数,这个函数返回一个清理函数。插件内的全部工作都是通过Editor实例调用指令(Commands)、转换(Transforms)、节点等接口实现的
示例lexical提供Vanilla JS版接口,不依赖任何框架,下面是一段示例代码(为了编写方便,我们用到了react。lexical提供了专门的react组件,使用更简单,但这里我们用的是Vanilla JS版接口):
.editor-wrapper{
border:2pxsolidgray;
}
#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;
}
importReact,{useEffect,useRef}from'react';
importreactDomfrom'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';
functionApp(){
consteditorRef=useRef();
conststateRef=useRef();
useEffect(()={
constinitialConfig={
namespace:'VanillaJSDemo',
//注册节点@lexical/rich-text
nodes:[HeadingNode,QuoteNode],
onError:error={
throwerror;
},
theme:{
//给引用节点增加样式classname,样式在css文件中定义
quote:'custom_quote_class_name'
}
consteditor=createEditor(initialConfig);
editor.setRootElement(editorRef.current);
//注册plugin
mergeRegister(
registerRichText(editor),
registerDragonSupport(editor),
registerHistory(editor,createEmptyHistoryState(),300),
editor.update(()={
constroot=$getRoot();
if(root.getFirstChild()!==null){
return;
}
constheading=$createHeadingNode('h1');
heading.append($createTextNode('这是一段标题'));
root.append(heading);
constquote=$createQuoteNode();
quote.append(
$createTextNode('这是一段引用'),
root.append(quote);
constparagraph=$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
divid='app'
div
h1LexicalBasic-VanillaJS/h1
divclassName='editor-wrapper'
divid='lexical-editor'contentEditableref={editorRef}/
/div
h4Editorstate:/h4
textareaid='lexical-state'ref={stateRef}/
/div
/div
/div
}
constroot=reactDom.createRoot(document.getElementById('main'));
root.render(App/);
接下来将会详细介绍通过lexical实现一个实际的富文本编辑器,详见《快速打造你自己的富文本编辑器》
关注公众号,“技术干货”及时达!
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线