DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)

引言

在 Web 开发领域,富文本编辑器( Rich Text Editor )是一个使用场景非常广,又非常复杂的组件。

要从0开始做一款好用、功能强大的富文本编辑器并不容易,基于现有的开源库进行开发能节省不少成本。

Quill 是一个很不错的选择。

本文主要介绍Quill内容渲染相关的基本原理,主要包括:

  1. Quill描述编辑器内容的方式
  2. Quill将Delta渲染到DOM的基本原理
  3. Scroll类管理所有子Blot的基本原理

Quill如何描述编辑器内容?

Quill简介

Quill 是一款API驱动、易于扩展和跨平台的现代 Web 富文本编辑器。目前在 Github 的 star 数已经超过25k。

Quill 使用起来也非常方便,简单几行代码就可以创建一个基本的编辑器:

1 <script>
2 var quill = new Quill('#editor', {
3 theme: 'snow'
4 });
5 </script>
 

Quill如何描述格式化的文本

当我们在编辑器里面插入一些格式化的内容时,传统的做法是直接往编辑器里面插入相应的 DOM,通过比较 DOM 树来记录内容的改变。

直接操作 DOM 的方式有很多不便,比如很难知道编辑器里面某些字符或者内容到底是什么格式,特别是对于自定义的富文本格式。

Quill 在 DOM 之上做了一层抽象,使用一种非常简洁的数据结构来描述编辑器的内容及其变化:Delta。

Delta 是JSON的一个子集,只包含一个 ops 属性,它的值是一个对象数组,每个数组项代表对编辑器的一个操作(以编辑器初始状态为空为基准)。

比如编辑器里面有"Hello World":

用 Delta 进行描述如下:

1 {
2 "ops": [
3 { "insert": "Hello " },
4 { "insert": "World", "attributes": { "bold": true } },
5 { "insert": "\n" }
6 ]
7 }
 

意思很明显,在空的编辑器里面插入"Hello ",在上一个操作后面插入加粗的"World",最后插入一个换行"\n"。

Quill如何描述内容的变化

Delta 非常简洁,但却极富表现力。

它只有3种动作和1种属性,却足以描述任何富文本内容和任意内容的变化。

3种动作:

  • insert:插入
  • retain:保留
  • delete:删除

1种属性:

  • attributes:格式属性

比如我们把加粗的"World"改成红色的文字"World",这个动作用 Delta 描述如下:

1 {
2 "ops": [
3 { "retain": 6 },
4 { "retain": 5, "attributes": { "color": "#ff0000" } }
5 ]
6 }
 

意思是:保留编辑器最前面的6个字符,即保留"Hello "不动,保留之后的5个字符"World",并将这些字符设置为字体颜色为"#ff0000"。

如果要删除"World",相信聪明的你也能猜到怎么用 Delta 描述,没错就是你猜到的:

1 {
2 "ops": [
3 { "retain": 6 },
4 { "delete": 5 }
5 ]
6 }
 

Quill如何描述富文本内容

最常见的富文本内容就是图片,Quill 怎么用 Delta 描述图片呢?

insert 属性除了可以是用于描述普通字符的字符串格式之外,还可以是描述富文本内容的对象格式,比如图片:

1 {
2 "ops": [
3 { "insert": { "image": "https://quilljs.com/assets/images/logo.svg" } },
4 { "insert": "\n" }
5 ]
6 }
 

比如公式:

1 {
2 "ops": [
3 { "insert": { "formula": "e=mc^2" } },
4 { "insert": "\n" }
5 ]
6 }
 

Quill 提供了极大的灵活性和可扩展性,可以自由定制富文本内容和格式,比如幻灯片、思维导图,甚至是3D模型。

setContent如何将Delta数据渲染成DOM?

上一节我们介绍了 Quill 如何使用 Delta 描述编辑器内容及其变化,我们了解到 Delta 只是普通的 JSON 结构,只有3种动作和1种属性,却极富表现力。

那么 Quill 是如何应用 Delta 数据,并将其渲染到编辑器中的呢?

setContents 初探

Quill 中有一个 API 叫 setContents,可以将 Delta 数据渲染到编辑器中,本期将重点解析这个 API 的实现原理。

还是用上一期的 Delta 数据作为例子:

1 const delta = {  "ops": [
2 { "insert": "Hello " },
3 { "insert": "World", "attributes": { "bold": true } },
4 { "insert": "\n" } ]
5 }
 

当使用 new Quill() 创建好 Quill 的实例之后,我们就可以调用它的 API 啦。

1 const quill = new Quill('#editor', {
2 theme: 'snow'
3 });
 

我们试着调用下 setContents 方法,传入刚才的 Delta 数据:

1 quill.setContents(delta);
 

