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

引言

本文基于DevUI的富文本编辑器开发实践Quill源码写成。

EditorX是DevUI开发的一款好用、易用、功能强大的富文本编辑器,它的底层基于Quill,并对其做了大量扩展,以增强编辑器的能力。

Quill是一款API驱动、支持格式和模块定制的开源Web富文本编辑器,目前在Github的Star数超过25k

如果还没有接触过Quill,建议先去Quill官网了解下它的基本概念。

通过阅读本文,你将收获:

  1. 了解Quill模块是什么,怎么配置Quill模块
  2. 为什么要创建Quill模块,怎么创建自定义Quill模块
  3. Quill模块如何与Quill进行通信
  4. 深入了解Quill的模块化机制

Quill模块初探

使用Quill开发过富文本应用的人,应该都对Quill的模块有所了解。

比如,当我们需要定制自己的工具栏按钮时,会配置工具栏模块:

  1. 1 var quill = new Quill('#editor', {
  2. 2 theme: 'snow',
  3. 3 modules: {
  4. 4 toolbar: [['bold', 'italic'], ['link', 'image']]
  5. 5 }
  6. 6 });

其中的modules参数就是用来配置模块的。

toolbar参数用来配置工具栏模块,这里传入一个二维数组,表示分组后的工具栏按钮。

渲染出来的编辑器将包含4个工具栏按钮:

要看以上Demo,请怒戳配置工具栏模块

Quill模块是一个普通的JS类

那么Quill模块是什么呢?我们为什么要了解和使用Quill模块呢?

Quill模块其实就是一个普通的JavaScript类,有构造函数,有成员变量,有方法。

以下是工具栏模块的大致源码结构:

  1. 1 class Toolbar {
  2. 2 constructor(quill, options) {
  3. 3 // 解析传入模块的工具栏配置(就是前面介绍的二维数组),并渲染工具栏
  4. 4 }
  5. 5
  6. 6 addHandler(format, handler) {
  7. 7 this.handlers[format] = handler;
  8. 8 }
  9. 9 ...
  10. 10 }

可以看到工具栏模块就是一个普通的JS类。在构造函数中传入了quill的实例和options配置,模块类拿到quill实例就可以对编辑器进行控制和操作。

比如:工具栏模块会根据options配置构造工具栏容器,将按钮/下拉框等元素填充到该容器中,并绑定按钮/下拉框的处理事件。最终的结果就是在编辑器主体上方渲染了一个工具栏,可以通过工具栏按钮/下拉框给编辑器内的元素设置格式,或者在编辑器中插入新元素。

Quill模块的功能很强大,我们可以利用它来扩展编辑器的能力,实现我们想要的功能。

除了工具栏模块之外,Quill还内置了一些很实用的模块,我们一起来看看吧。

Quill内置模块

Quill一共内置6个模块:

  1. Clipboard 粘贴版
  2. History 操作历史
  3. Keyboard 键盘事件
  4. Syntax 语法高亮
  5. Toolbar 工具栏
  6. Uploader 文件上传

Clipboard、History、Keyboard是Quill必需的内置模块,会自动开启,可以配置但不能取消。其中:

Clipboard模块用于处理复制/粘贴事件、HTML元素节点的匹配以及HTML到Delta的转换。

History模块维护了一个操作的堆栈,记录了每一次的编辑器操作,比如插入/删除内容、格式化内容等,可以方便地实现撤销/重做等功能。

Keyboard模块用于配置键盘事件,为实现快捷键提供便利。

Syntax模块用于代码语法高亮,它依赖外部库highlight.js,默认关闭,要使用语法高亮功能,必须安装highlight.js,并手动开启该功能。

其他模块不多做介绍,想了解可以参考Quill的模块文档

Quill模块的配置

刚才提到Keyboard键盘事件模块,我们再举一个例子,加深对Quill模块配置的理解。

Keyboard模块默认支持很多快捷键,比如:

  1. 加粗的快捷键是Ctrl+B;
  2. 超链接的快捷键是Ctrl+K;
  3. 撤销/回退的快捷键是Ctrl+Z/Y。

但它不支持删除线的快捷键,如果我们想定制删除线的快捷键,假设是Ctrl+Shift+S,我们可以这样配置:

  1. 1 modules: {
  2. 2 keyboard: {
  3. 3 bindings: {
  4. 4 strike: {
  5. 5 key: 'S',
  6. 6 ctrlKey: true,
  7. 7 shiftKey: true,
  8. 8 handler: function(range, context) {
  9. 9 const format = this.quill.getFormat(range);
  10. 10 this.quill.format('strike', !format.strike);
  11. 11 }
  12. 12 },
  13. 13 }
  14. 14 },
  15. 15 toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]
  16. 16 }

