图片取自 https://webdevtrick.com/javascript-rich-text-editor/

Why ContentEditable is Terrible?

现今主流的 Web 富文本编辑器,大多都是基于 contenteditable 开发的,几年前,Medium Editor 的开发者写过一篇著名的博文:Why ContentEditable is Terrible?,当中列举了一些 contenteditable 存在的一些问题:

视觉内容(用户所见)与实际内容(DOM)的一对多关系

1-to-many

看上去一样的文本内容,却可能对应了多种 DOM 结构,例如下面这段文字:

The hobbit was a very well-to-do hobbit, and his name was Baggins.

描述这句话的 HTML 可以有多种:

html)

如果你的编辑器使用了 contenteditble ,并且将编辑行为都托管给了浏览器,那么在不同的 DOM 结构下,执行相同的操作,浏览器要能够输出一样的视觉内容。

视觉选区与实际选区的多对多关系

many-to-many

选区的情况则更加糟糕,一个视觉选区可能映射为多种 DOM 选区,而一个 DOM 选区也可能映射为多个视觉选区,例如我们的 HTML 为:

selection-html

如果用户看到光标落在 Baggins 之前,那么 DOM 选区可以是:

dom-selections

在光标位置插入字符 I,上面不同的 DOM 选区就会让用户看到不同的文本

  • his name was IBaggins
  • his name was I*Baggins*
  • his name was IBaggins

而在文本:

The hobbit was a very well-to-do hobbit, and his name was Baggins.

well-to- 后回车,用户将看到:

The hobbit was a very well-to
do hobbit, and his name was Baggins.

从第一行末尾到第二行开头的 DOM 选区,在视觉上看有两种可能,要么在第一行末尾,要么在第二行开头。面对这样的 “悬摆 DOM 选区”,你无法告诉浏览器将其映射为哪个视觉选区。

让浏览器自己实现 contenteditbale 元素视觉与实际的统一,已经颇为不易,如果还期盼不同的对 contenteditbale 有一致的处理,就难上加难了。这也就是为什么时至今日,浏览器在功能和性能上已经大幅发展了,开发者还是不信任它的富文本处理能力,社区的 Web 富文本编辑器也还是层出不穷。

Why ContentEditable is Terrible? 还有一个副标题: How Medium Editor Works?,它不仅仅描述了 contenteditable 存在的缺陷,还以此为引,阐述了 Medium Editor 的工作机制。近年来社区活跃度颇高的 Slate.js,Draft.js 、ProseMirror 以及 CKEditor 等也使用了类似的机制,来规避 contenteditable 缺陷,这个机制可以被简单描述为:

  • 模型与视图相分离

    • 让编辑器决定数据结构(内容 + 选区),保证视觉与实际一致
    • 方便同样的内容在不同设备设备展示
  • 自定义命令

    • 脱离实现不一的浏览器命令(document.execCommand),自行划定编辑器的操作范围

这种机制最小程度使用了 contenteditble —— 仅仅让一个节点的 HTML 内容允许被编辑,而如何编辑,编辑器结果则收敛到了编辑器治理:

sync

防患未然:基于行为的同步策略

编辑器使用了视图模型相分离的架构,就需要处理视图与模型间的同步,当用户在编辑器完成了操作后,编辑器据此更新数据模型,再通过数据模型渲染出新的页面视图即对视图:

model-view-sync

编辑器是如何知道用户执行了哪个操作呢?答案就是 DOM 事件,假设监听到了 keyCode = 8 的 keydown 事件(Event),就推测用户是在执行退格(Intent),从而调用自定义的删除指令(Command),修改数据模型,最终用户看到光标前的字符被成功删除了。

基于事件拦截的数据同步,是理想型的同步链路,先更新模型,再渲染视图,由于模型和视图一一对应的关系,用户执行了相同的操作,总能获得一致的 DOM 结构,

然而,由于 DOM Event 的规范仍然在发展,各个浏览器对其的实现也不尽相同,因此,基于事件拦截的同步也不完全可靠。

举个例子来说,beforeinput event 是非常有用的一个事件,该事件在 Input Events Level 1 被定义,并在 Input Events Level 2 中获得完善,规范希望能帮助类似 Web 富文本编辑器这样的应用更容易地处理用户输入。

beforeinput event 能让开发者在输入反馈到 UI 之前,通过事件的 inputType 属性知悉用户输入意图(例如,当 inputTypedeleteContentBackward 时,我们就知道用户正在向前删除内容), 但尴尬的是:

  • 要么浏览器不支持这个事件
  • 要么部分支持这个事件:例如覆盖到的 input type 不全

beforeinput-compat

因此,DOM Event 本身的不完美,也就让编辑器难以完全摸清用户意图。而对用户意图的模糊,也会造成:

  • 无法理解 :编辑器忽略了某个用户操作
  • 理解错误 :将用户的操作理解为了另一个操作,例如用户是在输入,但被编辑器理解为了删除,造成数据模型发生非预期变更,用户看到错误的视图

