有道云笔记跨平台富文本编辑器的技术演进

使用过有道云笔记的读者会发现,该App在windows、Mac OS、桌面浏览器(webkit内核)、iOS、Android等终端提供了富文本编辑能力。在不同终端实现基本一致的编辑能力,这是如何做到的呢?

跨平台架构设计

这必须从有道云笔记的富文本编辑器的基本架构说起。

有道云笔记编辑器使用了前端技术构建编辑器的核心,并运行在特定的宿主环境——Native App提供的浏览器环境——中。在不同平台,浏览器环境不一样,以下是有道云笔记在不同平台中使用的浏览器环境。

平台
宿主环境
备注
Windows
CEF
Mac os
WebView
桌面浏览器
浏览器自身
仅支持webkit内核
iOS
UIWebView
亦可使用 WKWebView (iOS 8+)
Android
CrossWalk(Android 4.0+)
WebView(Android 7.0+)

在Windows 平台的客户端中,有道云笔记使用了CEF(Chromium Embedded FrameWork)提供浏览器环境。CEF是一个由Marshall Greenblatt在2008建立的开源项目,基于Chromium的内核,跨Windows/Mac/Linux桌面平台,性能好,支持HTML5/CSS3 等新特性。

在Android 4.0+ 中,有道云笔记使用了CrossWalk提供浏览器环境。CrossWalk 是 Intel 公司的一个开源项目, 目的是为Android 4.0+ 系统提供一个一致的性能强劲的WebView。由于随着Android 系统不断的更新迭代,系统自带WebView已使用Chromium内核, CrossWalk的优势在高版本的Android 中不明显。目前,Intel 已声明不再维护该项目。故在Android 7.0+ 中使用了系统自带的WebView。

虽然内嵌CEF, CrossWalk能够提供性能更好特性更丰富的浏览器环境,但程序安装包大小会增加20M左右。因此, iOS/Mac 平台由于系统自带的WebView 满足要求,故使用系统自带的WebView。

为什么采用Native App + 宿主环境(浏览器/WebView)+ 前端技术的方式来构建编辑器呢?这是因为

  • HTML+CSS 特性丰富,布局灵活,适合展现文本,图片等富文本内容。
  • 浏览器的contenteditable特性支持富文本的编辑,适合开发编辑器。
  • 可跨平台开发,不同平台编辑器的核心代码基本可以复用,降低开发成本。
  • Native App 具有更高的权限,当HTML+CSS+JavaScript能力受限时,可由Native App 提供接口来补充。

有道云笔记编辑器的迭代

宿主环境(浏览器/WebView)的挑选为编辑器提供了良好的运行环境,而编辑器的好坏取决于如何设计与实现编辑器。在发展过程中,有道云笔记共自研发了三代编辑器,每一代的设计与实现各不相同。

编辑器
持久存储层
编辑时
数据层
视图层
是否依赖WebView的特性
第一代
HTML
HTML/DOM 树
无特殊依赖
第二代
HTML
HTML/DOM 树
contenteditable
第三代
XML
Note/Block
NoteView/BlockView
不依赖contenteditable

第一代编辑器

在有道云笔记发展早期(2012年左右),由于当时Android自带的WebView不支持 contenteditable特性且无CrossWalk这类的项目,故无法基于contenteditable实现富文本编辑功能,不得不采用了类似普通网页的交互形式来实现简单的文本编辑。

WebView渲染内容(HTML),当用户点击在渲染视图上时,点击处的 HTML元素会将其innerText发给 Native App,然后Native App 调用系统原生控件进行纯文本编辑。待编辑完成后,Native App将编辑后的文本发给编辑器,编辑器更新视图。

该版本编辑器实现非常简单,仅支持文本编辑,无法支持修改文本格式等功能。

第二代编辑器

第二代编辑器的利用了浏览器的contenteditable的特性——这是主流web富文本编辑器采用的技术,比如国外的CKEditor、TinyMCE,国内的UEditor、KindEditor。

浏览器的contenteditable特性为富文本编辑提供了较为强大的功能,document.execComamnd API提供了较多的命令,支持文本编辑,格式编辑,插入超链接/图片。但不同浏览器编辑功能的实现有差异,且存在bug;再者,有些编辑命令未必符合产品需求,因此,不可避免的需要自实现部分(或全部)编辑命令。

