6

Quill编辑器实现原理初探 - Gerryli

 2 years ago
source link: https://www.cnblogs.com/gerry2019/p/17154900.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Quill编辑器实现原理初探

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

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

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

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

L1级编辑器核心亮点为增加了一层DOM抽象,用数据去驱动视图的更新。HTML是一门标记语言,没有较强逻辑性,而且可以层层嵌套,元素的种类又分为行内元素、行内块元素、块级元素,每个元素的表现形式又有区别,删繁就简,客观描述出每个元素的结构与行为,会让整个文档变得自主可控。字符是分散在不同的DOM节点中,树形结构遍历的时间复杂度是O(n*h),这无疑是一种巨大的性能消耗,因此L1级编辑器,用一种扁平化的数据结构去描述字符的位置、样式,这样对于字符查找、字符操作,会提升不少性能,具体实现细节也是很复杂的,后面会慢慢介绍。

L0L1级编辑器,自身并没有脱离DOM,底层还是依赖于contenteditable,还是受限于浏览器自身,比如页面排版、焦点、选区等。但是到了L2级编辑器,就脱离了浏览器原生操作。使用canvassvg来实现内容编排,焦点、选区等操作都是自身手动去实现。这部分过于复杂,也只有GoogleWPS之类的厂商才有实力去研发,我们不做过多的深究。

Quill编辑器API比较简单,概念比较清晰,上手也比prosemirror简单,又有底层定制开发能力,使用范围较广。本文将简单介绍Quill的一些核心概念和操作过程,实现细节在后续的文章中慢慢介绍。

Quill 基本原理

通过简介中的介绍,我们知道L1级编辑器的几个核心概念,

  1. document文档数据模型(对应Quill中的Parchment
  2. DOM节点Node的描述(对应Quill中的Blot
  3. 一种扁平化的字符位置、样式描述(对应Quill中的Delta

下文我们对以上Quill中的概念做进一步的描述。

  • Delta

套用官网的话,什么是Delta?

1107056-20230225174311785-1246926070.png

这段话翻译为中文为:“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 } }
  ]
}

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

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

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

  • ParchmentBlot

Parchmentdocument的数据抽象,而Blot是对Node节点的抽象。也就是说,ParchmentBlot的父级,很多个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信息,真实DOMblot缓存在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的过程

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

  1. 获取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);
  }
  1. 获取每行数据的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架构等,后续文章会慢慢进行阐述。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK