翼度科技»论坛 编程开发 JavaScript 查看内容

Quill编辑器实现原理初探

7

主题

7

帖子

21

积分

新手上路

Rank: 1

积分
21
简介

从事前端开发的同学,对富文本编辑器都不是很陌生。但是大多数富文本编辑器都是开箱即用,很少会对其实现原理进行深入的探讨。假如静下心去细细品味,会发现想要做好一款富文本编辑器,需要对整个前端生态有较深入的理解。在某种意义上说,富文本编辑器是前端一个集大成者。
富文本编辑器根据其实现方式,业内将其划分为L0 ~ L2,层层递进,功能的支撑也越来越强大。
阶段描述典型产品L0视图层基于contenteditable,逻辑层基于document.execCommand,直接操作DOMUEditor、TinyMCEL1视图层基于contenteditable,逻辑层对DOM进行抽象,用数据去驱动视图更新Quill、Prosemirror、slate、DraftL2自己实现内容排版,不依赖于浏览器原生操作Google Docs、WPSL0级编辑器,基于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数据结构表现形式:
  1. // 编辑器初始值
  2. {
  3.   "ops": [
  4.     { "insert": "Hello " },
  5.     { "insert": "World" },
  6.   ]
  7. }
  8. // 给World加粗后的值
  9. // 3种动作:insert: 插入,retain:保留, delete:删除
  10. {
  11.   "ops": [
  12.     { "retain": 6 },
  13.     { "retain": 5, "attributes": { "bold": true } }
  14.   ]
  15. }
复制代码
这个能力使文档协同编辑成为了可能。最简单的协同编辑,通过以下几步操作即可:

  • 监听编辑器文本改变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的实例数据结构:
  1. {
  2.   "domNode": {}, // 真实的DOM节点
  3.   "prev": null, // 前一个元素
  4.   "next": null, // 后一个元素
  5.   "uiNode": null,
  6.   "registry": { // 注册的信息
  7.     "attributes": {},
  8.     "classes": {},
  9.     "tags": {},
  10.     "types": {}
  11.   },
  12.   "children": { // 子元素的节点描述,为一个链表
  13.     "head": null, // 第一个元素
  14.     "tail": null, // 最后一个元素
  15.     "length": 0 // 子元素长度
  16.   },
  17.   "observer": {} // DOM监听器
  18. }
复制代码
DOM变化与Parchment之间的数据同步

文档数据描述固然好,但是真实DOM和数据模型如何实现实时同步呢?
在ScrollBlot中,有个MutationObserver,去实时监测DOM变化。当DOM发生变化时,会根据侦测到的真实DOM,去查找对应节点的blot信息,真实DOM与blot缓存在Registry中,以一个WeakMap的形式存储,具体缓存可见:
  1. // parchment\src\registry.ts
  2. public static blots = new WeakMap<Node, Blot>();
复制代码
根据MutationObserver回调的变化信息,执行对应的blot update,以blockBlot为例,其update方法如下:
  1. //
  2. public update(
  3.   mutations: MutationRecord[],
  4.   _context: { [key: string]: any },
  5. ): void {
  6.   // 调用ParentBlot中update方法,对新增和删除节点做逻辑同步
  7.   super.update(mutations, context);
  8.   // 更新样式的逻辑同步
  9.   const attributeChanged = mutations.some(
  10.     (mutation) =>
  11.       mutation.target === this.domNode && mutation.type === 'attributes',
  12.   );
  13.   if (attributeChanged) {
  14.     this.attributes.build();
  15.   }
  16. }
复制代码
Parchment映射成Delta的过程

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

  • 获取ScrollBlot中所有的Block,默认从Block开始处理,即最小颗粒度是块级元素
  1. // editor.ts中获取delta方法
  2. getDelta(): Delta {
  3.   return this.scroll.lines().reduce((delta, line) => {
  4.     // 以Block为维度,分别获取每行的delta描述
  5.     return delta.concat(line.delta());
  6.   }, new Delta());
  7. }
  8. // scroll.ts中获取所有line的方法,即Block
  9. lines(index = 0, length = Number.MAX_VALUE): (Block | BlockEmbed)[] {
  10.     const getLines = (
  11.       blot: ParentBlot,
  12.       blotIndex: number,
  13.       blotLength: number,
  14.     ) => {
  15.       let lines = [];
  16.       let lengthLeft = blotLength;
  17.       blot.children.forEachAt(
  18.         blotIndex,
  19.         blotLength,
  20.         (child, childIndex, childLength) => {
  21.           // 最小颗粒度为Block
  22.           if (isLine(child)) {
  23.             lines.push(child);
  24.           } else if (child instanceof ContainerBlot) {
  25.             lines = lines.concat(getLines(child, childIndex, lengthLeft));
  26.           }
  27.           lengthLeft -= childLength;
  28.         },
  29.       );
  30.       return lines;
  31.     };
  32.     return getLines(this, index, length);
  33.   }
复制代码

  • 获取每行数据的delta描述
  1. // block.ts
  2. delta(): Delta {
  3.   if (this.cache.delta == null) {
  4.     this.cache.delta = blockDelta(this);
  5.   }
  6.   return this.cache.delta;
  7. }
  8. function blockDelta(blot: BlockBlot, filter = true) {
  9.   return (
  10.     blot
  11.       // @ts-expect-error
  12.       .descendants(LeafBlot) // 获取所有叶子节点
  13.       .reduce((delta, leaf: LeafBlot) => {
  14.         if (leaf.length() === 0) { // 叶子节点的长度
  15.           return delta;
  16.         }
  17.         // 插入一个delta描述符,包含位置,样式描述
  18.         return delta.insert(leaf.value(), bubbleFormats(leaf, {}, filter));
  19.       }, new Delta())
  20.       .insert('\n', bubbleFormats(blot))
  21.   );
  22. }
复制代码
获取delta的过程也是遍历至叶子节点,根据叶子节点的位置进行计算。
结语

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


来源:https://www.cnblogs.com/gerry2019/p/17154900.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具