采用这一技术的编辑器特点是:

  • 依赖浏览器的contenteditable的特性
  • 特性丰富,性能较好,功能较为强大
  • 操作的数据是HTML/DOM树,数据与视图没有分离,都是同一份内存数据
  • 对HTML的兼容性好
  • 命令执行依赖浏览器document.execCommand API,虽然自实现部分或者全部命令,但依然存在难于解决的bug, 也不便于实现协同编辑、类似Word分页等功能。

第三代编辑器

因此,在2015年,编辑器团队对编辑器进行重新思考与定位,开始了第三代编辑器的探索。

不同于前两代编辑器,第三代编辑器在存储层采用了XML对数据及格式进行严格定义。编辑器运行时,将XML转换成JavaScript对象表示的数据层。视图层与数据层进行了分离,负责视图渲染及交互输入。

第三代编辑器不再依赖浏览器的contenteditable特性,命令执行不再依赖document.execCommand API。数据、选区(Range/Selection)、编辑命令、视图渲染等所有组件完全由编辑器自己定义和实现——这使得编辑器更加可控,但也导致编辑器更复杂,增加了开发的难度和成本。

基于contenteditable 的编辑器实现

基于contenteditable的第二代编辑器主要有以下几个核心:

  1. Range/Selection
  2. document.execCommand
  3. undo/redo
  4. 内容过滤
  5. 与Native App的通信

Range/Selection

无论是基于contenteditable还是超越contenteditable的编辑器都会有Range的概念。Range 翻译过来是范围,幅度的意思,与数学上的概念——区间——类似。在objective 中有NSRange的概念,常用来描述字符串的中一段连续的范围。

类似的,浏览器提供的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来对选定的内容进行编辑修改。

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

如需要对选定内容设置为红色,只需要执行document.execCommand("foreColor", false, "red")即可。

浏览器原生的命令

  • 未必符合产品需求,如fontSize命令只能传入 1-7 的参数,无法传入类似10px这样的参数。
  • 本身实现有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的编辑器使用打标记(Marker)的方式来实现undo/redo。在有道云笔记的编辑器中,由于没有复写全部的命令,难于使用打标记的方式,故另辟蹊径——使用HTML内容与Range快照的方式来实现undo/redo。

要实现HTML内容与Range快照,就必须实现HTML内容与Range的序列化和反序列化。其中值得注意的一点是,Range无法单独序列化和反序列化,必须与HTML内容绑定在一起。

内容修改是通过执行命令完成的,一个或者多个命令的执行过程可以抽象成一个Operation,每个Operation对象会持有:

  • snapshotBefore:修改前的HTML内容与Range快照
  • 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过滤有两种方式:

  • 使用正则表达式对HTML字符串进行过滤
  • 将HTML字符串解析成DOM树后进行过滤

其中,将HTML字符串解析成DOM树时,应当使用DOMParser API, 而不是简单地将HTML赋给临时元素的innerHTML。使用DOMParser API 的主要好处是:

  • 防止<script/>标签的执行,避免XSS攻击
  • 防止图片等资源的自动加载

以上两种方式可以综合起来,灵活运用。

HTML的过滤机制有两种:

  • 白名单
  • 黑名单

推荐使用白名单机制对HTML内容进行系统严格地过滤,对可接收的标签,属性,样式都严格限制。

与 Native App的通信

无论在哪个平台,编辑器都需要与对应的Native App进行通信。编辑器提供setContent/getContent等接口供Native App调用,Native App 则提供requestImageThumbrequestInsertImage等接口供编辑器调用。与Web App相比,Native App有更好的性能和可靠性,可访问各种设备,如持久存储、相册相机、震动器。Native App提供的接口极大丰富了编辑器的能力,能够实现无限次撤销重做、插入图片/视频、图像纠偏、手写笔记等功能。

超越 contenteditable 的编辑器实现

由于基于浏览器contenteditable特性实现的编辑器存在无法根除的bug,难于实现协同编辑、类似Word的分页等功能,有道云笔记编辑器团队重新思考与设计编辑器,开发了第三代编辑器。

与第二代相比,第三代编辑器的主要特点是:

  • 使用XML严格定义了数据
  • 编辑时,数据层与视图层分离
  • 不依赖浏览器原生的Range/Selection,自实现NoteRange/NoteSelection及其绘制
  • 不依赖contenteditable特性,使用中间层对接输入法
  • 不依赖document.execCommand, 自实现全部命令及命令的管理
  • 细粒度的undo/redo,占用更少的内存
  • 更加可控,扩展性更强,有利于实现协同编辑、类Word分页等功能

XML定义数据

