12月5日,InfoQ在深圳举办了GMTC大会,蚂蚁集团语雀编辑科技三强同学受邀参会分享《在线富文本编辑器的架构设计及实践》,以下内容根据现场演??讲整理整理。大家下午好,我叫韩聪,小名三甲。现在在蚂蚁集团语雀团队,负责语雀文档编辑器的研发。今天在这里和大家分享的是我们语雀在富文本编辑器上的架构设计和实践。语雀编辑家族首先,我们来认识一下语雀编辑家族。随着语雀的发展,我们产生了7种不同类型的编辑器。老大是文档编辑器,是基于传统DOM技术构建的,老大是目录编辑器,也是基于DOM技术构建的。第三个是工作表,它是基于Canvas的。第四个和第五个是基于SVG技术构建的图形编辑器。第六个是presentation,也是SVG技术。认识语雀文档编辑器今天给大家分享一下我们的老大——文档编辑器。让我们简单看一下这个文档编辑器的界面。这是一个非常经典的布局。顶部是我们的工具栏区域。我们列出一些常用的高频功能,放在工具栏中,供大家使用。右侧是我们的功能扩展面板区域,我们文档的轮廓常驻于此。根据用户的操作,此处还会出现其他功能面板,如图片设置面板、附件下载控制面板等。中间是文档的编辑区,是我们编辑工作的核心区域。编辑器下的代码大部分是处??理用户在这方面产生的交互。这是语雀府文本编辑器填写内容后的效果。富文本编辑器的工作原理像这样的富文本编辑器,其背后的工作原理是什么?其实从我的角度来说,我觉得只要把这两个问题搞清楚就可以了。第一个问题是:我们如何在浏览器上渲染富文本。第二个是:我们如何在浏览器上编辑富文本,我们展开看看如何在浏览器上渲染富文本?首先我们要搞清楚什么是富文本。传统意义上的富文本其实是相对于纯文本的概念提出来的。简而言之,格式丰富的文本。回到问题本身,我们如何在浏览器上呈现这些内容?那么一定离不开这款浏览器的内容渲染技术。浏览器提供的内容渲染技术大致分为三种:SVG、Canvas、HTML+CSS。我们应该选择这三种技术中的哪一种来呈现我们的富文本?我的答案是HTML+CSS,为什么呢?因为它足够简单,而且它的扩展也很方便。通常,如果我们要实现相同的UI效果,HTML+CSS是这三种技术中最简单的,需要的代码也最少。如何在浏览器上编辑富文本?接下来我们来看第二个问题,如何编辑富文本?弄清楚这个问题后,小编的神奇面纱就基本揭开了。对于这些现任编辑来说,大多数人的答案是contenteditable。contenteditable是一个HTML属性,它使DOM元素可编辑。这种能力非常适合构建我们的富文本编辑器。我们需要做的就是找到我们的编辑器,把这个属性放到我们编辑器的根节点上,然后开启编辑状态。同时,当一个元素变为可编辑状态时,浏览器还会帮我们处理一些基本的功能,比如选择、光标移动等。那么到这里,我们把两个问题都回答清楚之后,其实整个小编对于我们前端同学来说,并没有太大的技术壁垒。剩下要做的就是一步步实现编辑器的功能了。这部分是对这个富文本编辑器的工作原理的简单说明。语雀文档编辑器下一个环节,我们将开始进入语雀的文档编辑器,了解语雀文档编辑器背后的架构是如何设计和实现的?语雀文档编辑器的演进首先,我们来看一下语雀编辑器的发展历程。语雀诞生应该有六年左右的时间,期间经历了四代编辑升级。2016年第一代编辑器,是markdown编辑器,还不是富文本编辑器。我们是基于CodeMirror的二次开发。这时候我们的主要服务对象就是我们内部的工程师和同学。2017年,我们进入了富文本编辑器时代。我们基于Slate.js开发了第二代编辑器。2018年,我们推出了第三代编辑器。这一代编辑器是自研的,它的工作原理就是我刚才说的contenteditable。第三代编辑器是我们迄今为止运行时间最长的在线编辑器。上线快三年了,直到今年四月份才被我们的第四代编辑器取代。第四代编辑器的底层技术也是contenteditable,不过是基于微内核思想的重新设计。今天我们要重点说的是四代编辑器,接下来我们也会顺便提一下三代编辑器。这里先不说一二代,因为时间太长了。第三代文档编辑器第三代文档编辑器架构先介绍一下第三代文档编辑器。这是第三代编辑器的架构,主要由两部分组成。第一部分将负责UI的创建和管理。一些典型的是我们的工具栏侧边栏。然后第二部分是一个叫做Engine的编辑引擎。所有的富文本编辑都会在这里完成,它由一个叫做Core的核心和一系列的插件组成。通过这个插件和我们内核的配合,我们完成了整个编辑器的核心——富文本编辑功能。这是第三代编辑的框架。文档初始化过程之后是第三代编辑器的文档初始化过程。整个过程很简单,就是当我们的编辑器收到初始化请求后,会解析一次内容,转换成我们的DOM树,然后对DOM树进行一些转换。转换的目的是将一些具有相同语义或相同标签的标签归一化为相同的标签。这样做的目的是为了简化我们后续的算法实现,让他们可以专注于尽可能少的这些节点。然后规划好之后,交给我们的Schema进行过滤。Schema需要做的是去除非法的节点和属性。经过Schema过滤后,我们将得到一个纯DOM树。这个DOM树上的每个节点和属性都可以被我们的编辑器理解和识别。我们把这样一个模式序列化之后,然后生成HTML,一次性提交给编辑器渲染,这样就完成了整个文档。初始化进程。第三代文档编辑器的特点我们的第三代文档编辑器有一个非常大的特点,就是以DOM为中心。当所有的功能都开发出来的时候,唯一的目的就是把这个效果呈现在DOM节点上,非常简单粗暴,非常直接。但它也很难维护。新一代文档编辑器于是我们开始了第四代编辑器的研发。我们内部进行了一些小规模的讨论,沉淀了一个设计目标。这个设计目标是我们吸收了第三代编辑器的一些经验和教训后得出的。首先,第一个目标是我们要保证数据和视图的分离,第二点是我们的数据结构要严格控制。接下来,我们就来看看这款第四代编辑器的结构吧。现在的编辑器是典型的三层架构,每一层都会有自己非常明确的职责。最底层是我们的内核层,它会负责为整个编辑器创建一个抽象的文档数据结构,同时控制这个文档结构的读写。第二层是引擎层。该层的核心目标是向用户呈现文档。第三层是我们的编辑器层。这一层的目标是为用户提供一个交互界面。EditorArchitecture首先看内核层,内核层主要包含两个模块:IO模块和模型模块。IO用于控制编辑器与外界的数据交互和数据流转。模型模块负责创建文档模型以定义标准的文档更改过程。这一层的实现不仅运行在浏览器上,还运行在语雀服务器上,对数据进行操作。第二层是引擎层,包含两个模块:第一个模块是视图模块,根据内核中维护的数据,计算出适合在浏览器中渲染的节点树;然后把节点树交给第二个模块renderer模块去渲染给浏览器。第三层是编辑器层。这一层只有一个模块,做的事情很轻。就是创建编辑器的一些主要DOM节点,然后将这些DOM节点提供给有UI需求的插件。例如toolbar会把toolbar的UI组件挂载到编辑器创建的这些节点上呈现给用户。数据变更流程在新一代富文本编辑器中,我们对数据变更流程进行了严格的控制。只要发生变化,无论是什么原因,比如初始化或用户交互,都必须先将变化提交给内核。内核确认后,会推送到渲染层的视图模块。计算完成后推送到renderer模块进行实际渲染。所有插件都必须执行此数据更改过程。在这一代,每个插件分为三部分,编辑插件会根据自己的实际功能需要来决定包含哪一层。至此,我们自研编辑器项目的插件数量达到了103个,接下来我们来看看第四代编辑器支持的数据类型。前两种是标准数据格式,分别是纯文本和HTML。这两种数据格式是所有富文本编辑器(不只是语雀,甚至一些代码编辑器等)都支持的,因为这是与剪贴板关系最密切的两种数据类型。第三种数据格式是我们新编辑器的内部数据格式,称为inode。第四种是湖数据格式,这是第三代编辑器的内部数据格式。IO子系统接下来,看看IO子系统。我们现在用一个HTML格式读写的例子来让大家了解一下我们的IO子系统。在编辑器中会有一个叫做HTMLDataSource的插件,它会向内核注册数据类型,告诉我们的IO模块,有一种数据格式叫做HTML。另外两个插件是HTMLReader插件和HTMLWriter插件。通过这三个插件的注册,我们完成了整个编辑,可以完成对HTML格式数据的读写。但这还不够,HTMLReader和HTMLWriter本身就是一个框架插件,它只能识别HTML的语法,并不能理解HTML内容的语义。HTMLReader和HTMLWriter要想正确识别HTML数据中的内容,还需要一些功能插件的支持。例如,如果我需要阅读或编写包含h1标签的HTML,我需要Heading插件提供h1标签的转换。如果我需要写字体加粗这样的属性,那么就需要加粗这样的插件来提供加粗属性的转换处理。通过插件之间的这种层层协作,我们共同为我们的新编辑器构建了一个非常灵活的IO子系统。该子系统完全可以满足我们目前对所有格式数据的读写管理需求。文档结构的守护者——Schema再来看看Schema子组件,它本身很小,却肩负着保护文档数据结构的重任。Command接口后面是编辑器中的Command,很多编辑器中都是以Command方式实现的。Commend是编辑器具体功能的实现载体。所有的效果,包括用户输入、光标控制、修改字体大小等,都在Command中完成。对于第四代编辑器,Command的所有修改数据都必须通过编辑组件交给内核。以下是命令接口的定义。Command内部定义了三个常量,分别代表Command的状态。●第一种状态表示Command在当前位置不可用;●第二种状态表示Command已经在当前位置执行;●第三种状态表示Command在当前位置上面还没有执行;终于,我们的整个架构分析到此结束,我们来了解一下文档的初始化过程。初始化请求会先交给内核。当内核收到初始化请求后,会依赖我们刚刚提到的IO子系统对数据进行分析处理。IO子系统处理后的输出是inode格式的节点树。.这个节点树最终会交给Model模块中的Editing子组件处理。编辑定义了整个文档数据的编辑过程。它会创建一个Job,然后这个Job会把节点树上的每一个节点都挂到我们内核中的文档树上。节点每挂一次,就会产生相应的操作。在这个过程中,我们刚才提到的Schemavalidation也会进行。当所有节点挂载完成后,将提交整个操作,同时触发ContentChange事件。这个事件会携带我们变更中的所有操作列表,提交给上层引擎层。引擎层的视图模块会监听事件,事件发生后获取对应的操作列表,并对操作列表进行计算,转换为节点变化,然后将节点变化推送给渲染模块。渲染模块会根据节点的变化来操作实际的DOM节点,并将变化反映给浏览器。这样就完成了我们整个文档的初始化过程。在这种架构下,用户操作引起的渲染过程和初始化引起的渲染过程大致相同,唯一不同的是触发点。初始化的触发点由IO子系统处理。用户操作引起的变化由Command触发。否则,后续流程完全相同。未来目标最后一部分是文档编辑器的未来目标。第一点是处理编辑性能问题,比如打字卡顿、大文档处理等。第二点是把富文本编辑能力做成原创能力输出给其他编辑器。至此,我的整个分享就结束了,谢谢大家。阿里人在这里积累知识yuque.com