因此,基于事件的同步模型也是一个理想型模型,受限于浏览器支持,在实际应用中并不完全可靠。

亦敌亦友:输入法

在编辑器的生态中,作为文本合成(Text Composition)的主要手段,输入法能让编辑器展示和处理更多的语言,是编辑器的不可获取的伙伴。然后输入法本身又不受控于编辑器,它完全由第三方厂商实现,其实现也只和操作系统有关,而你编辑器的生死存亡,却不是它的义务:

os-ime-browser

我们看到,当生态中加入输入法之后,链路变得更加复杂了:输入法和操作系统交互来合成文本,浏览器也通过操作系统,获得输入法状态,在输入过程中抛出对应事件。

其次,抛开这个链路不看,输入法自己也不是个 “省油的灯”:

  • 输入法繁多 :不同的操作系统版本、浏览器内核,和输入法能形成众多的组合,每种组合在 contenteditable 元素上进行文本合成的时候,难以形成一致的事件流
  • 输入方式多样 :除了键盘能够进行文本合成,还要支持手写、语音等输入方式

而 Android 因为其生态紊乱,系统版本分布广泛,使用不同内核的浏览器众多,应用市场的第三方输入法层出不穷,让基于事件进行同步的策略更加脆弱。举个例子,我们在 Android 下使用某个第三方浏览器配合上某个第三方输入法编写内容时,可能遇到:

  • 浏览器不会抛出 beforeinput 事件 :编辑器无法通过 beforeinput 事件中的 inputType 属性知悉用户行为
  • 浏览器没有传递正确的虚拟键盘响应 :很多 Andorid 输入法都支持字词联想,当虚拟键盘呼出后,开始进入 Composing 状态,此时 keydown 事件的 keyCode 都会被设置为 229,那么编辑器无法也无法从 keydown 事件知悉用户行为

W3C KeyCode 规范 定义了 keyCode 为 229 的 Keyboard Event 是表示输入法正在处理按键输入

这就会造成比较恶性的现象,举个例子,当用户将光标放置在图片之后时,输入法被唤起,用户通过虚拟键盘按下了退格键希望能够删除图片。但由于编辑器既无法拦截 beforeinput 事件来判断用户是不是在执行删除,也无法通过 keydown 事件判断判断键盘的退格是否被按下,因此,也就不会生成对应的命令去修改数据模型,删除对应节点。此时,用户无论怎么疯狂点按退格,都不能把图片删掉。

因此,基于事件的同步策略,在面对输入法时,变得更加不可靠,社区目前流行的开源编辑器也仍在和 Android 输入法作斗争:

android-compat

亡羊补牢:基于现象的同步策略

编辑器之所以希冀于事件去同步数据模型和视图,是期望在浏览器对页面执行变更前,尽早将用户行为同步到数据模型上,实现实际内容与视觉内容的统一。但我们发现,这个本就不健壮的策略再遇到输入法之后,无法响应用户操作的概率更高了。

如果回到架构之前,仍然托管 contenteditable 的控制权给浏览器,那么,我们发现,输入法的增删改查还是能正确响应的,这也就说明了,托管给浏览器去处理 contenteditable,用户最基本的编辑诉求还是可以保障的。但我们又不能放任浏览器这么做,原因在开篇回顾 Why ContentEditable is Terrible 中也说了,因此,当浏览器处理了用户的行为并更新了视图后,我们要尽快的去 “纠正” 数据模型,亡羊补牢,犹未为晚。

浏览器提供了 Mutation Observer 来让开发者监听 DOM 变更,它的 API 非常简单,初始化观察者之后,在适当时机开始观察即可,每次观察到的变更序列,其中的元素都是 MutationRecord 对象:

mutation-observer

借助于 Mutation Observer,编辑器在观察到 DOM 变化后,可以根据变化(现象)来反推用户行为,从而生成对应的命令,去修缮数据模型:

mutation-to-intent

例如,经过反复的验证发现,当用户在某个段落顶部按下退格并合并了上下两个段落后,observer 将观察到一个 mutation 序列,这个序列的特征为:

  • 第一个 mutation record 的类型为 childList,且 removeNodes 不为空
  • mutation 序列包含了多个 mutation record

那么,我们就告诉编辑器,当观察到这样的 mutation 序列时,就是用户完成了合并段落,你需要去执行一条 merge-block 的指令,合并数据模型上的两个段落。

这种 事后同步 ,弥补了浏览器无法派发正确事件时,我们仍能推测用户行为 —— 通过现象去反推用户行为。但是,由于现象的成因可能有多个,因此我们就可能造成错误的推断:

predication

就拿上面的举例的 mutation 序列来说,编辑器中的图片,在加载完成之前,会先展示 loading 占位,当加载完毕后,observer 也会观察到同样特征的 mutation 序列。但是,编辑器先前已经被告知这样的序列是在合并段落,它会调用 merge-block 指令去修改模型,假设该指令内部还可能调用到 delete 指令,那么就会造成刚加载完成的图片又被删除了,用户永远也无法在编辑器中上传图片。为此,我们又得在算法中告诉编辑器如何识别图片的这个特征。

