添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

本文来自 网易云社区

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


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


有道云笔记编辑器使用了前端技术构建编辑器的核心,并运行在特定的宿主环境——Native App提供的浏览器环境——中。在不同平台,浏览器环境不一样,以下是有道云笔记在不同平台中使用的浏览器环境。
平台宿主环境备注WindowsCEFMac osWebView桌面浏览器浏览器自身仅支持webkit内核iOSUIWebView亦可使用 WKWebView (iOS 8+)AndroidCrossWalk(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的特性第一代HTMLHTML/DOM 树无特殊依赖第二代HTMLHTML/DOM 树 contenteditable 第三代XMLNote/BlockNoteView/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 的第二代编辑器主要有以下几个核心:

  • Range/Selection
  • document.execCommand
  • undo/redo
  • 与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 则提供 requestImageThumb requestInsertImage 等接口供编辑器调用。与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 这样的块级标签不能相互嵌套,而 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 对象。


    一篇笔记中有不同类型的 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 负责管理当前的 NoteRange NoteSelectionView 负责绘制 NoteSelection


    视图层

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


    BlockView 之上存在 NoteView NoteView 负责管理所有的 BlockView , 以及 BlockView 级别上无法处理的交互。
    除了 NoteView 外, NoteSelectionView 也是视图层的一部分。 NoteSelectionView 是一个绝对定位的半透明层,悬浮在 NoteView 上方。在计算 NoteSelection 的位置信息时,会调用在选区中的每个 BlockView getClientRectsForRange 方法以获取一组 ClientRect NoteSelectionView 根据这些 ClientRect 即可绘制出选区。值得注意的是, NoteSelectionView 需要将其CSS pointer-events 属性设置为 none 以禁止其接收鼠标点击等任何用户交互。
    一个完整的编辑器一般会提供工具栏,编辑器需要给工具栏提供命令状态查询接口。
    综上, 编辑器存储层、数据层、视图层的关系如下:


    输入法对接
    由于抛弃了 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 收到数据发生变化通知后,随即局部更新视图或者全部重新渲染。也就是说,视图更新的粒度控制在 Block / BlockView 级别;被修改的 Block 对应的 BlockView 更新视图即可,不需要更新整个 NoteView 视图。


    每个命令( Command )的除了会接受操作参数(如加粗)外,还会接收一个参数 startNoteRange ——描述被修改的数据的范围。命令的 doApply 方法会计算 endNoteRange ——命令执行完毕后的选区。当执行 doApply redoApply 方法时,编辑器会将 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 特性,自实现了所有组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。

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

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

    更多网易研发、产品、运营经验分享请访问 网易云社区