首页IT科技quill editor 图片路径 星号(Quill编辑器实现原理初探)

quill editor 图片路径 星号(Quill编辑器实现原理初探)

时间2025-07-31 03:32:14分类IT科技浏览4690
导读:简介 从事前端开发的同学,对富文本编辑器都不是很陌生。但是大多数富文本编辑器都是开箱即用,很少会对其实现原理进行深入的探讨。假如静下心去细细品味,会发现想要做好一款富文本编辑器,需要对整个前端生态有较深入的理解。在某种意义上说,富文本编辑器是前端一个集大成者。...

简介

从事前端开发的同学               ,对富文本编辑器都不是很陌生            。但是大多数富文本编辑器都是开箱即用                    ,很少会对其实现原理进行深入的探讨                     。假如静下心去细细品味      ,会发现想要做好一款富文本编辑器           ,需要对整个前端生态有较深入的理解        。在某种意义上说                     ,富文本编辑器是前端一个集大成者         。

富文本编辑器根据其实现方式         ,业内将其划分为L0 ~ L2       ,层层递进                      ,功能的支撑也越来越强大                    。

阶段 描述 典型产品 L0 视图层基于contenteditable            ,逻辑层基于document.execCommand   ,直接操作DOM UEditor            、TinyMCE L1 视图层基于contenteditable                      ,逻辑层对DOM进行抽象               ,用数据去驱动视图更新 Quill                     、Prosemirror        、slate         、Draft L2 自己实现内容排版,不依赖于浏览器原生操作 Google Docs                    、WPS

L0级编辑器                   ,基于contenteditable与document.execCommand指令                   ,直接操作DOM   ,简单粗暴               ,所见即所得                    ,其优点是简单      ,我们只需要聚焦在视图层           ,document.execCommand自身也提供一些操作指令                     ,可以满足基本的文本操作需求         ,个性化的需求也可以通过封装自定义指令来满足;同理       ,缺点也很明显                      ,只关注视图层            ,没有逻辑抽象   ,对于操作记录                      ,文档结构变化               ,是黑盒,对于文档的版本管理            、协同办公之类的需求                   ,无能为力                   ,因此   ,带着痛点               ,孕育出了L1级编辑器            。

L1级编辑器核心亮点为增加了一层DOM抽象                    ,用数据去驱动视图的更新      。HTML是一门标记语言      ,没有较强逻辑性           ,而且可以层层嵌套                     ,元素的种类又分为行内元素      、行内块元素                   、块级元素         ,每个元素的表现形式又有区别       ,删繁就简                      ,客观描述出每个元素的结构与行为            ,会让整个文档变得自主可控                   。字符是分散在不同的DOM节点中   ,树形结构遍历的时间复杂度是O(n*h)                      ,这无疑是一种巨大的性能消耗               ,因此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 } } ] }

这个能力使文档协同编辑成为了可能         。最简单的协同编辑       ,通过以下几步操作即可:

监听编辑器文本改变text-change                      ,获取数据改变的描述Delta 通过websocket将Delta分发给每位协同编辑用户 调用Quill实例中UpdateContents            ,更新协同编辑文档

Delta对于文档的位置            、样式描述   ,极大的简化文档操作                      ,最原始的文档查找替换               ,需要深度优先遍历,还需要递归查找                   ,十分不便                   ,有了Delta   ,它精准的描述了每个字符的位置               ,我们就可以像处理纯文本一样处理富文本                    。

Parchment与Blot

Parchment是document的数据抽象                    ,而Blot是对Node节点的抽象            。也就是说      ,Parchment是Blot的父级           ,很多个Blot组装成一个Parchment      。

Blot分类:

ContainerBlot(容器节点) ScrollBlot root(文档的根节点                     ,不可格式化) 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的形式存储,具体缓存可见:

// parchment\src\registry.ts public static blots = new WeakMap<Node, Blot>();

根据MutationObserver回调的变化信息                   ,执行对应的blot update                   ,以blockBlot为例   ,其update方法如下:

// public update( mutations: MutationRecord[], _context: { [key: string]: any }, ): void { // 调用ParentBlot中update方法               ,对新增和删除节点做逻辑同步 super.update(mutations, context); // 更新样式的逻辑同步 const attributeChanged = mutations.some( (mutation) => mutation.target === this.domNode && mutation.type === attributes, ); if (attributeChanged) { this.attributes.build(); } }

Parchment映射成Delta的过程

有了Parchment对DOM的抽象                    ,就方便对文档字符位置和样式进行扁平化的描述      ,以编辑器初始化为例           ,看看Quill是如何获取文档模型的Delta                。

获取ScrollBlot中所有的Block                     ,默认从Block开始处理         ,即最小颗粒度是块级元素 // editor.ts中获取delta方法 getDelta(): Delta { return this.scroll.lines().reduce((delta, line) => { // 以Block为维度       ,分别获取每行的delta描述 return delta.concat(line.delta()); }, new Delta()); } // scroll.ts中获取所有line的方法                      ,即Block lines(index = 0, length = Number.MAX_VALUE): (Block | BlockEmbed)[] { const getLines = ( blot: ParentBlot, blotIndex: number, blotLength: number, ) => { let lines = []; let lengthLeft = blotLength; blot.children.forEachAt( blotIndex, blotLength, (child, childIndex, childLength) => { // 最小颗粒度为Block if (isLine(child)) { lines.push(child); } else if (child instanceof ContainerBlot) { lines = lines.concat(getLines(child, childIndex, lengthLeft)); } lengthLeft -= childLength; }, ); return lines; }; return getLines(this, index, length); } 获取每行数据的delta描述 // block.ts delta(): Delta { if (this.cache.delta == null) { this.cache.delta = blockDelta(this); } return this.cache.delta; } function blockDelta(blot: BlockBlot, filter = true) { return ( blot // @ts-expect-error .descendants(LeafBlot) // 获取所有叶子节点 .reduce((delta, leaf: LeafBlot) => { if (leaf.length() === 0) { // 叶子节点的长度 return delta; } // 插入一个delta描述符            ,包含位置   ,样式描述 return delta.insert(leaf.value(), bubbleFormats(leaf, {}, filter)); }, new Delta()) .insert(\n, bubbleFormats(blot)) ); }

获取delta的过程也是遍历至叶子节点                      ,根据叶子节点的位置进行计算   。

结语

以上只是对Quill的核心概念的简单描述               ,还有很多细节没有做过多的阐述,如如何注册自定义扩展                     、Quill的渲染流程        、Parchment架构等                   ,后续文章会慢慢进行阐述                  。

参考资料

Quill Quill富文本编辑器的实践 深入浅出QuillJS

创心域SEO版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!

展开全文READ MORE
如何准确选择?(学习有效的选取技巧) emlog模板开发(Emlogseo插件:助您实现博客SEO优化)