本文来自网易云社区。前端
使用过有道云笔记的读者会发现,该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
contenteditable
特性支持富文本的编辑,适合开发编辑器。
有道云笔记编辑器的迭代
宿主环境(浏览器/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
的特性document.execCommand
API,虽然自实现部分或者所有命令,但依然存在难于解决的bug, 也不便于实现协同编辑、相似Word分页等功能。第三代编辑器
所以,在2015年,编辑器团队对编辑器进行从新思考与定位,开始了第三代编辑器的探索。
不一样于前两代编辑器,第三代编辑器在存储层采用了XML对数据及格式进行严格定义。编辑器运行时,将XML转换成JavaScript对象表示的数据层。视图层与数据层进行了分离,负责视图渲染及交互输入。
第三代编辑器再也不依赖浏览器的contenteditable
特性,命令执行再也不依赖document.execCommand
API。数据、选区(Range/Selection)、编辑命令、视图渲染等全部组件彻底由编辑器本身定义和实现——这使得编辑器更加可控,但也致使编辑器更复杂,增长了开发的难度和成本。
基于contenteditable 的编辑器实现
基于contenteditable
的第二代编辑器主要有如下几个核心:服务器
document.execCommand
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
这样的参数。所以,编辑器须要复写部分或所有命令,新增命令以及管理命令,提供相似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字符串解析成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的分页等功能,有道云笔记编辑器团队从新思考与设计编辑器,开发了第三代编辑器。
与第二代相比,第三代编辑器的主要特色是:
NoteRange
/NoteSelection
及其绘制contenteditable
特性,使用中间层对接输入法document.execCommand
, 自实现所有命令及命令的管理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
特性,自实现了全部组件——这给编辑器插上了翅膀,让其翱翔在自由的天空。
本文来自网易云社区,经做者付云贵受权发布。
原文地址:有道云笔记跨平台富文本编辑器的技术演进
更多网易研发、产品、运营经验分享请访问网易云社区。