作者:UC 国际研发 闻节
原生编辑器
浏览器提供了两个原生特性:
contenteditable:
https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content
document.execCommand():
https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
contenteditable 特性,可以指定某一容器变成可编辑器区域,即用户可以在容器内直接输入内容,或者删减内容。
execCommand API,可以对选中的某一段结构体,执行一个命令,譬如赋予黑体格式。
基于以上,可以做出最简单的富文本编辑器。
原来富文本编辑器是这么简单?当然不止如此简单!
首先问题集中在 execCommand() 身上:
- 在不同浏览器上表现存在差异,以及各种疑似浏览器bug的问题
- 只接受有限的 commands
https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands
*第一个是兼容性问题,第二个是能力局限问题。
*
传统编辑器
针对上述痛点,出现了第一代传统编辑器,他们主要的思路是:解决各种浏览器兼容差异,以及规避一些bug;同时对有限的命令集进行扩充。
其中具有代表性的包括:CKEditor(4-)、TinyMCE、UEditor、KindEditor、KissyEditor...
但这些耳熟能详的编辑器,还是会有许多问题:
- 对浏览器差异的屏蔽,和bug的规避,成本巨大,而且不稳定,时不时发现一些新问题;
- 对有限的命令集进行扩充,但不是基于execCommand() 进行扩展,而是自行封装实现效果,通过工具栏调用;
- 只是能力扩充,但并没有提供通用扩展接口,开发者无法自定义一种符合业务需要的格式;
归结下来,最大的问题是:缺乏扩展性。
现代编辑器
虽有不足,但传统编辑器仍然被广泛使用,因为大部分业务都首先要解决从无到有的问题。大约到了2013年,开始出现一批现代编辑器(Modern Editor),他们有一个共同的特点:摒弃 execCommnand(),完全自实现各种格式、撤销、重做等功能,而且都是基于自建的数据模型,提供通用扩展接口。
其中具有代表性的包括:CKEditor 5、Slate.js、Quill.js、Draft.js、ProseMirror...
现代编辑器风风火火发展了几年,的确解决了传统编辑器的老大难问题:扩展性。基于现代编辑器的扩展接口,开发者可以自行定义格式,定义内容等,并且可以实现更复杂的编辑器内交互,使用户体验有所提升。
然而现代编辑也并非银弹,真正接入到业务系统后,会发现各种大小问题,而在深入使用后更会发现一个几乎无解的极大的挑战:不受控输入。
这首先要从现代编辑器的常见设计说起。正如上文所述,他们是基于自建数据模型的,即 model-base,即通过数据模型去描述整个编辑器的内部结构,而非html。如此对视图与数据做了抽象隔离,好处是,对编辑器内容的所有变更,实际上是抽象到对数据模型的调整,在完成调整后,再通过渲染引擎更新DOM,性能更优(更少触碰DOM)也更便于提供扩展接口。譬如,Quill.js 基于 delta 做数据模型;Slate.js 则背靠React,以state做数据模型; CKEditor 使用MVC模式,自建model层,等等。
虽然摒弃了execCommand(),但现代浏览器依然是基于contenteditable 特性,那么用户在容器中执行输入、删除等操作的时候,为了达到 model-drive-render 的效果,首先要通过事件捕获用户的行为,再执行API调整model,最后触发render。譬如,用户输入一个backsapce的时候,编辑器需要捕获住,然后通过API去执行删除行为,原生的backspace实际被截断了。这种强控制,除了是 model-base 驱动考虑外,还有一个显著好处,就是可以在一些特殊边界位置,做更多控制,譬如一些位置可以无效化退格删除等,诸如此类场景,有助提升用户体验。
那么问题来了,事实上存在一些场景,输入是不受控的,或者说是不可识别的,譬如,输入法。
输入法的实现,没有一个标准约束,不同的输入法都存在一些差异(浏览器差异的坑才填完,居然还有输入法这个大坑-_-!!),这些差异最重的一点就是,用户输入的内容,是否可以被识别。一旦出现输入不可识别,就会导致事件无法正确截取,最终流向了原生的行为,dom上的内容发生了变化,但是model的数据没有变化,产生的不一致会直接让编辑器的model-base机制崩坏。举个例子:
原本编辑器里面有“abc”三个字母,用户输入了不可识别的“退格”,直接删除了dom上的“c”,剩下“ab”,此时model里面依然是“abc”,如果通过编辑器model接口获取内容,就会拿到和预期不一的结果;
假设用户又输入了可识别的“回车”,事件被截获,然后通过API 调整 model,变成“abcn”,继而触发渲染,dom上从“ab”变成“abcn”。从用户感知上看,原本删除了的“c”,在回车后,又突然跑出来了...
这种不可识别、不受控的输入,在我们一般日常使用的中文输入法上并不常见,但一些小语种的输入法,或者是基于chrome extension 实现的输入法,以及安卓设备的输入法,都大量存在。而最可怕的是,我们可以检测到用户使用什么浏览器,给出提示,但却无法检测到用户使用什么输入法,完全无法防范,最终用户发现编辑的内容出现各种混乱。
不受控输入引起的混乱,并不是一个简单的bug,并不是修修补补就可以解决的问题,而是一个机制性的问题,是要从底层上重构去解决的根本性问题。
显然,如果编辑器所在业务,是要面向国际化用户,面向移动端用户的话,现代编辑器都不足以支撑。
新一代编辑器
早在2010年,Google Docs 团队由于对 contenteditable 特性不满,提出了一种新型的方案 ,他们连 contenteditable 特性都舍弃了,也不基于 execCommand,就是为了达到完全控制,不受浏览器差异影响。事实上,这种方案的实现复杂度相当高,因为原本浏览器帮你做了80%的事情,现在只剩20%了。为了达到最好的效果而不惜提高了复杂度,估计这也是 Google Docs 一直没有开源编辑器的原因吧。
上面提到的方案地址如下:
https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs.html
可以看到,现代编辑器虽然都在 Google Docs之后产生的,但他们都没有采用 Google Docs 这种方案,他们保留了 contenteditable 就是为了控制复杂度。然而,面对不受控输入的挑战,我们最终发现,Google Docs 的方案才能真正有效解决。
纵观整个编辑器市场,不基于contenteditable 的,除了Google Docs,还有苹果的 iCloud Pages,并且更进一步将渲染层改成 SVG实现;后来网易的有道云笔记也实现了脱离 contenteditable 的编辑器。但这三个编辑器都是云服务的方式提供,并没有一个可复用可集成的开源编辑器。
Hugo.js 是阿里UC国际研发团队,参照 Google Docs 的方案抽象而成,第一个可解决不受控输入的可复用编辑器框架。经过抽象后的实现,我们称之为 shadow-input。为什么称为shadow ?下面一张图就能看明白:
如图,编辑器的编辑区域不再是一个 contenteditable容器,而是由三个层(layer)层叠而成,从上而下分别是 overlay-layer, render-layer, shadow-layer。
overlay-layer 负责模拟selection,即用户可见的光标、选中区间;
render-layer 负责渲染内容,即文本、图片等;
shadow-layer 负责承接用户输入,即各种输入法输入;
可见,三层layer实质上是将原来contenteditable容器的三种职责拆分了:
原来的光标,都是浏览器自带的,现在通过overlay模拟实现了;
原来的内容,都是直接可以在contenteditable 容器中编辑的,现在则强制通过 model-drive-render 方式更新了;
原来的不受控输入,都是直接落入contenteditable 容器中,现在则是重定向到了一个 shadow buffer中;
这里最重要的一点就是,我们将用户的输入重定向放到一个 shadow buffer 中,我们让用户的输入在一个不可见区域完整生效了之后,再去做内容检测,然后推断出用户的输入,以此来解决不可识别不受控的输入法输入。再举刚刚的例子:
原本编辑器里面有“abc”三个字母,shadow buffer 中也存有“abc”副本;
用户输入了不可识别的“退格”,退格并没有直接删除render-layer的内容,而是重定向落入了shadow buffer中,那么shadow buffer 的内容就变成了 “ab”,我们通过内容检查,可以推断出用户刚刚的输入,是一个退格删除行为,那么我们就可以调用 model.delete() API ,更新model并触发 render;
此时通过 model API 获取编辑器内容到时候,取到的是和dom表现一致的 “ab”;
假设用户又输入了“回车”,同样地通过shadow buffer 的内容检查,可以推断用户输入了回车,然后通过API 调整 model,变成“abn”,继而触发渲染,dom上从“ab”变成“abn”。dom视图和model数据始终保持一致,那么用户也不会见到突如其来的内容消失等混乱。
Hugo.js 通过 UC News 两印媒体人创作平台 Wemedia 落地实践,验证了这种方案在面向国际化用户,面向移动端用户的场景下,能提供更稳定的编辑体验,同时具备极强的通用扩展能力,可以应付业务的各种定制需求。
如果你的业务也将面向国际市场,面向移动端设备访问,不要犹豫了,Hugo.js 就是你最好的选择!(内外开源在路上...)