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

本文来自网易云社区前端

 

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


跨平台架构设计
这必须从有道云笔记的富文本编辑器的基本架构提及。正则表达式


有道云笔记编辑器使用了前端技术构建编辑器的核心,并运行在特定的宿主环境——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)+ 前端技术的方式来构建编辑器呢?这是由于windows

  • 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的第二代编辑器主要有如下几个核心:服务器

  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特性,自实现了全部组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。

 

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

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

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

相关文章
相关标签/搜索