
 DevUI技术体验部是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部上百个中后台系统,主打产品 DevUI Design 服务于设计师和前端工程师。官方网站:devui.design。Ng组件库:ng-devui。DevUI Design:https://devui.design/homeNg组件库:ng-devui:https://github.com/DevCloudFE/ng-devui
DevUI技术体验部是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部上百个中后台系统,主打产品 DevUI Design 服务于设计师和前端工程师。官方网站:devui.design。Ng组件库:ng-devui。DevUI Design:https://devui.design/homeNg组件库:ng-devui:https://github.com/DevCloudFE/ng-devui
引言
EditorX是DevUI开发的一款好用、易用、功能强大的富文本编辑器,它的底层基于Quill,一款API驱动、支持格式和模块定制的开源Web富文本编辑器,目前在Github的Star数超过25k。如果还没有接触过Quill,建议先去官网了解下Quill的基本概念。本文将结合DevUI的实践,先简单介绍什么是Quill模块,怎么配置Quill模块;然后重点分析Quill模块的执行机制,Quill模块和Quill通信的方式;最后通过实例讲解如何创建自定义的Quill模块,以扩展编辑器的能力。
Quill模块初探
使用Quill开发过富文本应用的人,应该都对Quill的模块有所了解。比如,当我们需要定制自己的工具栏按钮时,会配置toolbar模块:var quill = new Quill('#editor', {  theme: 'snow',  modules: {    toolbar: [['bold', 'italic'], ['link', 'image']]  }});

Quill模块就是一个普通的JavaScript类
那么模块是什么呢?模块其实就是一个普通的JavaScript类,有构造函数,有类成员变量,有类方法,以下是Toolbar模块的大致源码结构:class Toolbar {  constructor(quill, options) {    super(quill, options);    if (Array.isArray(this.options.container)) {      const container = document.createElement('div');      addControls(container, this.options.container);      quill.container.parentNode.insertBefore(container, quill.container);      this.container = container;    } else {      ...    }    this.container.classList.add('ql-toolbar');    this.controls = [];    this.handlers = {};    Object.keys(this.options.handlers).forEach(format => {      this.addHandler(format, this.options.handlers[format]);    });    Array.from(this.container.querySelectorAll('button, select')).forEach(      input => {       this.attach(input);      },    );    ...  }  addHandler(format, handler) {    this.handlers[format] = handler;  }  ...}
Quill内置模块
Quill一共内置6个模块:- Clipboard 粘贴版 
- History 操作历史 
- Keyboard 键盘事件 
- Syntax 语法高亮 
- Toolbar 工具栏 
- Uploader 文件上传 

Quill模块的配置
刚才提到Keyboard键盘事件模块,该模块默认支持很多快捷键,比如加粗的快捷键是Ctrl+B,超链接的快捷键是Ctrl+K,但它不支持删除线的快捷键,如果我们想定制删除线的快捷键,假设是Ctrl+Shift+S,我们可以这样配置:modules: {  keyboard: {    bindings: {      strike: {        key: 'S',        ctrlKey: true,        shiftKey: true,        handler: function(range, context) {          const format = this.quill.getFormat(range);          this.quill.format('strike', !format.strike);        }      },    }  },  toolbar: [['bold', 'italic'], ['link', 'image']]}
模块加载机制
在研究Quill的模块加载机制之前,有必要对Quill的初始化过程做一个简单的介绍。Quill类的初始化
当我们执行new Quill()的时候,其实执行的是Quill类的constructor方法,该方法位于Quill源码的core/quill.js文件中。初始化方法的大致源码结构如下(移除模块加载无关的代码):constructor(container, options = {}) {  this.options = expandConfig(container, options); // 扩展配置数据,包括增加主题类  ...  this.theme = new this.options.theme(this, this.options); // 使用options中的主题类初始化主题实例  // 增加必需的模块  this.keyboard = this.theme.addModule('keyboard');  this.clipboard = this.theme.addModule('clipboard');  this.history = this.theme.addModule('history');  this.theme.init(); // 初始化主题,将主题元素渲染到DOM中  ...} bubble主题:
bubble主题: 模块加载的秘密就在与theme.init()方法,如刚才看到的,Quill初始化时会通过addModule方法加载3个内置必需模块,其他模块的加载都在init方法里,并且会将二者合在一起。我们以Toolbar模块为例,介绍Quill加载和渲染模块的原理:
模块加载的秘密就在与theme.init()方法,如刚才看到的,Quill初始化时会通过addModule方法加载3个内置必需模块,其他模块的加载都在init方法里,并且会将二者合在一起。我们以Toolbar模块为例,介绍Quill加载和渲染模块的原理:工具栏模块的加载
以snow主题为例,当初始化Quill实例时配置以下参数:{  theme: 'snow',  modules: {    toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]  }}init() {  // 循环Quill options中的modules参数,将所有用户配置的modules挂载到主题类中  Object.keys(this.options.modules).forEach(name => {    if (this.modules[name] == null) {      this.addModule(name);    }  });}addModule(name) {  const ModuleClass = this.quill.constructor.import(`modules/${name}`); // 导入模块类,创建自定义模块的时候需要通过Quill.register方法将类注册到Quill,才能导入  // 初始化模块类  this.modules[name] = new ModuleClass(    this.quill,    this.options.modules[name] || {},  );  return this.modules[name];constructor(quill, options) {  super(quill, options);  // 解析modules.toolbar参数,生成工具栏结构  if (Array.isArray(this.options.container)) {    const container = document.createElement('div');    addControls(container, this.options.container);    quill.container.parentNode.insertBefore(container, quill.container);    this.container = container;  } else {    ...  }  this.container.classList.add('ql-toolbar');  this.controls = [];  this.handlers = {};  Object.keys(this.options.handlers).forEach(format => {    this.addHandler(format, this.options.handlers[format]);  });  // 绑定工具栏事件  Array.from(this.container.querySelectorAll('button, select')).forEach(    input => {      this.attach(input);    },  );  ...}extendToolbar(toolbar) {  toolbar.container.classList.add('ql-snow'); // 增加snow主题的css class  this.buildButtons(toolbar.container.querySelectorAll('button'), icons); // 创建并渲染工具栏按钮图标  this.buildPickers(toolbar.container.querySelectorAll('select'), icons); // 创建并渲染工具栏下拉框图标  this.tooltip = new SnowTooltip(this.quill, this.options.bounds);  // 绑定超链接快捷键  if (toolbar.container.querySelector('.ql-link')) {    this.quill.keyboard.addBinding(      { key: 'k', shortKey: true },      (range, context) => {        toolbar.handlers.link.call(toolbar, !context.format.link);      },    );  }}- Quill初始化时会通过addModule方法,将内置必需模块加载到主题类; 
- Quill初始化时会执行theme的init方法,并将option.modules参数里配置的所有模块记载到主题类的成员变量modules中,与内置必需模块合并; 
- addModule方法会先通过import方法导入模块类,然后通过new关键字创建模块实例; 
- 创建模块实例时会执行模块的初始化方法,Toolbar模块在初始化方法中根据toolbar配置信息构建了工具栏的结构,并填充按钮/下拉框,绑定它们的事件处理函数; 
- Toolbar模块在调用theme的addModule方法之前会先调用BaseTheme的addModule方法,判断是工具栏模块,则会为其添加图标,此外,超链接的快捷键事件也是在BaseTheme的addModule方法绑定的。 
 所有模块都是类似的机制,接下来我们要讲解的内容——创建自定义模块——将会用到上面提到的信息。
所有模块都是类似的机制,接下来我们要讲解的内容——创建自定义模块——将会用到上面提到的信息。
创建自定义模块
通过上一节Toolbar模块加载机制的介绍,我们了解到其实工具栏模块就是一个普通的JavaScript类,并没有什么特殊的,在该类的初始化参数中会传入Quill实例和该模块的options配置参数,然后就可以控制并增强编辑器的功能。现在我们尝试自己创建一个Quill模块,比如我们希望统计编辑器当前的字数,这就可以做成一个简单的Quill模块。创建Quill模块的第一步是新建一个TS文件,里面是一个普通的Javascript类:class Counter {  constructor(quill, options) {    console.log('quill:', quill);    console.log('options:', options);  }}export default Countermodules: {  toolbar: [    ['bold', 'italic'],    ['link', 'image']  ],  counter: true}import Quill from 'quill';import Counter from './counter';Quill.register('modules/counter', Counter);constructor(quill, options) {  this.container = quill.addContainer('ql-counter');  quill.on(Quill.events.TEXT_CHANGE, () => {    const text = quill.getText(); // 获取编辑器中的纯文本内容    const char = text.replace(/\s/g, ''); // 使用正则表达式将空白字符去掉    this.container.innerHTML = `当前字数:${char.length}`;  });}

总结
本文先通过2个例子简单介绍了Quill模块的配置方法,让读者对Quill的模块有个直观初步的印象。然后通过剖析Quill的初始化过程,逐步切入Quill模块的加载机制,并详细阐述了工具栏模块的加载过程。最后通过字符统计模块的例子介绍如何开发自己的Quill模块,对富文本的功能进行扩展。
加入我们
我们是DevUI技术体验部,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:zenglingka@huawei.com。 ↓点击
↓点击