编辑器中就出现了我们预期的格式化文本:

setContents 源码

通过查看 setContents 的源码,发现就调用了 modify 方法,主要传入了一个函数:

 1 setContents(delta, source = Emitter.sources.API) {
2 return modify.call( this, () => {
3 delta = new Delta(delta);
4 const length = this.getLength();
5 const deleted = this.editor.deleteText(0, length);
6 const applied = this.editor.applyDelta(delta);
7 ... // 为了方便阅读,省略了非核心代码
8 return deleted.compose(applied);
9 }, source, );
10 }
 

使用 call 方法调用 modify 是为了改变其内部的 this 指向,这里指向的是当前的 Quill 实例,因为 modify 方法并不是定义在 Quill 类中的,所以需要这么做。

我们先不看 modify 方法,来看下传入 modify 方法的匿名函数。

该函数主要做了三件事:

  1. 把编辑器里面原有的内容全部删除
  2. 应用传入的 Delta 数据,将其渲染到编辑器中
  3. 返回1和2组合之后的 Delta 数据

我们重点看第2步,这里涉及到 Editor 类的 applyDelta 方法。

applyDelta 方法解析

根据名字大概能猜到该方法的目的是:把传入的 Delta 数据应用和渲染到编辑器中。

它的实现我们大概也可以猜测就是:循环 Delta 里的 ops 数组,一个一个地应用到编辑器中。它的源码一共54行,大致如下:

 1 applyDelta(delta) {
2 let consumeNextNewline = false;
3 this.scroll.update();
4 let scrollLength = this.scroll.length();
5 this.scroll.batchStart();
6 const normalizedDelta = normalizeDelta(delta);
7
8 normalizedDelta.reduce((index, op) => {
9 const length = op.retain || op.delete || op.insert.length || 1;
10 let attributes = op.attributes || {};
11 // 1.插入文本
12 if (op.insert != null) {
13 if (typeof op.insert === 'string') {
14 // 普通文本内容
15 let text = op.insert;
16 ... // 为了阅读方便,省略非核心代码
17 this.scroll.insertAt(index, text);
18 ... // 为了阅读方便,省略非核心代码
19 } else if (typeof op.insert === 'object') {
20 // 富文本内容
21 const key = Object.keys(op.insert)[0];
22 // There should only be one key
23 if (key == null) return index;
24 this.scroll.insertAt(index, key, op.insert[key]);
25 }
26 scrollLength += length;
27 }
28 // 2.对文本进行格式化
29 Object.keys(attributes).forEach(name => {
30 this.scroll.formatAt(index, length, name, attributes[name]);
31 });
32 return index + length;
33 }, 0);
34 ... // 为了阅读方便,省略非核心代码 this.scroll.batchEnd();
35 this.scroll.optimize();
36 return this.update(normalizedDelta);
37 }
 

和我们猜测的一样,该方法就是用 Delta 的 reduce 方法对传入的 Delta 数据进行迭代,将插入内容和删除内容的逻辑分开了,插入内容的迭代里主要做了两件事:

  1. 插入普通文本或富文本内容:insertAt
  2. 格式化该文本:formatAt

至此,将 Delta 数据应用和渲染到编辑器中的逻辑,我们已经解析完毕。
下面做一个总结:

  1. setContents 方法本身没有什么逻辑,仅仅是调用了 modify 方法而已
  2. 在传入 modify 方法的匿名函数中调用了 Editor 对象的 applyDelta 方法
  3. 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 的构造函数中被赋值的。

1 this.scroll = Parchment.create(this.root, {
2 emitter: this.emitter,
3 whitelist: this.options.formats
4 });
 

Scroll 对象是通过调用 Parchment 的 create 方法创建的。

前面两期我们简单介绍了 Quill 的数据模型 Delta,那么 Parchment 又是什么呢?它跟 Quill 和 Delta 是什么关系?这些疑问我们先不解答,留着后续详细讲解。

先来简单看下 create 方法是怎么创建 Scroll 对象的,create 方法最终是定义在 parchment 库源码中的 registry.ts 文件中的,就是一个普通的方法:

 1 export function create(input: Node | string | Scope, value?: any): Blot {
2 // 传入的 input 就是编辑器主体 DOM 元素(.ql-editor),里面包含了编辑器里所有可编辑的实际内容
3 // match 是通过 query 方法查询到的 Blot 类,这里就是 Scroll 类
4 let match = query(input);
5 if (match == null) {
6 throw new ParchmentError(`Unable to create ${input} blot`);
7 }
8 let BlotClass = <BlotConstructor>match;
9 let node = input instanceof Node || input['nodeType'] === Node.TEXT_NODE
10 ? input
11 : BlotClass.create(value);
12
13 // 最后返回 Scroll 对象
14 return new BlotClass(<Node>node, value);
15 }

 

