使用过有道云笔记的读者会发现,该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)+ 前端技术的方式来构建编辑器呢?这是因为
contenteditable
特性支持富文本的编辑,适合开发编辑器。
宿主环境(浏览器/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
的特性document.execCommand
API,虽然自实现部分或者全部命令,但依然存在难于解决的bug, 也不便于实现协同编辑、类似Word分页等功能。
因此,在2015年,编辑器团队对编辑器进行重新思考与定位,开始了第三代编辑器的探索。
不同于前两代编辑器,第三代编辑器在存储层采用了XML对数据及格式进行严格定义。编辑器运行时,将XML转换成JavaScript对象表示的数据层。视图层与数据层进行了分离,负责视图渲染及交互输入。
第三代编辑器不再依赖浏览器的contenteditable
特性,命令执行不再依赖document.execCommand
API。数据、选区(Range/Selection)、编辑命令、视图渲染等所有组件完全由编辑器自己定义和实现——这使得编辑器更加可控,但也导致编辑器更复杂,增加了开发的难度和成本。
基于contenteditable
的第二代编辑器主要有以下几个核心:
document.execCommand
无论是基于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的兼容性问题。
编辑器使用Range/Selection选定内容,使用document.execCommand
来对选定的内容进行编辑修改。
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
如需要对选定内容设置为红色,只需要执行document.execCommand("foreColor", false, "red")
即可。
浏览器原生的命令
fontSize
命令只能传入 1-7
的参数,无法传入类似10px
这样的参数。
因此,编辑器需要复写部分或全部命令,新增命令以及管理命令,提供类似document.execCommand
的editor.execCommand接口。
使用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字符串解析成DOM树时,应当使用DOMParser
API, 而不是简单地将HTML赋给临时元素的innerHTML。使用DOMParser
API 的主要好处是:
<script/>
标签的执行,避免XSS攻击
以上两种方式可以综合起来,灵活运用。
HTML的过滤机制有两种:
推荐使用白名单机制对HTML内容进行系统严格地过滤,对可接收的标签,属性,样式都严格限制。
无论在哪个平台,编辑器都需要与对应的Native App进行通信。编辑器提供setContent
/getContent
等接口供Native App调用,Native App 则提供requestImageThumb
, requestInsertImage
等接口供编辑器调用。与Web App相比,Native App有更好的性能和可靠性,可访问各种设备,如持久存储、相册相机、震动器。Native App提供的接口极大丰富了编辑器的能力,能够实现无限次撤销重做、插入图片/视频、图像纠偏、手写笔记等功能。
由于基于浏览器contenteditable
特性实现的编辑器存在无法根除的bug,难于实现协同编辑、类似Word的分页等功能,有道云笔记编辑器团队重新思考与设计编辑器,开发了第三代编辑器。
与第二代相比,第三代编辑器的主要特点是:
NoteRange
/NoteSelection
及其绘制contenteditable
特性,使用中间层对接输入法document.execCommand
, 自实现全部命令及命令的管理
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
重新渲染。
命令(Command
)之间可以相互嵌套,不被其他命令嵌套的命令被称为顶层命令,一个编辑操作可以抽象成一个顶层命令。
当执行编辑操作时,顶层命令执行doApply
方法,然后被压入undo栈;执行撤销时,顶层命令从undo栈弹出,执行undoApply
方法,然后被压入redo栈;执行重做时,顶层命令从redo栈弹出,执行redoApply
方法,再次被压入undo栈。因此,整个编辑器的撤销和重做的粒度控制在命令级别上。
直接调用Note
/Block
的方法修改数据的命令,仅会备份被修改部分的格式或数据;不直接修改数据的命令,不会备份格式或数据。因此,与第二代编辑器采用快照方式实现undo/reodo相比,第三代编辑器实现undo/redo占用的内存更少。
当协同编辑时,命令(Command
) 会被序列化, 上传给协同服务器;协同服务器接收到来自客户端的命令后,不对命令进行处理,直接将命令分发给其他客户端。客户端接收到来自协同服务器的命令后,对命令反序列化,进行冲突处理后,重新构建命令。重新构建的命令会被执行,并产生endNoteRange
——即远端用户编辑的位置。该endNoteRange
会被NoteSelectionView
渲染,当前用户即可看到远端协同用户编辑的位置。
目前,实现协同编辑最好的技术是操作变换(Operation Transformation),但实现比较困难。因此,有道云笔记编辑器的协同没有采用操作变换的技术。
基于浏览器的富文本编辑器一般利用了contenteditable
特性,同时也被该特性束缚住,难逃离其窠臼。有道云笔记编辑器团队历时数年,不断迭代,抛弃了contenteditable
特性,自实现了所有组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。
本文来自网易实践者社区,经作者付云贵授权发布。