HTML特性丰富,灵活多变,不利于严格定义数据,而JSON又缺少描述文档结构的定义。XML适合用来结构化文档和数据,适应性强且通用——不但能够被浏览器支持,而且在其他端得到了广泛的应用和支持。在定义数据结构时,可以使用XML Schema描述XML文档结构。

比如在有道云笔记中,一个段落被抽象成paragraph标签,其下有以下子标签:

  • text: 表示段落中的文本数据
  • inline-styles: 表示段落中的文本的格式,比如字体, 字号, 颜色, 背景色
  • styles: 表示整个段落的格式,比如行高, 缩进

比如,上图所示的带格式文本,使用XML可描述为:

<paragraph>
    <text>Think Diffent</text>
    <inline-styles>
        <bold>
            <from>6</from>
            <to>13</to>
            <value>true</value>
        </bold>
        <italic>
            <from>0</from>
            <to>5</to>
            <value>true</value>
        </italic>
        <font-size>
            <from>0</from>
            <to>5</to>
            <value>22</value>
        </font-size>
        <font-size>
            <from>6</from>
            <to>13</to>
            <value>12</value>
        </font-size>
        <color>
            <from>0</from>
            <to>5</to>
            <value>#f77567</value>
        </color>
        <back-color>
            <from>0</from>
            <to>5</to>
            <value>#daeef4</value>
        </back-color>
        <back-color>
            <from>6</from>
            <to>13</to>
            <value>#ffffff</value>
        </back-color>
    </inline-styles>
    <styles>
        <align>center</align>
        <line-height>1.5</line-height>
    </styles>
</paragraph>

众所周知, 树状数据不如线性数据好处理. HTM是树状结构的,且无深度限制——div标签几乎可无限制嵌套div——非常不利于编辑器操作数据。因此,在XML定义的文档数据中,类似paragraph这样的块级标签不能相互嵌套,而textinline-styles等行内标签的嵌套也有严格定义。

数据层

运行时,第二代编辑器操作的数据和展现给用户的视图使用的是同一份HTML/DOM。通过对 Etherpad Lite,Quip,Google Doc 等产品的调研与分析,第三代编辑器重新设计了运行时的数据层。所有数据可以分为块状(Block) 和 行内(Inline)数据, 笔记内容由若干个块数据(Block)组成, 每个块数据(Block)由行内(Inline)数据组成——这与XML定义存储层时的逻辑一致。

在运行时, paragraph标签会被转化成Block的子类Paragraph 对象。行内数据 textinline-styles 则转化成一个RichText 对象, RichText 由若干个RichChar 组成。而styles标签则会被转化成blockStyles对象。Paragraph 负责整个段落,管理RichTextblockStyles对象。

一篇笔记中有不同类型的Block,如列表(ListItem),图片(Image),附件(Attachment),表格(Table),未知类型(Unknown)。其中,未知类型(Unknown)比较特殊,用于兼容未来新增的Block定义。笔记中的所有Block存放在一个数组中,该数组由Note对象管理。Note对象提供一些方法以支持Block的获取及增删改。

NoteRange/NoteSelection

Range是用来描述数据范围的,由于数据层中不同类型的Block数据结构不一样,因此需要不用类型的BlockRange来描述数据范围。

比如,ParagraphRange描述Paragraph数据范围,具有以下属性:

  • block:指向Block子类Paragraph的实例
  • start:数据范围的起始
  • end:数据范围的结尾

ImageRange描述Image的数据范围,则具有以下属性:

  • block: 指向Block子类Image的实例
  • rangeType:枚举常量,可取的值为ImageRange.START(图片左侧),ImageRange.END (图片右侧),ImageRange.ALL (选取图片)。

整个笔记的数据范围则用NoteRange来描述,其具有两个属性:

  • startBlockRange: BlockRange类型,笔记数据范围的起始处。
  • endBlockRange: BlockRange类型,笔记数据范围的结尾处。

NoteSelection负责管理当前的NoteRangeNoteSelectionView负责绘制NoteSelection

视图层

在第三代编辑器中,视图层与数据层进行了分离。BlockView对象负责数据层Block对象的渲染和交互,不同的Block类型对应不同的BlockView,比如ParagraphView负责ParagraphImageView负责Image

BlockView 之上存在NoteViewNoteView负责管理所有的BlockView, 以及BlockView级别上无法处理的交互。