create 方法的入参是编辑器主体 DOM 元素 .ql-editor,通过调用同文件中的 query 普通方法,查询到 Blot 类是 Scroll 类,查询的大致逻辑就是在一个 map 表里查,最后通过 new Scroll() 返回 Scroll 对象实例,赋值给 this.scroll。

1 {
2 ql-cursor: ƒ Cursor(domNode, selection),
3 ql-editor: ƒ Scroll(domNode, config), // 这个就是 Scroll 类
4 ql-formula: ƒ FormulaBlot(),
5 ql-syntax: ƒ SyntaxCodeBlock(),
6 ql-video: ƒ Video(),
7 }
 

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,负责总的调度)。

1 <div class="ql-editor" contenteditable="true">
2 <p>
3 Hello
4 <strong>World</strong>
5 </p>
6 ... // 其他编辑器内容
7 </div>
 

Scroll 类定义在 Quill 源码中的 blots/scroll.js 文件中,之前 applyDelta 方法中通过 this.scroll 调用的 insertAt / formatAt / deleteAt / update / batchStart / batchEnd / optimize 等方法都在 Scroll 类中。

以下是 Scroll 类的定义:

 1 class Scroll extends ScrollBlot {
2 constructor(domNode, config) {
3 super(domNode);
4 ...
5 }
6
7 // 标识批量更新的开始,此时执行 update / optimize 都不会进行实际的更新
8 batchStart() {
9 this.batch = true;
10 }
11
12 // 标识批量更新的结束
13 batchEnd() {
14 this.batch = false;
15 this.optimize();
16 }
17
18 // 在制定位置删除制定长度的内容
19 // 比如:deleteAt(6, 5) 将删除 "World"
20 // 在 Quill 的 API 中对应 deleteText(index, length, source) 方法
21 deleteAt(index, length) {}
22
23 // 设置编辑器的可编辑状态
24 enable(enabled = true) {
25 this.domNode.setAttribute('contenteditable', enabled);
26 }
27
28 // 在制定位置用制定格式格式化制定长度的内容
29 // 比如:formatAt(6, 5, 'bold', false) 将取消 "World" 的粗体格式
30 // 在 Quill 的 API 中对应 formatText(index, length, name, value, source) 方法 formatAt(index, length, format, value) {
31 if (this.whitelist != null && !this.whitelist[format]) return;
32 super.formatAt(index, length, format, value); this.optimize();
33 }
34
35 // 在制定位置插入内容
36 // 比如:insertAt(11, '\n你好,世界');
37 // 在 Quill 的 API 中对应 insertText(index, text, name, value, source)
38 // Quill 中的 insertText 其实是 Scroll 的 insertAt 和 formatAt 的复合方法
39 insertAt(index, value, def) {}
40
41 // 在某个 Blot 前面插入 Blot
42 insertBefore(blot, ref) {}
43
44 // 弹出当前位置 Blot 路径最外面的叶子 Blot(会改变原数组)
45 leaf(index) { return this.path(index).pop() || [null, -1]; }
46
47 // 实际上调用的是父类 ContainerBlot 的 descendant 方法
48 // 目的是得到当前位置所在的 Blot 对象
49 line(index) {
50 if (index === this.length()) {
51 return this.line(index - 1);
52 }
53 return this.descendant(isLine, index);
54 }
55
56 // 获取某一范围的 Blot 对象
57 lines(index = 0, length = Number.MAX_VALUE) {}
58
59 // TODO
60 optimize(mutations = [], context = {}) {
61 if (this.batch === true) return;
62 super.optimize(mutations, context);
63 if (mutations.length > 0) {
64 this.emitter.emit(Emitter.events.SCROLL_OPTIMIZE, mutations, context);
65 }
66 }
67
68 // 实际上调用的是父类 ContainerBlot 的 path 方法
69 // 目的是得到当前位置的 Blot 路径,并排除 Scroll 自己
70 // Blot 路径就和 DOM 节点路径是对应的
71 // 比如:DOM 节点路径 div.ql-editor -> p -> strong,
72 // 对应 Blot 路径就是 [[Scroll div.ql-editor, 0], [Block p, 0], [Bold strong, 6]]
73 path(index) {
74 return super.path(index).slice(1); // Exclude self
75 }
76
77 // TODO
78 update(mutations) {
79 if (this.batch === true) return;
80 ...
81 }
82 }
83
84 Scroll.blotName = 'scroll';
85 Scroll.className = 'ql-editor';
86 Scroll.tagName = 'DIV';
87 Scroll.defaultChild = 'block';
88 Scroll.allowedChildren = [Block, BlockEmbed, Container];
89
90 export default 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