要看以上Demo,请怒戳配置键盘模块

在使用Quill开发富文本编辑器的过程中,我们会遇到各种模块,也会创建很多自定义模块,所有模块都是通过modules参数进行配置的。

接下来我们将尝试创建一个自定义模块,加深对Quill模块和模块配置的理解。

创建自定义模块

通过上一节的介绍,我们了解到其实Quill模块就是一个普通的JS类,并没有什么特殊的,在该类的初始化参数中会传入Quill实例和该模块的options配置参数,然后就可以控制并增强编辑器的功能。

当Quill内置模块无法满足我们的需求时,就需要创建自定义模块来实现我们想要的功能。

比如:在EditorX富文本组件中有一个统计编辑器当前字数的功能,该功能就是通过自定义模块来实现的,下面我们将一步一步介绍如何将改该功能封装成独立的Counter模块。

创建一个Quill模块分三步:

第一步:创建模块类

新建一个JS文件,里面是一个普通的JavaScript类。

  1. 1 class Counter {
  2. 2 constructor(quill, options) {
  3. 3 console.log('quill:', quill);
  4. 4 console.log('options:', options);
  5. 5 }
  6. 6 }
  7. 7
  8. 8 export default Counter;

这是一个空类,什么都没有,只是在初始化方法中打印了Quill实例和模块的options配置信息。

第二步:配置模块参数

  1. 1 modules: {
  2. 2 toolbar: [
  3. 3 ['bold', 'italic'],
  4. 4 ['link', 'image']
  5. 5 ],
  6. 6 counter: true
  7. 7 }

我们先不传配置数据,只是简单地将该模块启用起来,结果发现并没有打印信息。

第三步:注册模块

要使用一个模块,需要在Quill初始化之前先调用Quill.register方法注册该模块类(后面我们详细介绍其中的原理),并且由于我们需要扩展的是模块(module),所以前缀需要以modules开头:

  1. 1 import Quill from 'quill';
  2. 2 import Counter from './counter';
  3. 3 Quill.register('modules/counter', Counter);

这时我们能看到信息已经打印出来。

添加模块的逻辑

这时我们在Counter模块中加点逻辑,用于统计当前编辑器内容的字数:

  1. 1 constructor(quill, options) {
  2. 2 this.container = quill.addContainer('ql-counter');
  3. 3 quill.on(Quill.events.TEXT_CHANGE, () => {
  4. 4 const text = quill.getText(); // 获取编辑器中的纯文本内容
  5. 5 const char = text.replace(/\s/g, ''); // 使用正则表达式将空白字符去掉
  6. 6 this.container.innerHTML = `当前字数:${char.length}`;
  7. 7 });
  8. 8 }

在Counter模块的初始化方法中,我们调用Quill提供的addContainer方法,为编辑器增加一个空的容器,用于存放字数统计模块的内容,然后绑定编辑器的内容变更事件,这样当我们在编辑器中输入内容时,字数能实时统计。

在Text Change事件中,我们调用Quill实例的getText方法获取编辑器里的纯文本内容,然后用正则表达式将其中的空白字符去掉,最后将字数信息插入到字符统计的容器中。

展示的大致效果如下:

要看以上Demo,请怒戳自定义字符统计模块

模块加载机制

对Quill模块有了初步的理解之后,我们就会想知道Quill模块是如何运作的,下面将从Quill的初始化过程切入,通过工具栏模块的例子,深入探讨Quill的模块加载机制。(本小结涉及Quill源码的解析,有不懂的地方欢迎留言讨论)

Quill类的初始化

当我们执行new Quill()的时候,会执行Quill类的constructor方法,该方法位于Quill源码的core/quill.js文件中。

初始化方法的大致源码结构如下(移除模块加载无关的代码):

  1. 1 constructor(container, options = {}) {
  2. 2 this.options = expandConfig(container, options); // 扩展配置数据,包括增加主题类等
  3. 3 ...
  4. 4 this.theme = new this.options.theme(this, this.options); // 1.使用options中的主题类初始化主题实例
  5. 5
  6. 6 // 2.增加必需模块
  7. 7 this.keyboard = this.theme.addModule('keyboard');
  8. 8 this.clipboard = this.theme.addModule('clipboard');
  9. 9 this.history = this.theme.addModule('history');
  10. 10
  11. 11 this.theme.init(); // 3.初始化主题,这个方法是模块渲染的核心(实际的核心是其中调用的addModule方法),会遍历配置的所有模块类,并将它们渲染到DOM中
  12. 12 ...
  13. 13 }

Quill在初始化时,会使用expandConfig方法对传入的options进行扩展,加入主题类等元素,用于初始化主题。(不配置主题也会有默认的BaseTheme主题)