其实,算法的工作过程非常类似于机器学习中的分类问题:

提取特征 -> 分析特征 -> 获得所属分类

这种算法需要被不断地被训练(发现并扩充特征),才能让最后的预测更加精准。只是与机器学习不同的是,我们的是人肉学习,为了获得更多特征,去更精确的映射用户行为,开发者将疲于应付不同的 Android 设备,不同的浏览器,以及不同的输入法形成的种种组合。

综上,基于现象去同步数据模型,是更不牢靠的手段, 即便开发者投入了大量精力去获得现象和意图间的关联关系,但难免还是有 “漏网之鱼”,导致模型 同步错误 ,这也许比不能响应操作更加严重。而基于事件的同步模型,因为事件本身就表达了用户意图,所以推测会更加精准,所以,只有当事件系统不完善时(例如在 Android 设备下),我们才使用基于现象的同步做兜底,保证用户在编辑器能进行最基本的输入。

DOM 的虚实调和

刚才所讨论的内容,还聚焦在使用了 contenteditable 的编辑器上,如果编辑器在视图层使用了更高的抽象,例如选用了类似 React 这样的基于虚拟 DOM 的解决方案,那么问题还将加剧。

虚实调和

现代化的前端开发中,已经很少使用真实 DOM 来表达一个组件了,如果选用了类似 React 这样的解决方案去描述实现编辑器视图(Slate.js、Draft.js 就倾向于此),就会让视图的表达 “虚实并存”:

  • :React 根据组件属性和状态,创建并且维护了一棵组件对应的 virtual DOM tree,并使用 virtual DOM tree 渲染节点内容
  • :对 contenteditable 的节点的修改会被浏览器托管,绕开了 React 去修改 DOM

所以,当你尝试用与下面类似的代码创建一个 contenteditale 组件时:

react-contenteditable

React 会警告你要自行管控 contenteditable 的节点变更:

Warning: A component is contentEditable and contains children managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.

所以我们在使用诸如 Slate.js 或者 Draft.js 这样的框架时,会遇到下面这样的异常,此时整个编辑器无法继续工作:

DOMException: Failed to execute ‘removeChild’ on ‘Node’: The node to be removed is not a child of this node.

我们姑且称这个现象为: 虚实不调,阴阳失衡

在 Android 设备上,为了解决输入法问题,编辑器将输入先托管给浏览器,并 Mutation Observer 观察 DOM 变化,而后再去同步数据模型。在这样的策略下,用户的输入过程在没有结束之前,都是浏览器在处理,当中发生许许多多的变更,不再受 React 控制,很容易造成 “虚实不调”,引起编辑器崩溃。

因此,当使用了类似 React 这样的方案之后,编辑器需要更加 谨小慎微地 处理数据同步,在容易引起虚实不调的场合(例如段落分裂与合并),强刷(rerender)编辑器组件,调和虚实,让 DOM 与 VDOM 保持一致。而对于预料不到的场合,可能还需要借助 componentDidCatch 这样的 hook 去俘获异常,强刷组件。有 React 开发经验的同学都知道,对于编辑器这样内部子孙繁多的组件,重绘的开销是非常的,最终,在终端用户一侧,就是看到页面闪烁和卡顿。

前路漫漫,任重道远

上文对 Web 富文本编辑器困局的讨论,还仅仅局限单机版的富文本编辑器,如果还要为编辑器支持多人实时协同,那么面对的挑战更加艰巨。

contenteditable is terrible, 但是编辑器已经最小化了对它的使用,比之更为严峻的是,操作系统、浏览器、输入法相互组合形成的紊乱生态 —— 一个编辑器无法控制的,但产品又期望在上面开出繁花的生态。所以才说,Web 富文本编辑器是前端的天坑之一。

面对这样的生态,做不到抛弃,编辑器只能去适应和妥协,但在其领域范围内,似乎还可以有一些突破:

  • 是否能通过交互绕开缺陷?:上面我们举例的图片无法通过退格删除的问题,但键盘不是唯一的 I/O,如果在交互上,能多一个删除图片的方式,就不会阻塞用户。
  • 是否能脱离 contenteditable 的架构?:更大程度的以模型为核心,完全自绘内容和选区,脱离 contenteditable,让视图层更加可控。

但是无论哪种解法,对编辑器的产品逻辑和开发者的技术要求都是非常大的。创建一个优秀的 Web 富文本编辑器,依然任重道远。钉钉文档团队致力于为钉钉用户提供最优质的文档服务,也因此面临着许许多多要去攻坚的技术方向,富文本编辑器就是其中之一,也希望对这个方向感兴趣的同学,和我们一起投入到这个可能会是伟大的历史进程中来:

招聘

参考资料