现代富文本编辑器Quill的内容渲染机制的更多相关文章

  1. 现代富文本编辑器Quill的模块化机制

    DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师.官方网站:devui.designNg组件库:ng-devui(欢迎S ...

  2. 富文本编辑器Quill(一)简单介绍

    Quill是一个很流行的富文本编辑器,github上star大约21k: github:https://github.com/quilljs/quill/ 官网: https://quilljs.co ...

  3. 富文本编辑器...quill 的使用放...

    移动端 quill 时候用的 是 div 而不是 textarea.... 引入 dom <link href="//cdn.quilljs.com/1.3.6/quill.snow. ...

  4. 富文本编辑器粘贴word内容

    很多时候我们用一些管理系统的时候,发布新闻.公告等文字类信息时,希望能很快的将word里面的内容直接粘贴到富文本编辑器里面,然后发布出来.减少排版复杂的工作量. 下面是借用百度doc 来快速实现这个w ...

  5. 富文本编辑器Quill(二)上传图片与视频

    image与video在Quill formats中属于Embeds,要在富文本中插入图片或者视频需要使用insertEmbed api. insertEmbed insertEmbed(index: ...

  6. 富文本编辑器Quill的使用

    我们经常需要使用富文本编辑器从后台管理系统上传文字,图片等用于前台页面的显示,Quill在后台传值的时候需要传两个参数,一个用于后台管理系统编辑器的显示,一个用前台页面的显示,具体代码如下截图: 另Q ...

  7. 清空KindEditor富文本编辑器里面的内容方法

    //清空KindEditorKindEditor.instances[0].html(""); 0表示第一个KindEditor编辑器对象 详情见链接:http://www.new ...

  8. php 解析富文本编辑器中的hmtl内容,富文本样式正确输出

    说明:富文本编辑器中的内容在直接获获取后需要解析以后才能在页面中正确显示 我在后端这样处理: $content = htmlspecialchars_decode($info['intro']); h ...

  9. 百度Web富文本编辑器ueditor在ASP.NET MVC3项目中的使用说明

    ====================================================================== [百度Web富文本编辑器ueditor在ASP.NET M ...

随机推荐

  1. 微信小程序--基于ColorUI构建皮皮虾短视频去水印组件(仅供学习使用)

    微信小程序--基于ColorUI构建皮皮虾短视频去水印组件(仅供学习使用) 没错,我是皮友,我想学习舞蹈(/doge)和瑜伽 ,要无水印的那种有助于我加深学习. 1.组件效果展示 2.组件引入准备 h ...

  2. K8S的Kafka监控(Prometheus+Grafana)

    欢迎访问我的GitHub https://github.com/zq2599/blog_demos 内容:所有原创文章分类汇总及配套源码,涉及Java.Docker.Kubernetes.DevOPS ...

  3. sync_with_stdio(false)的副作用

    sync_with_stdio()的一个特性 水一篇随笔 其实对于用快读的大佬来说没什么用,但还是提一下 ios::sync_with_stdio(false)用处是"关闭同步", ...

  4. 你说一下对Java中的volatile的理解吧

    前言 volatile相关的知识其实自己一直都是有掌握的,能大概讲出一些知识,例如:它可以保证可见性:禁止指令重排.这两个特性张口就来,但要再往深了问,具体是如何实现这两个特性的,以及在什么场景下使用 ...

  5. 快快使用ModelArts,零基础小白也能玩转AI!

    摘要: 走过路过不要错过,看Copy攻城狮如何借力华为云ModelArts玩转AI. "自2018年10月发布以来,ModelArts累计服务了众多行业十几万开发者,通过基础平台的完备性和面 ...

  6. CF1295E Permutation Separation

    线段树 难得把E想出来,写出来,但却没有调出来(再给我5分钟),我的紫名啊,我一场上紫的大好机会啊 首先考虑是否能将$k$在$1$--$n-1$的每一个的最小代价都求出来 因为$k$从$i$到$i-1 ...

  7. 18FlaskRESTful

    一,虚拟环境下安装(win) 在安装这个环节发现了很多问题,pycharm里装了之后发现根本引用不了,查明原因是因为第三方库pycharm无法识别. 解决办法:进入虚拟环境直接pip. 二,基本使用 ...

  8. spring处理静态资源方式

    1. <mvc:default-servlet-handler/>default-servlet-handler在SpringMVC上下文定义一个org.springframework.w ...

  9. Js中函数声明和函数表达式的区别

    先看以下几段烧脑的代码: f();//=>? var f = function () { console.log("var"); } function f() { conso ...

  10. 极客mysql16

    1.MySQL会为每个线程分配一个内存(sort_buffer)用于排序该内存大小为sort_buffer_size 1>如果排序的数据量小于sort_buffer_size,排序将会在内存中完 ...