之后调用主题实例的addModule方法将内置必需模块挂载到主题实例中。

最后调用主题实例的init方法将所有模块渲染到DOM。(后面会详细介绍其中的原理)

如果是snow主题,此时将会看到编辑器上方出现工具栏:

如果是bubble主题,那么当选中一段文本时,会出现工具栏浮框:

接下来我们以工具栏模块为例,详细介绍Quill模块的加载和渲染原理。

工具栏模块的加载

以snow主题为例,当初始化Quill实例时配置以下参数:

  1. 1 {
  2. 2 theme: 'snow',
  3. 3 modules: {
  4. 4 toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]
  5. 5 }
  6. 6 }

Quill的constructor方法中获取到的this.theme是SnowTheme类的实例,执行this.theme.init()方法时调用的是其父类Theme的init方法,该方法位于core/theme.js文件。

  1. 1 init() {
  2. 2 // 遍历Quill options中的modules参数,将所有用户配置的modules挂载到主题类中
  3. 3 Object.keys(this.options.modules).forEach(name => {
  4. 4 if (this.modules[name] == null) {
  5. 5 this.addModule(name);
  6. 6 }
  7. 7 });
  8. 8 }

它会遍历options.modules参数中的所有模块,调用BaseTheme的addModule方法,该方法位于themes/base.js文件。

  1. 1 addModule(name) {
  2. 2 const module = super.addModule(name);
  3. 3 if (name === 'toolbar') {
  4. 4 this.extendToolbar(module);
  5. 5 }
  6. 6 return module;
  7. 7 }

该方法会先执行其父类的addModule方法,将所有模块初始化,如果是工具栏模块,则会在工具栏模块初始化之后对工具栏模块进行额外的处理,主要是构建icons和绑定超链接快捷键。

我们再回过头来看下BaseTheme的addModule方法,该方法是模块加载的核心

该方法前面我们介绍Quill的初始化时已经见过,加载三个内置必需模块时调用过。其实所有模块的加载都会经过该方法,因此有必要研究下这个方法,该方法位于core/theme.js

  1. 1 addModule(name) {
  2. 2 const ModuleClass = this.quill.constructor.import(`modules/${name}`); // 导入模块类,创建自定义模块的时候需要通过Quill.register方法将类注册到Quill,才能导入
  3. 3 // 初始化模块类
  4. 4 this.modules[name] = new ModuleClass(
  5. 5 this.quill,
  6. 6 this.options.modules[name] || {},
  7. 7 );
  8. 8 return this.modules[name];
  9. 9 }

addModule方法会先调用Quill.import方法导入模块类(通过Quill.register方法注册过的才能导入)。

然后初始化该类,将其实例挂载到主题类的modules成员变量中(此时该成员变量已有内置必须模块的实例)。

以工具栏模块为例,在addModule方法中初始化的是Toolbar类,该类位于modules/toolbar.js文件。

  1. 1 class Toolbar {
  2. 2 constructor(quill, options) {
  3. 3 super(quill, options);
  4. 4
  5. 5 // 解析modules.toolbar参数,生成工具栏结构
  6. 6 if (Array.isArray(this.options.container)) {
  7. 7 const container = document.createElement('div');
  8. 8 addControls(container, this.options.container);
  9. 9 quill.container.parentNode.insertBefore(container, quill.container);
  10. 10 this.container = container;
  11. 11 } else {
  12. 12 ...
  13. 13 }
  14. 14
  15. 15 this.container.classList.add('ql-toolbar');
  16. 16
  17. 17 // 绑定工具栏事件
  18. 18 this.controls = [];
  19. 19 this.handlers = {};
  20. 20 Object.keys(this.options.handlers).forEach(format => {
  21. 21 this.addHandler(format, this.options.handlers[format]);
  22. 22 });
  23. 23 Array.from(this.container.querySelectorAll('button, select')).forEach(
  24. 24 input => {
  25. 25 this.attach(input);
  26. 26 },
  27. 27 );
  28. 28 ...
  29. 29 }
  30. 30 }

工具栏模块初始化时会先解析modules.toolbar参数,调用addControls方法生成工具栏按钮和下拉框(基本原理就是遍历一个二维数组,将它们以按钮/下拉框形式插入到工具栏中),并为它们绑定事件。

  1. 1 function addControls(container, groups) {
  2. 2 if (!Array.isArray(groups[0])) {
  3. 3 groups = [groups];
  4. 4 }
  5. 5 groups.forEach(controls => {
  6. 6 const group = document.createElement('span');
  7. 7 group.classList.add('ql-formats');
  8. 8 controls.forEach(control => {
  9. 9 if (typeof control === 'string') {
  10. 10 addButton(group, control);
  11. 11 } else {
  12. 12 const format = Object.keys(control)[0];
  13. 13 const value = control[format];
  14. 14 if (Array.isArray(value)) {
  15. 15 addSelect(group, format, value);
  16. 16 } else {
  17. 17 addButton(group, format, value);
  18. 18 }
  19. 19 }
  20. 20 });
  21. 21 container.appendChild(group);
  22. 22 });
  23. 23 }

