## 有道云笔记跨平台富文本编辑器的技术演进
文/傅云贵
>使用过有道云笔记的读者会发现,该 App 在 Windows、mac OS、桌面浏览器(WebKit 内核)、 iOS、Android 等终端提供了富文本编辑能力。在不同终端实现基本一致的编辑能力,这是如何做到的呢?
### 跨平台架构设计
这必须从有道云笔记富文本编辑器的基本架构说起。
图1 有道云笔记编辑器跨平台架构设计
有道云笔记编辑器使用了前端技术构建编辑器的核心,并运行在特定的宿主环境(Native App 提供的浏览器环境)中。在不同平台,浏览器环境不一样,以下是有不同平台中使用的浏览器环境。
表1 有道云笔记编辑器的宿主环境
在 Windows 平台的客户端中,使用了 CEF(Chromium Embedded Framework)提供浏览器环境。CEF 是一个基于 Chromium 内核的开源项目,跨 Windows/Mac/Linux 桌面平台,性能好,支持 HTML5/CSS3 等新特性。
在 Android 4.0+中,有道云笔记使用了 CrossWalk 提供浏览器环境。CrossWalk 是 Intel 的一个开源项目,目的是为 Android 系统提供一个一致的性能强劲的 WebView。由于随着 Android 系统不断更新迭代,系统自带 WebView 已使用 Chromium 内核,CrossWalk 的优势在高版本的 Android 中不明显。目前,Intel 已声明不再继续开发该项目,故在 Android 7.0+ 中使用了系统自带的 WebView。
虽然内嵌 CEF,CrossWalk 能够提供性能更好特性更丰富的浏览器环境,但程序安装包的大小会增加 20M 左右。iOS/mac OS 平台系统自带的 UIWebView/WebView 满足要求,故使用系统自带。UIWebView/WebView 已被官方不推荐使用,建议使用 WKWebView 代替。目前我们正往 WKWebView 迁移。
为什么采用 Native App+宿主环境(浏览器/WebView )+前端技术的方式来构建编辑器呢?这是因为:
1. HTML+CSS 特性丰富,布局灵活,适合展现文本,图片等富文本内容;
2. 浏览器的 contenteditable 特性支持富文本的编辑,适合开发编辑器;
3. 可跨平台开发,不同平台编辑器的核心代码基本可以复用,降低开发成本;
4. Native App 具有更高的权限,当 HTML/CSS/JavaScript 能力受限时,可由 Native App 提供接口来补充。
### 编辑器的迭代
宿主环境(浏览器/ WebView)的挑选为编辑器保证了良好的运行环境,而编辑器的好坏取决于如何设计与实现。在发展过程中,有道云笔记共自研发了三代编辑器,每一代编辑器的设计思路各不相同。
#### 第一代
在有道云笔记发展早期(2012年左右),由于当时 Android 自带的 WebView 不支持 contenteditable 特性且无 CrossWalk 这类的项目,故无法基于 contenteditable 实现富文本编辑功能,不得不采用类似普通网页的交互形式来实现简单的文本编辑。
WebView 渲染内容(HTML),当用户点击在渲染视图上时,点击处的 HTML 元素会将其 innerText 发给 Native App ,然后 Native App 调用系统原生控件进行纯文本编辑。待编辑完成后,Native App 将编辑后的文本发给编辑器,编辑器更新视图。
图2 第一代编辑器界面
该版本编辑器实现非常简单,仅支持文本编辑,无法支持修改格式等功能。
#### 第二代
第二代编辑器利用了浏览器的 contenteditable 的特性——这是主流 web 富文本编辑器采用的技术,比如国外的 CKEditor、TinyMCE,国内的 UEditor、KindEditor。
浏览器的 contenteditable 特性为富文本编辑提供了较为强大的功能,document.execCommand API 提供了较多的命令,支持文本编辑、格式修改、插入超链接/图片等功能。但不同浏览器编辑功能的实现有差异,且存在 bug;再者,有些编辑命令未必符合产品需求,因此,不可避免地需要自实现部分(或全部)编辑命令。
采用这一技术的编辑器特点是:
1. 依赖浏览器的 contenteditable 的特性;
2. 特性丰富,性能较好,功能较为强大;
3. 操作的数据是 HTML/DOM 树,数据与视图没有分离,都是同一份内存数据;
4. 对 HTML 的兼容性好;
5. 命令执行依赖浏览器 document.execCommand API,虽然自实现部分或者全部命令,但依然存在难于解决的 bug,也不便于实现协同编辑、类似 Word 分页等功能。
#### 第三代
因此,在2015年,编辑器团队对编辑器进行重新思考,开始探索不一样的道路。
不同于前两代编辑器,第三代编辑器在存储层采用了 XML 对数据进行严格定义。编辑器运行时,XML 被转化成 JavaScript 对象表示的数据层。视图层负责视图渲染及 UI 交互,与数据层分开。
第三代编辑器不依赖浏览器的 contenteditable 特性,命令执行不依赖 document.execCommand API。数据、选区、编辑命令、视图渲染等所有组件完全由编辑器自己定义和实现——这使得编辑器更加可控,但也导致编辑器更复杂,增加了开发的难度和成本。
在业界,Google Docs 在2010年抛弃了对 contenteditable 的依赖,采用了类似的思路,详情见 Google Drive 的博文:What’s different about the new Google Docs? (https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs. HTML )。
类似思路的开源编辑器有 ritzy、Ace。
表2 三代编辑器的区别
### 基于 contenteditable 的编辑器
基于 contenteditable 的第二代编辑器主要有以下几个核心:
1. Range/Selection
2. document.execCommand
3. undo/redo
4. 内容过滤
5. 与 Native App 的通信
#### Range/Selection
无论是基于 contenteditable 还是超越 contenteditable 的编辑器都会有 Range 的概念。Range 翻译过来是范围、幅度的意思,与数学上的概念——区间——类似。
在 iOS 开发中,NSRange 有 location 和 length 属性,常用来描述字符串的中一段连续的范围。
类似的,浏览器提供的 Range 用来描述 DOM 树中的一段连续的范围。startContainer,startOffset 描述 Range 的起始处,endContainer,endOffset 描述
Range 的结尾处。当一个 Range 的起始处和结尾处是同一个位置时,该 Range 就处于 collapsed 状态。当给一段文本进行操作(比如加粗)时,必须先使用 Range 来描述该段文本。
Selection(选区)管理整个页面当前的 Range 及 Range 的绘制。当 Selection 中的 Range 处于 collapsed 状态时,即是日常所说的光标。光标其实是 Selection 的一种特殊状态。
在有道云笔记编辑器中,由于只支持 webkit 内核的浏览器环境,故不存在 Range/Selection 的兼容性问题。
#### document.execCommand
编辑器使用 Range/Selection 选定内容,使用
document.execCommand 来对选定的内容进行编辑修改。该 API 定义如下:
bool=document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
如需要对选定内容设置为红色,只需要执行 document.execCommand ("foreColor", false, "red") 即可。
W3C 标准定义的命令总共有四十多个,分为四类:
1. 行内(Inline)格式编辑,如 backColor、bold、createLink、fontSize;
2. 块级(Block)格式编辑,如 formatBlock、insertOrderedList、insert HTML 、insertText、justifyCenter;
3. 剪贴板相关,只有三个:copy、cut、paste;
4. 其他,如 undo、redo、selectAll、styleWithCSS。
5. 浏览器原生的命令:
6. 未必符合产品需求,如 fontSize 命令只能传入1-7的参数,无法传入类似 10px 这样的参数。
7. 本身实现有 bug。
因此,编辑器需要复写部分或全部命令,新增命令以及管理命令,提供类似 document.execCommand 的
editor.execCommand 接口。
#### Undo/redo
使用 document.execCommand 对内容修改时,浏览器内部会对该 contenteditable 区域维护一个 undo 栈和一个 redo 栈,使得每一个修改行为可以撤销和重做。
如果一旦使用了 document.execCommand 之外的 DOM API 修改内容,就会破坏 undo/redo 栈的连续性,导致撤销和重做出错或失效。比如,使用 jQuery 查找一个元素,其 Sizzler 引擎在查找过程中可能会对 HTML 元素添加属性,并在查找完成后删除新添加属性。在该过程中,Sizzler 使用了 DOM API 操作添加和删除属性,会导致浏览器内部的 undo/redo 出错。
在复写或新增命令时,不可避免地会使用 DOM API 操作内容,破坏浏览器内部的 undo/redo 管理,因此,编辑器必须自身实现 undo/redo。
通常,基于 contenteditable 的编辑器使用打标记(Bookmark)的方式来实现 undo/redo。在有道云笔记的编辑器中,由于没有复写全部的命令,难于使用打标记的方式,故另辟蹊径——使用 HTML 内容与 Range 快照的方式来实现 undo/redo。
要创建和恢复 HTML 内容与 Range 快照,就必须实现 HTML 内容与 Range 的序列化和反序列化。其中值得注意的一点是,Range 无法单独序列化和反序列化,必须与 HTML 内容绑定在一起。
图3 Undo/Redo 设计
修改内容是通过执行命令完成的,一个或者多个命令的执行过程可以抽象成一个 Operation,每个 Operation 对象会持有:
1. snapshotBefore:修改前的 HTML 内容与 Range
快照;
2. snapshotAfter:修改后的 HTML 内容与 Range 快照。
当执行修改动作后,Operation 被压入 undo 栈。执行 undo 时,Operation 从 undo 栈弹出,然后 snapshotBefore 被恢复到编辑器中,最后 Operation 被压入 redo 栈。执行 redo 时,Operation 从 redo 栈弹出,snapshotAfter 被恢复到编辑器中,最后 Operation 压入 undo 栈。
HTML 内容与 Range 每次快照都存储整篇笔记,占用的内存较大。因此,内存中只保留有限个 Operation——这限制了撤销和重做的次数。在 PC/Mac/iOS/Android 平台,Native App 可以提供持久化存储接口。因此,可以将超出个数限制的 Operation 序列化,通过 Native App 提供的接口保存到持久化存储层。当内存中的
Operation 个数不够时,从持久化存储层中获取数据,反序列化成 Operation,并放入 undo 栈中。通过这种方式,可以突破内存大小的限制,实现无限次撤销与重做,尤其适合对 App 内存大小有严格限制的移动端。
#### 内容过滤
由于 HTML 特性丰富,灵活多变,因此需要对输入的 HTML 内容供进行过滤。粘贴过来的内容,需要特殊处理,尤其是从 Word,Excel 粘贴过来的内容。
对 HTML 过滤有两种方式:
1. 使用正则表达式对 HTML 字符串进行过滤;
2. 将 HTML 字符串解析成 DOM 树后进行过滤。
其中,将 HTML 字符串解析成 DOM 树时,应当使用 DOMParser API,而不是简单地将 HTML 赋给临时元素的 inner HTML 。使用 DOMParser API 的主要好处是:
1. 防止 script 标签的执行,避免 XSS 攻击;
2. 防止图片等资源的自动加载。
以上两种方式可以综合起来,灵活运用。
HTML 的过滤机制有两种:
1. 白名单
2. 黑名单
推荐使用白名单机制对 HTML 内容进行系统严格地过滤,对可接收的标签,属性,样式都严格限制。
#### 与 Native App 的通信
无论在哪个平台,编辑器都需要与对应的 Native App 进行通信。编辑器提供 setContent/getContent 等接口供 Native App 调用,Native App 则提供 requestImageThumb,requestInsertImage 等接口供编辑器调用。与 Web App 相比,Native App 有更好的性能和可靠性,可访问各种设备,如持久存储、相册相机、震动器。Native App 提供的接口极大增强了编辑器的能力,有道云笔记编辑器依靠 Native App 提供的接口,实现了无限次撤销重做、插入图片/视频、图像纠偏、手写笔记等功能。
### 超越 contenteditable 的编辑器
由于基于浏览器 contenteditable 特性实现的编辑器存在无法根除的 bug,难于实现协同编辑、类 Word 分页等功能,重新思考与设计编辑器,开发了第三代编辑器。
与第二代相比,第三代编辑器的主要特点是:
1. 使用 XML 严格定义了数据;
2. 编辑时,数据层与视图层分离;
3. 不依赖浏览器原生的 Range/Selection,自实现 NoteRange/NoteSelection 及其绘制;
4. 不依赖 contenteditable 特性,使用中间层对接输入法;
5. 不依赖 document.execCommand,自实现全部命令及命令的管理;
6. 细粒度的 undo/redo,占用更少的内存;
7. 更加可控,扩展性更强,有利于实现协同编辑、类 Word 分页等功能。
#### XML 定义数据
HTML 特性丰富,灵活多变,不利于严格定义数据,而 JSON 又缺少描述文档结构的定义。XML 适合用来结构化文档和数据,适应性强且通用——不但能够被浏览器支持,而且在其他端得到了广泛的应用和支持。在定义数据结构时,可以使用 XML Schema 描述 XML 文档结构。
在有道云笔记中,一个段落被抽象成 paragraph 标签,其下有以下子标签:
1. text:表示段落中的文本数据;
2. inline-styles:表示段落中的文本的格式,比如字体、字号、颜色、背景色;
3. styles:表示整个段落的格式,比如行高、缩进。
图4 具有段落和行内格式的 Paragraph——显示效果
比如,图4所示的带格式文本,使用 XML 可描述为图5所示。
图5 具有段落和行内格式的 Paragraph——XML 描述
众所周知,树状数据不如线性数据好处理。HTM 是树状结构的,且无深度限制——div 标签几乎可无限制嵌套 div——非常不利于编辑器操作数据。因此,在 XML 定义的文档数据中,类似 paragraph 的块级标签不能相互嵌套,且 text、inline-styles 等行内标签的嵌套也有严格定义。
#### 数据层
运行时,第二代编辑器操作的数据和展现给用户的视图使用的是同一份 HTML /DOM。通过对 Etherpad Lite、Quip、Google Doc 等产品的调研与分析,第三代编辑器重新设计了运行时的数据层。所有数据可以分为块状(Block)和行内(Inline)数据,笔记内容由若干个块数据(Block)组成,每个块数据(Block)由行内(Inline)数据组成——这与 XML 定义存储层时的逻辑一致。
在运行时,paragraph 标签会被转化成 Block 的子类 Paragraph 对象。行内数据 text 和 inline-styles 则转化成一个 RichText 对象,RichText 由若干个 RichChar 组成。而 styles 标签则会被转化成 blockStyles 对象。Paragraph 负责整个段落,管理 RichText 和 blockStyles 对象(如图6)。
图6 Paragraph 存储层到数据层
一篇笔记中有不同类型的 Block,如列表(ListItem)、图片(Image)、附件(Attachment)、表格(Table)、未知类型(Unknown)。其中,未知类型(Unknown)比较特殊,用于兼容未来新增的 Block 定义。笔记中的所有 Block 存放在一个数组中,该数组由 Note 对象管理。Note 对象提供一些方法以支持 Block 的获取及增删改。
#### NoteRange / NoteSelection
Range 是用来描述数据范围的,由于数据层中不同类型的 Block 数据结构不一样,因此需要不用类型的 BlockRange 来描述数据范围。
比如,ParagraphRange 描述 Paragraph 数据范围,具有以下属性:
1. block:指向 Block 子类 Paragraph 的实例;
2. start:数据范围的起始;
3. end:数据范围的结尾。
ImageRange 描述 Image 的数据范围,则具有以下属性:
1. block:指向 Block 子类 Image 的实例;
2. rangeType:枚举常量,可取的值为 ImageRange.START(图片左侧);ImageRange.END(图片右侧),ImageRange.ALL(选取图片)。
整个笔记的数据范围则用 NoteRange 来描述,其具有两个属性:
1. startBlockRange: BlockRange 类型,笔记数据范围的起始处;
2. endBlockRange:BlockRange 类型,笔记数据范围的结尾处。
NoteSelection 负责管理当前的 NoteRange,NoteSelectionView 负责绘制 NoteSelection。
#### 视图层
在第三代编辑器中,视图层与数据层进行了分离。BlockView 对象负责数据层Block对象的渲染和交互,不同的 Block 类型对应不同的 BlockView,比如 ParagraphView 负责 Paragraph(如图7),ImageView 负责 Image。
图7 Paragraph 数据层到视图层
在 BlockView 之上存在 NoteView,NoteView 负责管理所有的 BlockView,以及 BlockView 级别上无法处理的交互。
除了 NoteView 外,NoteSelectionView 是视图层重要的一部分。NoteSelectionView 是一个绝对定位的半透明层,悬浮在 NoteView 上方。在计算 NoteSelection 的位置信息时,会调用在选区中的每个 BlockView 的 getClientRectsForRange 方法以获取一组 ClientRect,NoteSelectionView 根据这些 ClientRect 即可绘制出选区。值得注意的是,NoteSelectionView 需要将其 CSS pointer-events 属性设置为 none 以禁止其接收鼠标点击等用户交互。
一个完整的编辑器一般会提供工具栏,编辑器需要给工具栏提供命令状态查询接口。
综上,编辑器存储层、数据层、视图层的关系如图8:
图8 整篇笔记存储层/数据层/视图层的关系
#### 输入法对接
由于抛弃了 contenteditable 特性,编辑器无法使用系统默认光标/选区来支持输入法的输入,但真实的光标/选区又必须存在,浏览器才能接收到输入法的输入,该如何处理呢?
业界普遍采用的方式是将真实的光标/选区放置在一个用户不可见的 input 元素或者 textarea 元素中。input 或 textarea 元素监听 keydown,textInput,compositionstart/compositionupdate /compositionend,copy/cut/paste 等键盘、输入法、剪贴板相关事件。
在第三代编辑器中,使用不可见的 textarea 元素,并由 HiddenInputView 组件负责管理。HiddenInputView 会将来自 textarea 元素的事件稍加整理,然后交与整个编辑器的控制器 Controller 处理。
#### 命令及其管理
当控制器 Controller 接收到键盘按键、输入法、剪贴板等相关事件时,会执行对应的命令(Command)。
编辑器不能直接去修改数据层的 Note/Block,必须通过执行命令(Command)的方式间接修改数据。任何修改操作行为都必须抽象成命令(Command),每个命令都必须实现 doApply、undoApply 和 redoApply 方法,以便于整个编辑器实现撤销和重做功能。
比如,当我们对一个段落中选择的一部分文字加粗时,会将执行 SetInlineStyle 命令。其 doApply 方法优先调用数据层 Block 的 get 方法获取将要被修改的格式,并将这些格式数据备份,然后调用 Block 的 set 方法设置加粗格式。当 undo 时,undoApply 方法将调用 Block 的 set 方法设置成之前备份的格式。执行 redo 时,redoApply 方法将调用 Block 的 set 方法设置加粗格式。
当 Block 的 set 方法被调用时,Block 会通知对应的 BlockView。BlockView 收到数据发生变化通知后,随即局部更新视图或者全部重新渲染(如图9)。也就是说,视图更新的粒度控制在 Block/BlockView 级别;被修改的 Block 对应的 BlockView 更新视图即可,不需要更新整个 NoteView 视图。
图9 Paragraph 级上的 MVC
每个命令(Command)除了会接收操作参数(如加粗)外,还会接收一个参数 startNoteRange——描述被修改的数据的范围。命令的 doApply 方法会计算 endNoteRange——命令执行完毕后的选区。当执行 doApply,redoApply 方法时,编辑器会将 endNoteRange 设置给 NoteSelection;执行 undoApply 方法时,编辑器会将 startNoteRange 设置给 NoteSelection。当 NoteSelection 发生变化时,通知 NoteSelectionView 重新渲染(如图10)。
图10 编辑器 MVC
#### 细粒度的 undo/redo
命令(Command)之间可以相互嵌套,不被其他命令嵌套的命令被称为顶层命令,一个编辑操作可以抽象成一个顶层命令。
当执行编辑操作时,顶层命令执行 doApply 方法,然后被压入 undo 栈;执行撤销时,顶层命令从undo栈弹出,执行 undoApply 方法,然后被压入 redo 栈;执行重做时,顶层命令从 redo 栈弹出,执行 redoApply 方法,再次被压入 undo 栈。因此,整个编辑器的撤销和重做的粒度控制在命令级别上。
直接调用 Note/Block 的方法修改数据的命令,仅会备份被修改部分的数据;不直接修改数据的命令,不会备份数据。因此,与第二代编辑器采用快照方式实现 undo/redo 相比,第三代编辑器实现 undo/redo 占用的内存非常少。
#### 协同编辑
当协同编辑时,被编辑的 Block 会被锁定;执行的命令(Command)会被序列化,上传给协同服务器;协同服务器接收到来自客户端的命令后,不对命令进行处理,直接分发命令给其他客户端。客户端接收到来自协同服务器的命令后,对命令反序列化,进行冲突处理后,重新构建命令。重新构建的命令会被执行,并产生 endNoteRange——即远端用户编辑的位置。该 endNoteRange 会被 NoteSelectionView 渲染,当前用户即可看到远端协同用户编辑的位置。
目前,实现协同编辑最好的技术是操作变换(Operation Transformation),但实现比较困难。因此,有道云笔记编辑器的协同使用了段落锁定,没有采用操作变换技术。
### 小结
基于浏览器的富文本编辑器一般利用了 contenteditable 特性,同时也被该特性束缚住,难逃离其窠臼。有道云笔记编辑器不断迭代,抛弃了 contenteditable 特性,自实现了所有组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。
### 自研发 VS 开源
有道云笔记编辑器团队约五人的规模,历时数年,投入了比较大的成本自研发跨平台的富文本编辑器。对大多数产品来说,如果编辑器不属于核心竞争力之一,不推荐走自己研发的道路,因为编辑器的坑太多了(可参阅知乎的话题:为什么都说富文本编辑器是天坑?),需要投入较多的人力,且难做到尽善尽美。
小团队可以采用业界开源的编辑器,如国外 CKEditor、TinyMCE,国内的 UEditor、KindEditor,这些编辑器都比较成熟,适用于桌面和移动 Web。在移动 App 中,有基于 WebView contenteditable 特性开发的富文本编辑器,如适合 iOS 的 ZSSRichTextEditor、适合 Android的richeditor-android;亦有直接
Native 原生开发的富文本编辑器,如适合 iOS 的 FastTextView、适合 Android 的 Knife。与基于 WebView 的编辑器相比,直接 Native 原生开发的编辑器性能更好,更稳定,但 HTML 兼容性会差一些。采用开源方案的关键在于挑选合适的编辑器。此方案无须开发编辑器,成本低,大部分团队应首选该方案。
如果开源编辑器满足不了产品需求,可以在开源编辑器的基础上进行二次开发——有道云笔记早期的桌面 Web 端为了兼容 IE 浏览器,基于 KindEditor 进行二次开发以增加附件、待办等功能。基于开源编辑器进行二次开发更贴合产品的需求,但需要一定的开发成本;当开源编辑器无法满足产品某方面的需求时,可采用该方案。
如果编辑器是产品的核心竞争力之一,不可避免地会走上自研发的道路——Google Doc、Quip、有道云笔记、石墨文档等产品都走了这条道路。自研发的优势是定制可控,最大程度地满足产品需求,劣势是需要投入更多的人力物力。一入编辑器深似海,此方案需慎重考虑。