除了NoteView外, NoteSelectionView也是视图层的一部分。NoteSelectionView是一个绝对定位的半透明层,悬浮在NoteView上方。在计算NoteSelection的位置信息时,会调用在选区中的每个BlockViewgetClientRectsForRange 方法以获取一组ClientRectNoteSelectionView 根据这些ClientRect即可绘制出选区。值得注意的是,NoteSelectionView需要将其CSS pointer-events属性设置为none以禁止其接收鼠标点击等任何用户交互。

一个完整的编辑器一般会提供工具栏,编辑器需要给工具栏提供命令状态查询接口。

综上, 编辑器存储层、数据层、视图层的关系如下:

输入法对接

由于抛弃了contenteditable特性,编辑器无法使用系统默认光标/选区来支持输入法的输入,但真实的光标/选区又必须存在,浏览器才能接收到输入法的输入,该如何处理呢?

业界普遍采用的方式是将真实的光标/选区放置在一个用户不可见的<input/>元素或者<textarea/>元素中。<input/><textarea/>元素监听keydowntextInputcompositionstart/compositionupdate /compositionendcopy/cut/paste等键盘、输入法、剪贴板相关事件。

在第三代编辑器中,使用不可见的<textarea/>元素,并由HiddenInputView组件负责管理。HiddenInputView会将来自<textarea/>元素的事件稍加整理,然后交与整个编辑器的控制器Controller处理。

命令及其管理

当控制器Controller接收到键盘按键、输入法、剪贴板等相关事件时,会执行对应的命令(Command)。

编辑器不能直接去修改数据层的Note/Block,必须通过执行命令(Command)的方式间接修改数据。任何修改操作行为都必须抽象成命令(Command),每个命令都必须实现 doApplyundoApplyredoApply方法,以便于整个编辑器实现撤销和重做功能。

比如,当我们将选中文字加粗时,会将执行SetInlineStyle命令。其doApply方法优先调用数据层Block的get方法获取将要被修改的格式,并将这些格式数据备份,然后调用Block的set方法设置加粗格式。当undo时,undoApply方法将调用Block的set方法设置成之前备份的格式。执行redo时,redoApply方法将调用Block的set方法设置加粗格式。

Block的set方法被调用时,Block会通知对应的BlockViewBlockView收到数据发生变化通知后,随即局部更新视图或者全部重新渲染。也就是说,视图更新的粒度控制在Block/BlockView级别;被修改的Block对应的BlockView更新视图即可,不需要更新整个NoteView视图。

每个命令(Command)的除了会接受操作参数(如加粗)外,还会接收一个参数startNoteRange——描述被修改的数据的范围。命令的doApply方法会计算endNoteRange——命令执行完毕后的选区。当执行doApplyredoApply方法时,编辑器会将endNoteRange设置给NoteSelection;执行undoApply方法时,编辑器会将startNoteRange设置给NoteSelection。当NoteSelection发生变化时,通知NoteSelectionView重新渲染。

细粒度的undo/redo

命令(Command)之间可以相互嵌套,不被其他命令嵌套的命令被称为顶层命令,一个编辑操作可以抽象成一个顶层命令。

当执行编辑操作时,顶层命令执行doApply方法,然后被压入undo栈;执行撤销时,顶层命令从undo栈弹出,执行undoApply方法,然后被压入redo栈;执行重做时,顶层命令从redo栈弹出,执行redoApply方法,再次被压入undo栈。因此,整个编辑器的撤销和重做的粒度控制在命令级别上。

直接调用Note/Block的方法修改数据的命令,仅会备份被修改部分的格式或数据;不直接修改数据的命令,不会备份格式或数据。因此,与第二代编辑器采用快照方式实现undo/reodo相比,第三代编辑器实现undo/redo占用的内存更少。

协同编辑

当协同编辑时,命令(Command) 会被序列化, 上传给协同服务器;协同服务器接收到来自客户端的命令后,不对命令进行处理,直接将命令分发给其他客户端。客户端接收到来自协同服务器的命令后,对命令反序列化,进行冲突处理后,重新构建命令。重新构建的命令会被执行,并产生endNoteRange——即远端用户编辑的位置。该endNoteRange会被NoteSelectionView渲染,当前用户即可看到远端协同用户编辑的位置。

目前,实现协同编辑最好的技术是操作变换(Operation Transformation),但实现比较困难。因此,有道云笔记编辑器的协同没有采用操作变换的技术。

总结

基于浏览器的富文本编辑器一般利用了contenteditable特性,同时也被该特性束缚住,难逃离其窠臼。有道云笔记编辑器团队历时数年,不断迭代,抛弃了contenteditable特性,自实现了所有组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。

本文来自网易实践者社区,经作者付云贵授权发布。