现代富文本编辑器Quill的内容渲染机制
DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
引言
在 Web 开发领域,富文本编辑器( Rich Text Editor )是一个使用场景非常广,又非常复杂的组件。
要从0开始做一款好用、功能强大的富文本编辑器并不容易,基于现有的开源库进行开发能节省不少成本。
Quill 是一个很不错的选择。
本文主要介绍Quill内容渲染相关的基本原理,主要包括:
- Quill描述编辑器内容的方式
- Quill将Delta渲染到DOM的基本原理
- Scroll类管理所有子Blot的基本原理
Quill如何描述编辑器内容?
Quill简介
Quill 是一款API驱动、易于扩展和跨平台的现代 Web 富文本编辑器。目前在 Github 的 star 数已经超过25k。
Quill 使用起来也非常方便,简单几行代码就可以创建一个基本的编辑器:
Quill如何描述格式化的文本
当我们在编辑器里面插入一些格式化的内容时,传统的做法是直接往编辑器里面插入相应的 DOM,通过比较 DOM 树来记录内容的改变。
直接操作 DOM 的方式有很多不便,比如很难知道编辑器里面某些字符或者内容到底是什么格式,特别是对于自定义的富文本格式。
Quill 在 DOM 之上做了一层抽象,使用一种非常简洁的数据结构来描述编辑器的内容及其变化:Delta。
Delta 是JSON的一个子集,只包含一个 ops 属性,它的值是一个对象数组,每个数组项代表对编辑器的一个操作(以编辑器初始状态为空为基准)。
比如编辑器里面有"Hello World":
用 Delta 进行描述如下:
意思很明显,在空的编辑器里面插入"Hello ",在上一个操作后面插入加粗的"World",最后插入一个换行"\n"。
Quill如何描述内容的变化
Delta 非常简洁,但却极富表现力。
它只有3种动作和1种属性,却足以描述任何富文本内容和任意内容的变化。
3种动作:
- insert:插入
- retain:保留
- delete:删除
1种属性:
- attributes:格式属性
比如我们把加粗的"World"改成红色的文字"World",这个动作用 Delta 描述如下:
意思是:保留编辑器最前面的6个字符,即保留"Hello "不动,保留之后的5个字符"World",并将这些字符设置为字体颜色为"#ff0000"。
如果要删除"World",相信聪明的你也能猜到怎么用 Delta 描述,没错就是你猜到的:
Quill如何描述富文本内容
最常见的富文本内容就是图片,Quill 怎么用 Delta 描述图片呢?
insert 属性除了可以是用于描述普通字符的字符串格式之外,还可以是描述富文本内容的对象格式,比如图片:
比如公式:
Quill 提供了极大的灵活性和可扩展性,可以自由定制富文本内容和格式,比如幻灯片、思维导图,甚至是3D模型。
setContent如何将Delta数据渲染成DOM?
上一节我们介绍了 Quill 如何使用 Delta 描述编辑器内容及其变化,我们了解到 Delta 只是普通的 JSON 结构,只有3种动作和1种属性,却极富表现力。
那么 Quill 是如何应用 Delta 数据,并将其渲染到编辑器中的呢?
setContents 初探
Quill 中有一个 API 叫 setContents,可以将 Delta 数据渲染到编辑器中,本期将重点解析这个 API 的实现原理。
还是用上一期的 Delta 数据作为例子:
当使用 new Quill() 创建好 Quill 的实例之后,我们就可以调用它的 API 啦。
我们试着调用下 setContents 方法,传入刚才的 Delta 数据:
编辑器中就出现了我们预期的格式化文本:
setContents 源码
通过查看 setContents 的源码,发现就调用了 modify 方法,主要传入了一个函数:
使用 call 方法调用 modify 是为了改变其内部的 this 指向,这里指向的是当前的 Quill 实例,因为 modify 方法并不是定义在 Quill 类中的,所以需要这么做。
我们先不看 modify 方法,来看下传入 modify 方法的匿名函数。
该函数主要做了三件事:
- 把编辑器里面原有的内容全部删除
- 应用传入的 Delta 数据,将其渲染到编辑器中
- 返回1和2组合之后的 Delta 数据
我们重点看第2步,这里涉及到 Editor 类的 applyDelta 方法。
applyDelta 方法解析
根据名字大概能猜到该方法的目的是:把传入的 Delta 数据应用和渲染到编辑器中。
它的实现我们大概也可以猜测就是:循环 Delta 里的 ops 数组,一个一个地应用到编辑器中。它的源码一共54行,大致如下:
和我们猜测的一样,该方法就是用 Delta 的 reduce 方法对传入的 Delta 数据进行迭代,将插入内容和删除内容的逻辑分开了,插入内容的迭代里主要做了两件事:
- 插入普通文本或富文本内容:insertAt
- 格式化该文本:formatAt
至此,将 Delta 数据应用和渲染到编辑器中的逻辑,我们已经解析完毕。
下面做一个总结:
- setContents 方法本身没有什么逻辑,仅仅是调用了 modify 方法而已
- 在传入 modify 方法的匿名函数中调用了 Editor 对象的 applyDelta 方法
- applyDelta 方法对传入的 Delta 数据进行迭代,并依次插入/格式化/删除 Delta 数据所描述的编辑器内容
Scroll如何管理所有的Blot类型?
上一节我们介绍了 Quill 将 Delta 数据应用和渲染到编辑器中的原理:通过迭代 Delta 中的 ops 数据,将 Delta 行一个一个渲染到编辑器中。
了解到最终内容的插入和格式化都是通过调用 Scroll 对象的方法实现的,Scroll 对象到底是何方神圣?在编辑器的操作中发挥了什么作用?
Scroll 对象的创建
上一节的解析终止于 applyDelta 方法,该方法最终调用了 this.scroll.insertAt 将 Delta 内容插入到编辑器中。
applyDelta 方法定义在 Editor 类中,在 Quill 类的 setContents 方法中被调用,通过查看源码,发现 this.scroll 最初是在 Quill 的构造函数中被赋值的。
Scroll 对象是通过调用 Parchment 的 create 方法创建的。
前面两期我们简单介绍了 Quill 的数据模型 Delta,那么 Parchment 又是什么呢?它跟 Quill 和 Delta 是什么关系?这些疑问我们先不解答,留着后续详细讲解。
先来简单看下 create 方法是怎么创建 Scroll 对象的,create 方法最终是定义在 parchment 库源码中的 registry.ts 文件中的,就是一个普通的方法:
create 方法的入参是编辑器主体 DOM 元素 .ql-editor,通过调用同文件中的 query 普通方法,查询到 Blot 类是 Scroll 类,查询的大致逻辑就是在一个 map 表里查,最后通过 new Scroll() 返回 Scroll 对象实例,赋值给 this.scroll。
Scroll 类详解
Scroll 类是我们解析的第一个 Blot 格式,后续我们将遇到各种形式的 Blot 格式,并且会定义自己的 Blot 格式,用于在编辑器中插入自定义内容,这些 Blot 格式都有类似的结构。
可以简单理解为 Blot 格式是对 DOM 节点的抽象,而 Parchment 是对 HTML 文档的抽象,就像 DOM 节点是构成 HTML 文档的基本单元一样,Blot 是构成 Parchment 文档的基本单元。
比如:DOM 节点是<div>,对其进行封装变成 <div class="ql-editor">,并在其内部封装一些属性和方法,就变成 Scroll 类。
Scroll 类是所有 Blot 的根 Blot,它对应的 DOM 节点也是编辑器内容的最外层节点,所有编辑器内容都被包裹在它之下,可以认为 Scroll 统筹着其他 Blot 对象(实际 Scroll 的父类 ContainerBlot 才是幕后总 BOSS,负责总的调度)。
Scroll 类定义在 Quill 源码中的 blots/scroll.js 文件中,之前 applyDelta 方法中通过 this.scroll 调用的 insertAt / formatAt / deleteAt / update / batchStart / batchEnd / optimize 等方法都在 Scroll 类中。
以下是 Scroll 类的定义:
Scroll 类上定义的静态属性 blotName 和 tagName 是必须的,前者用于唯一标识该 Blot 格式,后者对应于一个具体的 DOM 标签,一般还会定义一个 className,如果该 Blot 是一个父级 Blot,一般还会定义 allowedChildren 用来限制允许的子级 Blot 白名单,不在白名单之内的子级 Blot 对应的 DOM 将无法插入父类 Blot 对应的 DOM 结构里。
Scroll 类中除了定义了插入 / 格式化 / 删除内容的方法之外,定义了一些很实用的用于获取当前位置 Blot 路径和 Blot 对象的方法,以及触发编辑器内容更新的事件。
相应方法的解析都在以上源码的注释里,其中 optimize 和 update 方法涉及 Quill 中的事件和状态变更相关逻辑,放在后续单独进行解析。
关于 Blot 格式的规格定义文档可以参阅以下文章:
https://github.com/quilljs/parchment#blots
我也是初次使用Quill进行富文本编辑器的开发,难免有理解不到位的地方,欢迎大家提意见和建议。
加入我们
我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com。
文/DevUI Kagol