12月5日, 极客邦(InfoQ)在深圳举办 GMTC
大会,蚂蚁集团语雀编辑器技术同学三甲受邀参加大会并分享《在线富文本编辑器的架构设计及实践》,以下内容是根据现场演讲收集整理。
大家下午好,我叫韩聪,花名三甲。现在在蚂蚁集团语雀团队负责语雀文档编辑器的研发工作。
今天在这边和大家分享的是我们语雀在富文本编辑器上的架构设计和实践。
语雀编辑器家族
首先,我们先来认识一下语雀的编辑器家族。语雀发展到现在我们已经诞生了 7 款类型不同的编辑器。老大是文档编辑器,它是基于传统的 DOM 技术来构建的,老二是目录编辑器,也是 DOM 技术来构建的。老三是工作表,他是基于 Canvas 构建的。老四和老五都是图类型的编辑器,他们是基于 SVG 技术来构建的。老六是演示文稿,也是 SVG 技术。
认识语雀文档编辑器
今天和大家分享的是我们的老大 —— 文档编辑器。先简单看一下这个文档编辑器的界面,是非常经典的一个布局,顶部是我们的工具栏区域,我们把一些常见的高频功能罗列出来放置于工具栏当中,供大家使用。
右侧是我们的功能扩展面板区域,这个区域常驻的是我们文档的大纲,根据用户的操作不同,这边还会出现其他的功能面板,比如图片设置面板以及附件下载控制面板。
中间是文档的编辑区域,这是我们编辑器工作的核心区域,编辑器下大部分的代码都是在处理这个区域上用户产生的交互。
这是语雀富本文编辑器填充后内容之后的一个效果。
富文本编辑器工作原理
像这样的一个富文本编辑器,它背后的工作原理是什么呢?
其实在我的角度来看的话,我觉得其实只要清楚这两个问题就好了。第一个问题就是:在浏览器上我们如何去呈现富文本。第二个就是:在浏览器上我们如何去编辑富文本,我们来展开看一下
在浏览器上如何呈现富文本?
首先我们需要先搞清楚什么是富文本,传统意义上的富文本其实是相对于纯文本的概念提出来的。简单来说就是具有丰富格式的文本。
回到这个问题本身,我们怎么去在浏览器上去呈现这些内容呢?那就必然离不开这个浏览器的内容呈现技术。浏览器为我们提供的内容呈现技术大致上有 3 种: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 节点上呈现出来,非常简单粗暴,非常直接。但是维护起来也有些困难。
新一代文档编辑器
于是我们启动了第四代编辑器的研发。我们内部进行过一些小范围的讨论,沉淀出了一个设计目标。这个设计目标是我们在吸收了第三代编辑器的一些经验和教训之后得出来的。首先,第一个目标就是我们要保证数据和视图分离,第二点就是我们的数据结构要是严格受控的。
接着,我们来看一下这个第四代编辑的架构。现在的编辑器是一个典型的三层架构,每一层都会有自己非常明确的职责。最底层是我们的 kernel 层,这一层会负责为整个编辑器创建一个抽象的文档数据结构,同时控制好对这个文档结构的读写。第二层是 engine 层,这一层的核心目标就是把文档呈现给用户。第三层是我们的 editor 层,这一层它的目标就是为用户提供交互界面。
编辑器架构
首先看下 kernel 层,它包含了两个主要的模块:IO 模块和 model 模块。IO 去控制编辑器和外界之间的数据交互和数据流通。model 模块负责创建文档模型,去定义一个标准的文档变更流程。这一层的实现,不仅仅跑在了浏览器上,也跑在了语雀的服务端去操作数据。
第二层是 engine 层,这层包含两个模块:第一个模块是 view 模块,它会根据在内核中维护的数据去计算出一个适合在浏览器中渲染出来的节点树;然后把节点树交给第二个模块 renderer 模块渲染到浏览器上。
第三层是 editor 层,这一层只有一个模块,做的事情也非常轻量,就是创建编辑器的一些主体 DOM 节点,然后把这些 DOM 节点提供给有 UI 需求的插件。比如说工具栏会把工具栏 UI 组件挂载到 editor 所创建的这些节点上,呈现给用户。
数据变更流程
在新一代富文本编辑器中,我们对数据的变更流程做了严格控制。只要变更产生了,无论是什么原因导致,比如初始化导致的,亦或是用户交互操作导致的,这个变更都必须先提交给内核。在内核确认了之后,才会推送给渲染层的 view 模块。经过计算之后,再推送给 renderer 模块去做实际的渲染。这个数据变更流程是所有的插件都必须遵守的。
这一代中,每个插件划分为三个部分,编辑插件会根据自己的实际功能需要去决定需要包含哪一层。
到目前为止,我们自研开发的编辑器项目里,插件数量达到了103个。
接下来看一下第四代编辑器支持的数据类型。前两者都是标准的数据格式,分别是纯文本和HTML。这两种数据格式是所有的富文本编辑器(不仅仅是语雀,甚至包括一些代码编辑器等)都是要支持的,因为这是我们和剪贴板最相关的两种数据类型。
第三个数据格式是我们新编辑器的内部数据格式,称作 inode。第四种是 lake 数据格式,它是第三代编辑器的内部数据格式。
IO子系统
接下来看 IO 子系统。我们现在用一个 HTML 格式的读写来做示例,让大家了解一下我们的 IO 子系统。
在编辑器中会有一个名为 HTMLDataSource 的插件,它会向内核进行数据类型的注册,目的是告诉我们的 IO 模块,有一个名为 HTML 的数据格式。
另外两个插件,分别是 HTMLReader 插件和 HTMLWriter 插件。通过这样三个插件的注册方式,我们就完成了整个编辑,就可以完成对 HTML 格式数据的读写。但是仅仅这样是还不够的, HTMLReader 和 HTMLWriter 本身也是一个框架性的插件,它只能识别 HTML 的语法,并不理解 HTML 内容的语义。为了让 HTMLReader 和 HTMLWriter 能够正确地识别 HTML 数据中的内容,它还需要一些功能性插件的支持。
比如说如果我需要读入或者写出一个包含 h1 标签的 HTML 的话,就需要 Heading 插件来提供对 h1 标签的转换。如果我需要去写出字体加粗这样的一个属性的话,那我就需要 Bold 这样的一个插件来提供对加粗属性的转换处理。我们通过这种插件间的层层协作,共同去为我们的新编辑器构建出了一个非常灵活的 IO 子系统。这个子系统完全能够满足我们目前对所有格式数据的读写管理需求。
文档结构的守护者 — Schema
我们看一下 Schema 子组件,它本身很小,但是肩负着保护文档数据结构的重任。
Command 接口
接着就是编辑器里面的 Command,在很多编辑器里面都会以 Command 模式来实现。Commend 是编辑器具体功能的实现载体。所有的效果,包括用户输入、光标控制、以及字号修改等等,都是在 Command 中来完成的。
对于第四代编辑器来说,Command 的所有修改数据都要交给内核,通过 editing 组件来进行。
以下是 Command 接口的定义。
Command 内部定义了三个常量,分别表示这个 Command 的状态。
● 第一个状态,表示 Command 在当前这个位置上是不可用的;
● 第二个状态,表示 Command 在当前这个位置上是已经被执行过的;
● 第三个状态,表示 Command 在当前这个位置上是没有被执行过的;
最后,就是我们整个架构剖析的一个尾声了,我们来了解一下文档初始化流程。
初始化请求会先交给内核,当内核收到了初始化请求之后,它会依靠我们刚才提到的 IO 子系统去把数据进行一次解析处理,IO子系统处理之后的输出是 以 inode 格式表示的节点树。这个节点树最终会被交给 Model 模块里面的 Editing 子组件去处理。Editing 定义了整个文档数据的编辑流程,它会去创建一个 Job ,然后由 Job 来把这个节点树上的每一个节点往我们内核中的文档树上去挂。
每挂一个节点,它会生成一个对应的操作。这个过程中也会进行我们刚才提到的 Schema 校验。当所有的节点被挂载完了之后,整个操作会被提交,同时触发一个 ContentChange 事件。这个事件会携带着我们这次变更中所有的操作列表,提交给上层的 engine 层,engine 层中的 view 模块会监听该事件,在事件发生后拿到对应的操作列表,对操作列表进行一次计算,把它转换成节点的变化,然后再把节点变化推送给 render 模块。render 模块会根据节点变化去操作实际的 DOM 节点,把变化反映到浏览器上。
这样就完成了我们整个文档的初始化流程。在这个架构下,用户的操作导致的渲染流程和初始化引起的渲染流程大致上是相同的,它们唯一的区别就是触发点不同。初始化的这个触发点是 IO 子系统来处理的,用户操作引起的变更,是通过 Command 来触发的,除此之外后续的流程都是完全相同的。
未来目标
最后一部分,是文档编辑器未来的目标。第一点我们会去处理编辑性能问题,比如说打字卡顿、大文档处理等问题,第二点就是把富文本编辑能力做成原化能力,输出给其他的编辑器。
到这里,我的整个分享就结束了,谢谢大家。