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']]
}
});
其中的modules参数就是用来配置模块的,toolbar参数用来配置工具栏模块,这里传入一个二维数组,表示分组后的工具栏按钮。渲染出来的编辑器里面将包含4个工具栏按钮: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;
}
...
}
可以看到Toolbar模块就是一个普通类,在constructor构造函数中传入了quill的实例和options配置,Toolbar类拿到quill实例就可以对编辑器进行控制和操作,比如Toolbar模块会根据options配置构造工具栏container,并将按钮/下拉框等元素填充到container中,添加ql-class类,绑定处理事件等。该模块的最终渲染结果就是在编辑器主体上方渲染了一个工具栏,可以通过里面的按钮/下拉框编辑器内的元素设置格式,或者插入新元素。那么Quill模块和Quill交互的方式是怎么样的,Quill实例是如何注入到Quill模块中的呢?这些问题我们后面会继续分析,先来看看Quill内置了一些什么模块吧。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开发富文本编辑器过程中,我们会遇到各种模块,也会创建很多自定义模块,所有模块都是通过modules参数进行配置的。后面会介绍Quill内部如何通过读取该配置进行模块的加载和渲染。模块加载机制
在研究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中
...
}
Quill在初始化时,会使用expandConfig方法对传入的options进行扩展,加入主题类等元素,用于初始化主题。之后调用主题实例的addModule方法获取到内置必需模块,将其挂载到Quill实例中,调用addModule方法还将使主题实例拿到该模块。最后调用主题实例的init方法将主题元素渲染到DOM,如果是snow主题,此时将会看到编辑器上方出现工具栏,如果是bubble主题,那么当选中一段文本时,会出现工具栏浮框。snow主题:bubble主题:模块加载的秘密就在与theme.init()方法,如刚才看到的,Quill初始化时会通过addModule方法加载3个内置必需模块,其他模块的加载都在init方法里,并且会将二者合在一起。我们以Toolbar模块为例,介绍Quill加载和渲染模块的原理:工具栏模块的加载
以snow主题为例,当初始化Quill实例时配置以下参数:
{
theme: 'snow',
modules: {
toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]
}
}
Quill的constructor方法中获取到的this.theme是SnowTheme类的实例,执行this.theme.init()方法时调用的是其父类Theme的init方法,该方法位于core/theme.js文件,它会遍历options.modules参数中的所有模块,并通过调用addModule方法将这些自定义模块的实例挂载到主题类的modules成员变量中(此时该成员变量已有内置必须模块的实例)。
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];
在addModule方法中会初始化Toolbar类,解析modules.toolbar参数,生成工具栏按钮和下拉框,并绑定工具栏事件。
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);
},
);
...
}
在init方法中执行addModule时,其实先执行的是BaseTheme的addModule方法,拿到Toolbar模块实例之后,会判断当前模块名是否是toolbar,如果是则执行SnowTheme的extendToolbar,这个方法位于themes/snow.js文件,它的作用是渲染工具栏按钮和下拉框的图标,以及绑定超链接事件,源码大致结构如下: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); }, ); }}
该方法先会依次调用buildButtons/buildPickers方法给工具栏按钮和下拉框加上图标元素,然后绑定超链接快捷键。Toolbar模块就这样被加载并渲染到富文本编辑器中,为编辑器操作提供便利。Toolbar模块的加载机制做一个小结: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 Counter
这是一个空类,什么都没有,只是在初始化方法中打印了Quill实例和模块的options配置信息。通过之前对Toolbar模块的分析,我们了解到在Quill的初始化过程中,会通过调用主题模块的init方法完成所有模块的渲染,其中会循环options.modules参数里的配置的所有模块,并将其渲染到编辑器。所以第二步是配置模块参数:modules: { toolbar: [ ['bold', 'italic'], ['link', 'image'] ], counter: true}
我们先不传配置数据,只是简单地将该模块启用起来,结果发现并没有打印信息。前面我们了解到在addModule的时候需要import该模块类,而要想让Quill import一个模块,需要在Quill初始化之前先调用Quill.register注册该类,并且由于我们需要扩展的是模块(module),所以前缀需要以modules开头:import Quill from 'quill';import Counter from './counter';Quill.register('modules/counter', 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}`; });}
在Counter模块的初始化方法中,我们调用Quill提供的addContainer,为编辑器增加一个空的容器,用于存放字数统计模块的位置,然后绑定编辑器的内容变更事件,这样当我们在编辑器中输入内容时,字数能实时统计。在Text Change事件中,我们调用Quill实例的getText方法获取编辑器内放入纯文本内容,然后用正则表达式将其中的空白字符去掉,最后将字数信息插入到字符统计的容器中。展示的大致效果如下: