在上篇 文章 提到过,团队准备基于 Quill 2.x 版本的代码开发富文本编辑器。富文本编辑器从 Quill 1.x 升级到 Quill 2.x 已经有段时间了,升级改造的过程中也遇到了很多问题,下面就简单分享一下我们的踩坑经历。
破坏性变更
1. 无序列表和有序列表的区分。
在 1.x 版本中,无序列表用的是 HTML 中的 ul 标签,有序列表用的是 ol 标签。但在 2.x 版本中,无论是有序列表还是无序列表,都使用的是 ol 标签。为了区分有序列表和无序列表,2.x 版本中给 ol 标签添加了额外的属性,然后根据不同的属性值设置不同的样式。
如果仅仅是标签、样式的更改,那么处理起来还比较容易,但是这里面还有一个坑。在上篇文章中介绍 Quill 的优点时提到过:
高一致性和可预测性。前面我们说过, Quill 不依赖于浏览器的文档对象模型,而是自行实现了一个类似的 Parchment 。通过它就能对文本内容的一致性有所约束。举例来说,如果编辑器的内容是 “Text”,那么它的 HTML 内容究竟是 “<p>Text </p>” 还是 “<p><span>Text</span></p>”呢,毕竟这两种格式在浏览器中显示效果是一样的。但有了 Parchment 约束后,只有前者是有效的,因为 Parchment 要求用最简洁的格式来表现内容。
由于 Quill 2.x 中列表元素统一都是 ol 标签,那么之前 Quill 1.x 版本中的无序列表在 2.x 版本中编辑,可能存在这些无序列表内容丢失的问题。举例来说,在 1.x 版本中发表过的文章中包含一段无序列表:
< li >无序列表项 1 </ li > < li >无序列表项 2 </ li >但在 2.x 版本的 Quill 中,由于 Parchment 对格式的约束,它判定上述的文本格式是无效的,因为新版本只会有 ol 标签包含的 li 元素,而没有 ul 标签包含的 li 元素。
2. 自定义图片上传模块
在上传和粘贴图片时,Quill 默认是把图片转成 Base64 格式。图片用 Base64 格式固然简单,但是文本内容很容易就超出长度限制。在 Quill 中我们可以通过自定义图片上传模块,把图片上传到我们自己的服务器。但在 2.x 版本中,即便我们自定了图片上传模块,它还是会调用默认的上传逻辑。这就导致用户复制粘贴图片到富文本编辑器的时候,会有两张图片出现,并且因为文本内容超过长度限制,导致文章发表失败。
3. formats 参数无效
Quill 1.x 的版本提供了 formats 参数,是一个白名单的形式,用来设置编辑器允许粘贴哪些格式的文本。我们的项目中发表主题的组件就用到了这个参数,因为主题内容是不支持富文本格式的,因此我们借助 formats 参数来限制文本格式。但 formats 参数在 Quill 2.x 中是无效的,要想解决这个问题,要么移除 formats 参数通过插件来达到同样的目的,要么对源码进行改造继续支持该参数。
插件存在兼容性问题
易拓展性也是 Quill 的一大特性,一些定制化的需求都可以通过插件来实现。我们项目中也使用了一些插件,在升级过程中遇到的兼容性问题大致有以下几个方面:
1. Clipboard 模块的一些 API 变更。
Quill 中 Clipboard 模块负责处理剪贴板相关的变更。一些和剪贴板相关的插件,大多都继承了该模块,并添加自定义方法。项目中引用了一个插件,用于过滤从其他地方复制粘贴过来的文本中包含的非法标签和样式等。Clipboard 模块的 onPaste 参数形式在 Quill 2.x 中有变更,这会导致实现了该方法的插件都无法运行。
2. EmbedBlot 节点的变更。
在 Quill 1.x 版本中 EmbedBlot 是由 Parchment 导出,但在 2.x 中该节点直接由 Quill 自身导出:
// Quill 1.x
import * as Parchment from 'parchment';
class EmojiBlot extends Parchment.EmbedBlot {}
// Quill 2.x
import Quill from 'quill';
const EmbedBlot = Quill.import('blots/embed');
class EmojiBlot extends EmbedBlot {}
3. Parchment API 变更
Parchment 中 StyleAttributor 和 ClassAttributor 也是我们编写插件时常用到的两个属性。在迁移到 Quill 2.x 的时候需要注意写法上的差异:
// Quill 1.x
const Parchment = Quill.imports.parchment;
const FloatStyle = new Parchment.Attributor.Style('float', 'float');
const offsetAttributor = new Parchment.Attributor.Attribute('nameClass', 'class', {
scope: Parchment.Scope.INLINE,
// Quill 2.x
import * as Parchment from 'parchment';
const FloatStyle = new Parchment.StyleAttributor('float', 'float');
const offsetAttributor = new Parchment.ClassAttributor('nameClass', 'class', {
scope: Parchment.Scope.INLINE,
框架自身的缺陷
1. Safari 中文换行问题
众所周知,中文输入法也是富文本编辑器的一个大坑。但 Quill 2.x 在 Safari 上中文输入法方面的糟糕表现,还是让人大跌眼镜。输入中文,然后在末尾按 Enter 换行就会表现得很怪异:
Quill 核心功能是通过 Module 来组织管理的,其中 Keyboard 模块就是负责管理键盘事件的。在 keyboard.js 文件中有个名为 handleEnter 的方法,顾名思义它应该就是用来处理键盘 Enter 事件的的吧:
handleEnter(range, context) {
// ...
const delta = new Delta().retain(range.index)
.delete(range.length)
.insert('\n', lineFormats);
this.quill.updateContents(delta, Quill.sources.USER);
this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
handleEnter 方法的代码组成不多,可能引起换行行为异常的因素大概率是光标选取 range 这个变量了。结合上面动图展示的效果,明明是在文字末尾按下 Enter 键,看起来却更像是在文字开头按下 Enter 键,这一行为又恰好和光标选取异常相佐证。接下来我们只需要比对一下,在富文本编辑器中输入中文并换行时,Safari 浏览器和其他浏览器的 range 变量有什么区别即可,现在我们把 range 变量打印出来:
// Safari
range = { index: 0, length: 0 };
// other browsers
range = { index: 2, length: 0 };
原本我是准备通过浏览器的开发者工具进行断点调试,逐步分析 Safari 浏览器中 range 变量没有正确更新的原因。但是在调试的过程中又发现一个问题,就是当你打开 Safari 浏览器开发者工具进行调试的时候,中文换行异常的问题就消失了。
不能调试给分析光标选区异常带来了极大的困难,既然逐步推理的方案走不通那就只能另辟蹊径了。通过测试我们发现,在 Quill 1.x 中不会有中文换行异常问题。那么稍微分析下相关的代码变更,看换行异常是因为哪部分代码改动导致的即可。在 Quill 2.x 中 handleEnter 方法是由编辑器的 keydown 事件触发:
this.quill.root.addEventListener('keydown', evt => {
const bindings = (this.bindings[evt.key] || []).concat(
this.bindings[evt.which] || []
bindings.some(binding => {
// ...
return binding.handler.call(this, range, curContext, binding) !== true;
当输入中文并换行时,上述代码中 binding.handler 会指向 handleEnter 方法。现在我们来看看 Quill 1.x 中是如何处理这段逻辑的:
this.quill.root.addEventListener('keydown', evt => {
const bindings = this.bindings[evt.which] || [];
bindings.some(binding => {
// ...
return binding.handler.call(this, range, curContext, binding) !== true;
Quill 1.x 中给 bindings 变量赋值稍有不同,此时 bindings 变量是一个空数组,因此就不会执行 handleEnter 方法。虽然目前我们还不知道光标选取为什么没有正确更新,但是只要在 2.x 中不执行 handleEnter 方法,就能够修复中文换行问题。
2. Safari 中无法进行格式化操作
当在编辑器中输入中文,无法对选中的文字进行格式化操作。
前面我们在分析中文换行异常问题的时候,推断出起因是光标选取不对。那么无法对选中的文字进行格式化操作,这个问题是不是又和光标选取有关呢?因为没有正确获取到选中的文字内容,所以文字加粗没有效果,看起来这种猜测也是合情合理。
在分析中文换行问题的时候,我们没有直接一步步追踪光标选取的变化。主要原因是无法调试,即便是可以调试,富文本编辑器的调试也略微麻烦,你输入任何一个字符都会有很多异步事件被触发。现在我们分析无法对选中文字进行格式化操作的问题时,同样会遇到这些麻烦。同换行问题一样,我们还是采用曲线救国的方案去分析解决问题。
通过对比发现,当我们在编辑器输入中文汉字的时候,Safari 浏览器中编辑器的 HTML 结构多了一个 br 标签:
// Safari
<p> 中文 <br></p>
// other browsers
<p> 中文</p>
初步看起来,这个似乎是 contenteditable 在 Safari 浏览器的默认行为,毕竟 contenteditable 在不同浏览器的表现不一致性还是早有耳闻。但是当我自己通过一个简单的示例去验证的时候,发现在 Safari 浏览器中带有contenteditable 属性的元素内输入中文,并不会存在多出的 br 标签。如果不是浏览器的默认行为,那么 Quill 库本身大概率也不会自己添加 br 标签,因为在上篇已经介绍过了,Quill 对文档内容有着强一致性约束。我们继续转换思路,既然输入英文没有问题,那么输入中文的时候做了什么特殊的操作才导致异常行为的出现吗?
this.root.addEventListener('compositionend', () => {
// ...
this.scroll.batchEnd();
// ...
batchEnd() {
const mutations = this.batch;
this.update(mutations); // 该方法用于更新编辑器内容
Quill 监听了 compositionend 事件,当输入法输入中文选中文字后该事件会被触发。从 batchEnd 方法不难看出,问题的关键是要找到 mutations 的赋值是从何而来。
update(mutations?: MutationRecord[] | EmitterSource): void {
if (this.batch) {
if (Array.isArray(mutations)) {
this.batch = this.batch.concat(mutations);
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
this.update(mutations); // 调用上面的 update 方法
this.observer.observe(this.domNode, OBSERVER_CONFIG);
MutationObserver 接口提供了监视 DOM 树更改的能力,Quill 通过 observer 监控编辑器的 DOM 树结构变更。我们打印这些 MutationRecord 对象数组来看看:
原来在 Safari 浏览器中输入中文汉字的时候,浏览器默认会新增一个 br 节点,然后填充输入的文本,最后再删除这个 br 节点。按理说浏览器插入的这个 br 节点会自己删除的,为什么 Quill 编辑器中不会删除这个 br 标签呢?答案也不难猜到,这大概率是因为 Quill 在更新编辑器内容的时候没有同步到最新的变更导致的,也就是更新内容的时候只监测到了插入 br 标签、插入输入的文本这两步。分析到这里,该问题应该就迎刃而解了。
因篇幅所限,本文只列出一些本人稍有印象的问题分享出来。总体而言,Quill 2.x 的代码因为没有正式发布,稳定性方面是不如 Quill 1.x的。但 2.x 的代码也有很多改进、以及新增的功能,如何取舍就需要视具体情况而定。