## 有道云笔记跨平台富文本编辑器的技术演进 文/傅云贵 >使用过有道云笔记的读者会发现,该 App 在 Windows、mac OS、桌面浏览器(WebKit 内核)、 iOS、Android 等终端提供了富文本编辑能力。在不同终端实现基本一致的编辑能力,这是如何做到的呢? ### 跨平台架构设计 这必须从有道云笔记富文本编辑器的基本架构说起。 图1 有道云笔记编辑器跨平台架构设计 图1 有道云笔记编辑器跨平台架构设计 有道云笔记编辑器使用了前端技术构建编辑器的核心,并运行在特定的宿主环境(Native App 提供的浏览器环境)中。在不同平台,浏览器环境不一样,以下是有不同平台中使用的浏览器环境。 表1 有道云笔记编辑器的宿主环境 表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  第一代编辑器界面 图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 三代编辑器的区别 表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设计 图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 具有段落和行内格式的 Paragraph——显示效果 比如,图4所示的带格式文本,使用 XML 可描述为图5所示。 图5  具有段落和行内格式的Paragraph——XML描述 图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存储层到数据层 图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数据层到视图层 图7 Paragraph 数据层到视图层 在 BlockView 之上存在 NoteView,NoteView 负责管理所有的 BlockView,以及 BlockView 级别上无法处理的交互。 除了 NoteView 外,NoteSelectionView 是视图层重要的一部分。NoteSelectionView 是一个绝对定位的半透明层,悬浮在 NoteView 上方。在计算 NoteSelection 的位置信息时,会调用在选区中的每个 BlockView 的 getClientRectsForRange 方法以获取一组 ClientRect,NoteSelectionView 根据这些 ClientRect 即可绘制出选区。值得注意的是,NoteSelectionView 需要将其 CSS pointer-events 属性设置为 none 以禁止其接收鼠标点击等用户交互。 一个完整的编辑器一般会提供工具栏,编辑器需要给工具栏提供命令状态查询接口。 综上,编辑器存储层、数据层、视图层的关系如图8: 图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 图9 Paragraph 级上的 MVC 每个命令(Command)除了会接收操作参数(如加粗)外,还会接收一个参数 startNoteRange——描述被修改的数据的范围。命令的 doApply 方法会计算 endNoteRange——命令执行完毕后的选区。当执行 doApply,redoApply 方法时,编辑器会将 endNoteRange 设置给 NoteSelection;执行 undoApply 方法时,编辑器会将 startNoteRange 设置给 NoteSelection。当 NoteSelection 发生变化时,通知 NoteSelectionView 重新渲染(如图10)。 图10  编辑器MVC 图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、有道云笔记、石墨文档等产品都走了这条道路。自研发的优势是定制可控,最大程度地满足产品需求,劣势是需要投入更多的人力物力。一入编辑器深似海,此方案需慎重考虑。