概要
Quill.js 是一个借助 contenteditable
+ MutationObserver
实现的 API 驱动的富文本编辑器。
有以下特性:
- 为开发者构建,细粒度访问、更新文本内容,一致的的 JSON 格式输入输出
- 跨平台。所有现代浏览器,在手机端、平板、桌面
- 伸缩性强。大小项目都可简单使用,可通过自定义扩展丰富富文本功能。
架构
Quill.js 架构清晰,核心功能简介:
- Delta:富文本数据层,负责描述数据
- Parchment:富文本控制器层,负责操控 dom,将数据变化映射到 UI 表示
- Formats :Quill支持多种格式,包括UI控件和API调用
- Module:模块化形式,负责解耦功能项,管控模块加载
模块机制
Quill 的模块注册全局唯一,通过键值对保存模块名称与定义。
我们可以调试,查看已注册的模块:
- 审查元素找到编辑器容器类名为
ql-container
的元素并选中 - 控制台执行
$0.__quill.constructor.imports
,ps:$0
为当前选中的元素
可以看到注册的内容格式有一些规则:
- 核心内置模块:
delta
parchment
这些直接用包名替代了 blots/
前缀模块:控制器元素,同时也会注册在 parchment 全局环境下,可通过Parchment.query(balabala)
查到到
attributors/
前缀模块:简化对 DOM 属性更新的工具,同时会注册到Parchment
,统一接管formats/
前缀模块:功效和 blots + attributors 一致,为了与菜单栏操作有所对应moduels/
前缀模块:功能集合,包括内置的剪切板、历史记录、快捷键等核心模块,这里也同样适合作为扩展自定义模块的切入点
模块通过调用 Quill.register()
注册,会保存在 Quill.imports
变量上,比较特殊的 blots/
formats/
前缀会同时注册到 Parchment
(用于管理 blot)上,blot
之于富文本就相当于 DOM
之于 web。
API 驱动
我们一起通过案例了解 Quill.js 的定位: “API 驱动的富文本编辑器”。
以 加粗
功能为例,将 [0, 5) 范围内的字符进行加粗,让我们大概了解下 quill 的执行流程。
一种方式是命令式调用:
quill.formatText(0, 5, 'bold', true);
执行流程:
- 定位选区的范围
- 确定编辑器 formatText,开始、结束位置,格式化方式
- 遍历选区子节点,这里我们先假设最简单场景:无格式化文本
- 创建 bold blot,并将文本 Leaf 节点插入到 bold 节点内
- 替代 bold blot 到选区的元素位置
- 计算 Delta 最新数据,触发编辑器更新事件
这种触发更新的方式是先更新 dom,再更新 delta 数据。
另一种调用方式:updateContents
。
quill.updateContents(new Delta().retain(5, { bold: true }));
执行流程:
- 遍历 delta 操作项
- 发现 attributes 有内容,执行 formatAt 方法
- 节点更新操作类似上述 3,4,5 流程,不重复赘述
- 遍历 delta 删除项(示例这一步不会发生)
- 触发编辑器更新事件
扩展实战
模块扩展
模块可以作为功能扩展的集合,Quill 中的模块、Blot 扩展都通过面向对象的编程方式实现。模块的 hook 包括:
static register
:会在Quill.register
执行时调用constructor
:在实例化时调用,例如:
new Quill({
modules: {
demo: {
// demo module options
}
}
})
class DemoModule<T = any> {
static register() {
// 这里可以注册 blot
Quill.register(ImageBlot);
}
quill: Quill;
options: T;
constructor(quill, options: T = {} as any) {
this.quill = quill;
this.options = options;
// 可以在这里扩展内置模块
// 例如:增加剪切板匹配、增加快捷键
this.addClipboardMatcher();
}
/**
* 挂载剪切板控制
*/
private addClipboardMatcher() {
this.quill.clipboard.addMatcher('IMG', (node: HTMLImageElement, delta: Delta) => {
// 这里可以对剪切板中的图片进行处理
// 例如替换使用图片上传组件
});
}
}
Blot 扩展
要自定义编辑器中的板块,那就需要用到 Blot:文档展示的基本单元。《富文本编辑器 Quill.js 系列二:Parchment - 文档模型》中,简述了 Blot 的生命周期,这里就派上用场了。
假设我们想要使用 React 渲染 Blot,可以这么做:
const Parchment = Quill.import('parchment');
// 内联嵌入式 Blot,ps: 块级可以使用 Quill.import('blots/block/embed')
const { Embed } = Parchment;
class RcBlot<Props = {}> extends Embed {
static blotName = 'demo-blot';
static tagName = 'demo-blot';
// blot hook: 创建时调用
static create(value: Record<string, string>) {
const node: HTMLElement = super.create(value);
// 禁用编辑,交由 React 接管
node.contentEditable = 'false';
// 使用 dataset 保存组件 props
Object.assign(node.dataset, value);
return node;
}
// blot hook: 获取 value
static value(domNode: HTMLElement) {
return { ...domNode.dataset };
}
// blot hook: delta format
format(name: string, value: string) {
this.domNode.dataset[name] = value;
this.render();
}
// blot hook: 组件挂载
attach() {
super.attach();
this.render();
}
// blot hook: 组件卸载
detach() {
super.detach();
ReactDOM.unmountComponentAtNode(this.domNode!);
}
// 外部操作更新
setState(value: Partial<Props>) {
Object.assign(this.domNode.dataset, value);
this.render();
}
private getReactComponent(): ComponentType<any> {
// 这里可以置顶 React 组件
return Fragment;
}
// 获取当前 blot 所在编辑器 Quill 实例
private getQuill() {
return Quill.find(this.scroll.domNode.parentElement);
}
// render React component
private render(appendProps?: Partial<Props>) {
const ReactComponent = this.getReactComponent();
const quill = this.getQuill();
ReactDOM.render(
<ReactComponent {...RcBlot.value(this.domNode)} {...appendProps} />,
this.domNode!,
);
}
}
注册 blot,通过 Quill.register(RcBlot)
进行注册,blotName 会用作 Parchment 注册中心的唯一标识,tagName 会用作创建 DOM。
通过 formt hook
和扩展的 setState
,可以做到 quill 与 React 组件双向的同步。前者用于接收 updateContents
引发的变更进而同步 react 组件;后者用于组件内部引发的状态变更,将数据同步到 quill 中。
借助 React,我们可以极大地简化 并 复用现有 UI 组件。
图片插入实战
假设我们需要新增一个图片模块,支持上传、编辑尺寸、删除、粘贴适配等功能,功能还算比较全。
借助于上述的 Blot React 扩展功能,我们可以快速开发图片组件、上传组件。这里把 2 者区分开来,主要是因为便于 delta 数据的区分,总不能把上传状态中的信息,保存到数据库吧。
以下是 ImageBlot
ImageUploadBlot
实现:
// RcBlot 实现见上文
class ImageBlot extends RcBlot {
static blotName = 'qh-image';
static tagName = 'qh-image';
render() {
super.render({
onResize: (width, height) => {
this.setState({
width,
height,
});
},
});
}
getReactComponent() {
// Image 是 React 组件,接收 src,width,height,onResize 等属性
// 实现忽略,具体脑补吧,比较简单
return Image;
}
}
class ImageUploadBlot extends RcBlot {
static blotName = 'qh-image-upload';
static tagName = 'qh-image-upload';
getReactComponent() {
// ImageUpload 接收 status,previewUrl,width,height,percent,error 等属性
// 实现忽略,具体脑补吧,比较简单
return ImageUpload;
}
}
实现 Blot 后,需要使用 Module 进行包裹,方便使用方接入。
class ImageModule {
static register() {
// blot 注册
Quill.register(ImageUploadBlot);
Quill.register(ImageBlot);
}
quill: Quill;
options: {
// 上传交由使用方实现
request: any;
};
constructor(quill: Quill, options: IImageModuleOptions) {
super(quill, options);
if (!options.request) {
throw new Error('options request is required');
}
// 限于篇幅,粘贴、拖放略...
quill.root.addEventListener('drop', this.handleDrop, false);
quill.root.addEventListener('paste', this.handlePaste, false);
this.addClipboardMatcher();
}
private addClipboardMatcher() {
// 替换剪切板中的图片为自定义 ImageUploadBlot,并且可自行控制上传到自有云服务
this.quill.clipboard.addMatcher('IMG', (node: HTMLImageElement, delta: Delta) => {
return new Delta(
delta.map((op) => {
if (typeof op.insert === 'object' && typeof op.insert?.image === 'string') {
return {
insert: {
[ImageUploadBlot.blotName]: {
status: 'uploading',
percent: 0,
previewUrl: op.insert.image,
width: 200,
height: 100,
},
},
};
}
return op;
})
);
});
}
}
实现图片插入上传流程:
quill.getSelection()
获取选区- 使用
URL.createObjectURL
加载图片获取图片元信息 - 使用
quill.insertEmbed
插入ImageUploadBlot
- 调用
options.request
上传图片 - 监听上传回调使用
quill.updateContents
更新upload blot
状态 - 上传成功删除
upload blot
,插入ImageBlot
,流程结束
总结
通过上文我们了解到 Quill.js 的基本概念与功能,清晰合理的架构,以及偏内部实现相关的 模块注册机制、常用的 2 个 API 执行流程。
通过模块扩展、Blot 扩展的示例,以及图片插入实战,结合代码演示,丰富了 Quill.js 应用部分的展示。
Quill.js 的优点有很多:架构清晰、分层合理,源码实现上也比较简洁,Delta 的数据操作且能适应多人协作。
笔者认为缺点也是有的:
- 官方文档一般,使用示例,如何扩展还是比较欠缺的;
- 上手困难,如果只是用最基础的 Demo 那这条忽略,假如涉及到富文本的扩展,大概率还是要去阅读源码的;
- 模块的注册部分实现是全局的,假设想要实现同名 blot 的不同实现且运行在同个上下文内,就无法做到,模块的注册使用 provider + inject 的方式会更好。举个此场景的例子:在同一页面展示 pc web & 移动端 web 编辑器的使用案例 并 提供 demo 展示。
完。