工具栏模块就这样被加载并渲染到富文本编辑器中,为编辑器操作提供便利。

现在对模块的加载过程做一个小结:

  1. 模块加载的起点是Theme类的init方法,该方法将option.modules参数里配置的所有模块加载到主题类的成员变量modules中,并与内置必需模块合并;
  2. addModule方法会先通过import方法导入模块类,然后通过new关键字创建模块实例;
  3. 创建模块实例时会执行模块的初始化方法,执行模块的具体逻辑。

以下是模块与编辑器实例的关系图:

总结

本文先通过2个例子简单介绍了Quill模块的配置方法,让大家对Quill模块有个直观初步的印象。

然后通过字符统计模块这个简单的例子介绍如何开发自定义Quill模块,对富文本编辑器的功能进行扩展。

最后通过剖析Quill的初始化过程,逐步切入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. 富文本编辑器Quill(二)上传图片与视频

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

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

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

  6. react-quill 富文本编辑器

    适合react的一款轻量级富文本编辑器 1.http://blog.csdn.net/xiaoxiao23333/article/details/62055128 (推荐一款Markdown富文本编辑 ...

  7. Quill – 可以灵活自定义的开源的富文本编辑器

    Quill 的建立是为了解决现有的所见即所得(WYSIWYG)的编辑器本身就是所见即所得(指不能再扩张)的问题.如果编辑器不正是你想要的方式,这是很难或不可能对其进行自定义以满足您的需求. Quill ...

  8. Quill 富文本编辑器

    Quill 富文本编辑器 https://quilljs.com/ https://github.com/quilljs/quill https://github.com/quilljs/awesom ...

  9. Vue整合Quill富文本编辑器

    Quill介绍 Quill是一款开源的富文本编辑器,基于可扩展的架构设计,提供丰富的 API 进行定制.截止2021年1月,在github上面已有28.8k的star. Quill项目地址:https ...

随机推荐

  1. 应用LORAWAN技术的好处是什么

    LoRaWAN现在一种非常流行的LPWA通信标准,在ISM(工业.科学.医疗)频段使用未经许可的无线电频谱,频率约为900MHz到430MHz(世界各地的标准各不相同). 物联网连接环境除了智能家庭联 ...

  2. JWT原理

    1.COOKIE使用和优缺点 https://www.cnblogs.com/xiaonq/p/11094480.html   1.1 cookie原理: 用户名+密码 cookie是保存在用户浏览器 ...

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

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

  4. 不停机不更新代码线上调试BUG的工具

    如果你有以下痛点,请你查看本文章: 1.我改的代码为什么没有执行到?难道是我没 commit?分支搞错了? 2.遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗? 3.线上遇到某个用户的 ...

  5. c++11-17 模板核心知识(三)—— 非类型模板参数 Nontype Template Parameters

    类模板的非类型模板参数 函数模板的非类型模板参数 限制 使用auto推断非类型模板参数 模板参数不一定非得是类型,它们还可以是普通的数值.我们仍然使用前面文章的Stack的例子. 类模板的非类型模板参 ...

  6. 在嵌入式设备中实现webrtc的第三种方式③

    本系列的最后一篇,讲解收发音视频数据. 贴出最终效果: 其实很简单,直接调用writeFrame即可,如下图: 当然,这是部分代码,完整代码在下面,展开可见: 1 #include "com ...

  7. fidder 学习

    前提 你要清楚下面两个问题的答案 1.接口是什么? 2.抓包是什么? 在提一嘴 想要获取手机上的时时请求 首先要把手机和电脑连接同一个网络 也就是在同一频道上 开始 1.安装 Fidder Every ...

  8. leetcode两数之和go语言

    两数之和(Go语言) 给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标. 你可以假设每种输入只会对应一个答案.但是,你不能重复 ...

  9. 想更改Github仓库中的某个文件结构

    虽然有各种版本回退啥的,可是感觉好麻烦,还是没搞来,后来发现可以直接先删除,然后在本地更改,更改完之后重新添加一次即可 删除远程库的某个文件: $ git pull origin master 将远程 ...

  10. 理解js参数

    <!DOCTYPE html><html><head> <meta charset="utf-8" /> <title> ...