Quill编辑器实现原理初探
从事前端开发的同学,对富文本编辑器都不是很陌生。但是大多数富文本编辑器都是开箱即用,很少会对其实现原理进行深入的探讨。假如静下心去细细品味,会发现想要做好一款富文本编辑器,需要对整个前端生态有较深入的理解。在某种意义上说,富文本编辑器是前端一个集大成者。
富文本编辑器根据其实现方式,业内将其划分为L0L2,层层递进,功能的支撑也越来越强大。
阶段
描述
典型产品
L0
视图层基于contenteditable,逻辑层基于document。execCommand,直接操作DOM
UEditor、TinyMCE
L1
视图层基于contenteditable,逻辑层对DOM进行抽象,用数据去驱动视图更新
Quill、Prosemirror、slate、Draft
L2
自己实现内容排版,不依赖于浏览器原生操作
GoogleDocs、WPS
L0级编辑器,基于contenteditable与document。execCommand指令,直接操作DOM,简单粗暴,所见即所得,其优点是简单,我们只需要聚焦在视图层,document。execCommand自身也提供一些操作指令,可以满足基本的文本操作需求,个性化的需求也可以通过封装自定义指令来满足;同理,缺点也很明显,只关注视图层,没有逻辑抽象,对于操作记录,文档结构变化,是黑盒,对于文档的版本管理、协同办公之类的需求,无能为力,因此,带着痛点,孕育出了L1级编辑器。
L1级编辑器核心亮点为增加了一层DOM抽象,用数据去驱动视图的更新。HTML是一门标记语言,没有较强逻辑性,而且可以层层嵌套,元素的种类又分为行内元素、行内块元素、块级元素,每个元素的表现形式又有区别,删繁就简,客观描述出每个元素的结构与行为,会让整个文档变得自主可控。字符是分散在不同的DOM节点中,树形结构遍历的时间复杂度是O(nh),这无疑是一种巨大的性能消耗,因此L1级编辑器,用一种扁平化的数据结构去描述字符的位置、样式,这样对于字符查找、字符操作,会提升不少性能,具体实现细节也是很复杂的,后面会慢慢介绍。
L0、L1级编辑器,自身并没有脱离DOM,底层还是依赖于contenteditable,还是受限于浏览器自身,比如页面排版、焦点、选区等。但是到了L2级编辑器,就脱离了浏览器原生操作。使用canvas或svg来实现内容编排,焦点、选区等操作都是自身手动去实现。这部分过于复杂,也只有Google、WPS之类的厂商才有实力去研发,我们不做过多的深究。
Quill编辑器API比较简单,概念比较清晰,上手也比prosemirror简单,又有底层定制开发能力,使用范围较广。本文将简单介绍Quill的一些核心概念和操作过程,实现细节在后续的文章中慢慢介绍。Quill基本原理
通过简介中的介绍,我们知道L1级编辑器的几个核心概念,document文档数据模型(对应Quill中的Parchment)DOM节点Node的描述(对应Quill中的Blot)一种扁平化的字符位置、样式描述(对应Quill中的Delta)
下文我们对以上Quill中的概念做进一步的描述。核心概念Delta
套用官网的话,什么是Delta?
这段话翻译为中文为:Deltas是一种简单而富有表现力的格式,可以用来描述Quill的内容和变化。该格式是JSON的严格子集,是人类可读的,机器很容易解析。Deltas可以描述任何Quill文档,包括所有文本和格式信息,没有HTML的歧义和复杂性。
一个Delta数据结构表现形式:编辑器初始值{ops:〔{insert:Hello},{insert:World},〕}给World加粗后的值3种动作:insert:插入,retain:保留,delete:删除{ops:〔{retain:6},{retain:5,attributes:{bold:true}}〕}
这个能力使文档协同编辑成为了可能。最简单的协同编辑,通过以下几步操作即可:监听编辑器文本改变textchange,获取数据改变的描述Delta通过websocket将Delta分发给每位协同编辑用户调用Quill实例中UpdateContents,更新协同编辑文档
Delta对于文档的位置、样式描述,极大的简化文档操作,最原始的文档查找替换,需要深度优先遍历,还需要递归查找,十分不便,有了Delta,它精准的描述了每个字符的位置,我们就可以像处理纯文本一样处理富文本。Parchment与Blot
Parchment是document的数据抽象,而Blot是对Node节点的抽象。也就是说,Parchment是Blot的父级,很多个Blot组装成一个Parchment。
Blot分类:ContainerBlot(容器节点)ScrollBlotroot(文档的根节点,不可格式化)BlockBlot块级(可格式化的父级节点)InlineBlot内联(可格式化的父级节点)
ScrollBlot的实例数据结构:{domNode:{},真实的DOM节点prev:null,前一个元素next:null,后一个元素uiNode:null,registry:{注册的信息attributes:{},classes:{},tags:{},types:{}},children:{子元素的节点描述,为一个链表head:null,第一个元素tail:null,最后一个元素length:0子元素长度},observer:{}DOM监听器}DOM变化与Parchment之间的数据同步
文档数据描述固然好,但是真实DOM和数据模型如何实现实时同步呢?
在ScrollBlot中,有个MutationObserver,去实时监测DOM变化。当DOM发生变化时,会根据侦测到的真实DOM,去查找对应节点的blot信息,真实DOM与blot缓存在Registry中,以一个WeakMap的形式存储,具体缓存可见:parchmentsrcregistry。tspublicstaticblotsnewWeakMapNode,Blot();
根据MutationObserver回调的变化信息,执行对应的blotupdate,以blockBlot为例,其update方法如下:publicupdate(mutations:MutationRecord〔〕,context:{〔key:string〕:any},):void{调用ParentBlot中update方法,对新增和删除节点做逻辑同步super。update(mutations,context);更新样式的逻辑同步constattributeChangedmutations。some((mutation)mutation。targetthis。domNodemutation。typeattributes,);if(attributeChanged){this。attributes。build();}}Parchment映射成Delta的过程
有了Parchment对DOM的抽象,就方便对文档字符位置和样式进行扁平化的描述,以编辑器初始化为例,看看Quill是如何获取文档模型的Delta。获取ScrollBlot中所有的Block,默认从Block开始处理,即最小颗粒度是块级元素editor。ts中获取delta方法getDelta():Delta{returnthis。scroll。lines()。reduce((delta,line){以Block为维度,分别获取每行的delta描述returndelta。concat(line。delta());},newDelta());}scroll。ts中获取所有line的方法,即Blocklines(index0,lengthNumber。MAXVALUE):(BlockBlockEmbed)〔〕{constgetLines(blot:ParentBlot,blotIndex:number,blotLength:number,){letlines〔〕;letlengthLeftblotLength;blot。children。forEachAt(blotIndex,blotLength,(child,childIndex,childLength){最小颗粒度为Blockif(isLine(child)){lines。push(child);}elseif(childinstanceofContainerBlot){lineslines。concat(getLines(child,childIndex,lengthLeft));}lengthLeftchildLength;},);returnlines;};returngetLines(this,index,length);}获取每行数据的delta描述block。tsdelta():Delta{if(this。cache。deltanull){this。cache。deltablockDelta(this);}returnthis。cache。delta;}functionblockDelta(blot:BlockBlot,filtertrue){return(blottsexpecterror。descendants(LeafBlot)获取所有叶子节点。reduce((delta,leaf:LeafBlot){if(leaf。length()0){叶子节点的长度returndelta;}插入一个delta描述符,包含位置,样式描述returndelta。insert(leaf。value(),bubbleFormats(leaf,{},filter));},newDelta())。insert(,bubbleFormats(blot)));}
获取delta的过程也是遍历至叶子节点,根据叶子节点的位置进行计算。结语
以上只是对Quill的核心概念的简单描述,还有很多细节没有做过多的阐述,如如何注册自定义扩展、Quill的渲染流程、Parchment架构等,后续文章会慢慢进行阐述。
前三季度汽车自主品牌市场份额近50,造车新势力抢占BBA丢掉的地盘成功了吗?记者周姝祺编辑随着9月份各大汽车厂商公布销量数据,今年前三季度销量表现已经一目了然。自主品牌依旧稳步前进,而豪华品牌和合资品牌难掩下跌趋势。从整体来看,乘联会数据显示,今年前三季度
总书记打开的湖南窗口马栏山万马犇腾创V谷习近平总书记指出文化自信,是更基础更广泛更深厚的自信。文化自信成为继道路自信理论自信和制度自信之后,中国特色社会主义的第四个自信。海拔不高产业高的马栏山,崛起了一座举国鼎盛无二的视
国务院国资委央企要在11月底前完成减免小微企业和个体工商户房屋3个月租金工作人民网北京10月13日电(黄盛)国务院国资委日前发布关于进一步做好2022年服务业小微企业和个体工商户房屋租金减免工作的通知(以下简称通知),就切实将服务业小微企业和个体工商户房屋
中国科研人员揭秘神奇温泉蛇与青藏高原的形成和隆起有密切联系科学杂志新闻栏目近日报道李家堂研究团队的温泉蛇系列研究成果。中科院成都生物研究所供图中新网成都10月13日电(记者贺劭清)世界上大多数蛇类生活在热带或亚热带地区,为什么神奇的温泉蛇
新疆科研人员对亚洲水塔积雪变化研究获初步成果乌鲁木齐晚报全媒体讯(记者梁乐)来自中科院新疆生地所的一支科研团队,近年来系统研究了全球气候变化背景下,亚洲中部高海拔地区(也称亚洲水塔)近40年来不同区域不同海拔地区积雪指标的时
生命活到极致一定是简与静头条创作挑战赛缘分靠什么而永远,靠的是一分相知,九分认真朋友靠什么而不散,靠的是一分陪伴,九分帮衬人心靠什么而贴近,靠的是一分喜欢,九分交换。人心,永远是互换的。你替别人着想,别人
生命终止之时,人如何体面地离开?人生两件大事,便是生与死。在自己的啼哭声中迎来新生,在他人的痛哭声中迎来死亡。大多数人都是普通而平凡的一生,虽不能像英雄一般生的伟大死的光荣,但也不愿悄然无声毫无尊严地离世。为自己
陈春花生长最美生长之路往往风雨交加,真正的生长之美不在于人生最终的高度,而是生长的过程中,我们内心能否保持足够的沉静,行动能否保持足够的韧性。春暖花开好文1998字3分钟阅读前几日,应骆医生的邀
每日好诗心事有些臃肿,北方的天空依然很空每日好诗摆渡文依梅听雪我的文字流落到异乡的时候江南正在下一场鹅毛大雪心事有些臃肿北方的天空依然很空偶尔有云彩飞过来盖住阳光影子趁势溜掉路还是有些难走有一些岔路没有路标前方延伸的地方
读首诗再睡觉我只为你意乱神迷,在神撑起的天空流亡像挥霍无度的帝王我躺在其下受尽语言的欺压事物高高举起缓缓放下的快乐如同河马,扬起血盆大口我们把勃起的力量收进渐渐萎缩的渴望。有一种甜蜜胜过书页翻动的声音我们俯身过去为模糊的记忆重燃
灰椋鸟的天空灰椋鸟的天空文紫藤晴儿摄影钟福生小到玲珑却可以扩大着无限的天空它鸣叫的早晨是我们睡醒的一天黯然的事物也在重新焕然生机用一些爱去辨认着它故乡从不会遥远,它飞在哪哪儿也就像故乡乡愁里的