全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2024-07-16_新一代富文本编辑器框架lexical入门

您的位置:首页 >> 新闻 >> 行业资讯

新一代富文本编辑器框架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实现一个实际的富文本编辑器,详见《快速打造你自己的富文本编辑器》 关注公众号,“技术干货”及时达! 阅读原文

上一篇:2022-02-18_周五了,今天的你也辛苦了 下一篇:2019-12-05_「转」中国青年导演的“小宁浩”们,为何放不下“黑色幽默+非线性叙事”?

TAG标签:

18
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价