我做编辑器这些年:钉钉文档编辑器的前世今生

我做编辑器这些年:钉钉文档编辑器的前世今生

大家好,我是展新,来自钉钉文档团队。2011 年加入支付宝,一路成长于支付宝的前端团队,孵化了语雀,2018 年到钉钉,开启钉钉文档的旅程。

今天主要和大家从另外一个角度来分享,一起来认识编辑器,讲解钉钉文档编辑器的前世今生,让更多对编辑器感兴趣的人能更好地了解这一个领域。

一、Web 编辑器简史

在和大家分享编辑器技术之前,想和大家来介绍下 Web 编辑器的简史,因为在编辑器这个领域,作为人机交互最复杂的场景之一,我们了解它的“前世”,能够更清晰找到编辑器的现在一些设计的根源。

打字机时代

编辑器满足的最初需求,就是输入(Input)和输出(Output),我们在人机交互的极简版本时期,可以从打字机上找到它的一些特点,而这些特点,直接影响且继承至今。

键盘操作

在打字机上,键盘的排布和我们现在用的键盘几乎差不多。实际上我们在编辑输入时候,除了 26 个字母之外,常用的“Enter”、“Shift”、“Space”等实体按键,以及他们点按之后交互反馈一致沿用至今。这些交互反馈,可以用一个比较实际的测试用例描述来标识,例如:

按下“Enter”,编辑器会“换行”
-- 从最早的打字机上,我们就可以体验到编辑器的最基础的功能了。

光标

打字机有一个很明显的特点,就是打字的时候,在纸上会有一个游标在右移。在打字机时代这个游标,就成为我们后来的光标了。它用于定位,可以在那里落笔。

WYSIWYG

当我们在打字机上敲下一个按键的时候,白纸上的游标位置处,就会输出黑色的字符。这是一个所见即所得的过程。而现在很多的编辑器,也正是为了满足所见即所得的产物。

文本编辑器时代

随着计算机的普及和演进,我们可以看到在屏幕上进行文本编辑成为最基础的诉求。而文本编辑器时代,它和打字机时代最大的区别在于下面几点:

  • 查找替换:能够基于编辑器的内容,进行批量的“查找”和“替换
  • CCP:内容之间的输入,不在局限于字符的一个个输入,还支持了拷贝粘贴复制
  • Undo & Redo:当你输入之后,你可以反悔,还能反悔你的反悔 -- undo & redo
  • Syntax highlighting:同时,文本编辑器时代,你输入的文本样式,就不再局限于白纸黑字,部分的内容还可以支持不同的颜色(高亮)

富文本编辑器时代

富文本编辑器,丰富在格式。我们可以看到它和纯文本之间,又多了一些特性:

  • 支持最基础的文本格式(加粗、斜体、删除线...)
  • 支持基于整个段落的样式设置(背景颜色、字体大小...)
  • 支持非文本的类型(图片、音视频、代码块...)

而正是越来越多丰富的格式,增加了在不同终端上实现 WYSIWYG 的难度。要想保持在不同的媒介之前输入的内容,能够一致的输出(特别是电子屏到纸质的打印),富文本时代无疑增加了不少门槛。

Web 编辑器时代

如何定义 Web 编辑器呢?最基本的特征就是基于浏览器的编辑器。在浏览器里编写文字,我们最常用的就是使用 Textarea 或者 Input 了,但编写富文本内容的话,则需要依赖富文本编辑器的能力。于是,Web 编辑器时代还有一个特点就是编辑器输出的内容往往会和 HTML 的结构性匹配以便于在浏览器端去消费输出的数据

Web 编辑器时代和我们的关系最为密切,例如这篇文档,便是在语雀的编辑器中编辑操作完成。那么回顾下整个富文本编辑器的历史,我们可以大概划分为几个小阶段:

阶段一

在这个阶段中,有不少优秀的编辑器萌芽和得到推广:

这一个阶段的开源编辑器,依赖浏览器特性,主要是使用到了 designModeContentEditablewebkit-user-modifyexecCommand 等特性。早期的编辑器都采用这种方案,但可定制的空间有限。

阶段二

这一个阶段的编辑器,部分使用浏览器的特性、DOM 的 API 来自主实现 SelectionRangeElementTextNode 等,具备一定的可扩展性,但也会有很多难以解决的问题。

二、Web 编辑器时代之难

当我们基于浏览器来设计和开发编辑器的时候,实际上是凌驾于编辑器去开发一个高阶的输入组件。本应该由 HTML 标准来定义的富文本编辑能力,却在不同的浏览器厂商中有不同的实现方法。标准化一直比较模糊,是导致富文本编辑器成为天坑的原因之一。

当然这个原因,很多人已经在 zhihu.com/question/3869 中有所耳闻。这里也不再细说。我们且从所见即所得谈起。

WYSIWYG

这是来自 Wikipedia 的介绍,我们抽出其中的关键词:content(text and graphics)以及 printed、displayed 来看下,富文本编辑器的价值在于能够输入内容且满足在不同终端下的显示一致性

三个定理

而为了满足这个要求,如何才能做到 WYSIWYG 呢?

即:

  • DOM 内容和可视化(Visible)内容能够很好地进行映射。
  • DOM 选择和可视化(Visible)选择能够很好地进行映射。
  • 所有的可视化编辑都能够映射到一个从代数上来说封闭的和完整的可视化内容集合上面。

DOM 是我们能在 HTML 中表述的所有网页页面的集合,而编辑器要处理的行为和 DOM 之间的关系在于能够让用户输入的内容,正确地在 DOM 中表示出来;用户选中的区域,也能在 DOM 中显示出来。在这个过程中,编辑器一般会有一个内存模型的概念用于处理用户行为的整理再驱动 DOM 的更新

ContentEditable is Terrible

我们在实现所见即所得的编辑器的时候,除了使用 Canvas 来完全绘制之外,还有另外一种方案就是使用 ContentEditable 来实现内容的可编辑。但这个属性,并不是那么友好。主要表现在两个方面:

其一,HTML 本身就是非常自由嵌套组合的,用于描述一个样式“粗体斜体”,会有很多种不同的表达方法。例如要实现下面的这一句话:

HTML 的嵌套关系将会有多种实现方案,大致如下:

<strong><em>Baggins</em></strong>
<em><strong>Baggins</strong></em>
<em><strong>Bagg</strong><strong>ins</strong></em>
<em><strong>Bagg</strong></em><strong><em>ins</em></strong>

如果我们要做好编辑器,那么第一个得处理的,就是要解决 DOM 内容和可视化内容之间的映射关系这种映射关系它能允许我们在序列化和反序列化之后都得到一个标准答案。因此,ContentEditable 的基础上,HTML 的可嵌套灵活度又将会成为编辑器的一个障碍 -- 看到的和得到的不一致。

其二,我们在处理选区的时候,选区的边界也是个问题。Window 的 selection 选区,会有 Anchor 和 Focus 两个属性,分别代表的从哪里开始,聚焦在哪里。而这两个属性,又会有 offset 的值。当 Anchor 和 Focus 都在一个 node 节点的时候,并且 offset 是一致的,我们可以认为它就是一个“光标”。反过来讲,光标就是一个特殊的选区。

在 ContentEditable 的基础上,我们会在选区上遇到一个问题就是光标在哪儿的问题。如果 “|” 代表着光标,那么它可能会在下面的几个位置中:

|<p><span><strong>hello...
<p>|<span><strong>hello...
<p><span>|<strong>hello...
<p><span><strong>|hello...

在展示效果上,它都表现为在 hello 这个文字的左边,但实际上它可能是在不同层级的 DOM 的节点处。我们要处理的是,把选区尽可能地计算一致,把这些可能性给磨平,内存模型驱动选区的位置,而主动选区又能解释成为内存模型的描述。

四个关键点

如果我们不想用 Canvas 去完全接管渲染及事件的动作,还想使用浏览器自己就具备的特性,我们可以怎么来解决编辑器的这些坑呢?单聚解决 ContentEditable 的问题来说,有四个关键的技术点:

  • 一个文档模型,并且能够确保 2 个模型在视觉上相等
  • 一种映射关系,DOM 和文档模型之间的映射
  • 基于文档模型的良好编辑指令
  • 能将 DOM 的事件转化为编辑操作

这里用上编辑器的内存模型的话,可以非常好理解。我们定义好一种编辑时候的内存模型,它能够与 HTML 有关联的映射关系。这种映射关系在于,它可能是通过 Shadow DOM 来与真实 DOM 进行转换。

同时,我们可以通过监听真实 DOM 来发起事件,这些事件最后会变成操作我们内存模型的一些指令,例如 insert_text 这样的语句,操作完指令之后,内存模型发生变化,触发虚拟 DOM 的变更从而真实 DOM 发生变化。

Web 编辑器的三个层级

对应编辑器的不同阶段,我们还可以将他们划分为下面三个层级:

对于终极方案来说,就是文字处理器时代。前有 Office Word,现有 Google Doc,还不断有优质的产品持续出现。而他们背后,我们看到的,已经不是一个富文本编辑器了,而是一个文本处理器

对于文本处理器来说,它有着鲜明的特点:

  • 完全抛弃 ContentEditable 的特性
  • 从光标的绘制,到选区的计算,再到内容的排版,均是通过 Javascript 来实现,而非浏览器就具备的能力

三、回归钉钉文档

上述两个章节为编辑器做了简单的历史回顾,那么回到我们自己的产品来看,这里受先要和大家分享的是 -- 编辑器的冰山效应。

编辑器的冰山

如何来看待它是一个编辑器还是一个技术体系我们应该透过表象看本质。有很多人都来找我要编辑器组件,想拿来即用,但我一般都会去咨询一个问题:你们的场景是什么?很多人的回复都很简单,例如“我需要插入图文混排”、“我需要 @ 人”、“我需要能够插入链接”...而事实上,这些都只是编辑器技术的一小部分功能而已。

我们可以这样来总结下,用户能够看到的可能是一些格式的变化,例如加粗、斜体、字体、字号等,还可能是一些具备一定交互能力的组件,例如附件的上传下载、图片的放大缩小甚至裁剪、提及的下拉自动选择和消息发送、链接地址的高亮和跳转...

而这些效果,如果作为一个编辑器组件来说,都是非常冰冷的,你直接可以看到的一个纯组件效果。但实际上处于冰山之下的是我们要去处理的一些用户不需要感知的问题这些是我们编辑器技术的难点所在

  • 例如我们会有一些很重很重的组件:你的编辑器里在嵌入一个表格,用户需要合并单元格,批量格式化甚至添加公式,它是另外一个垂直领域的能力。但它不能非常直白地加入,而是需要从数据模型上去融合,从用户行为上去统一。
  • 例如我们会有一些很难形容的意图识别问题拷贝粘贴是一个非常频繁的操作,我们是无法感知到用户从哪里拷贝的,但却要做到粘贴完的效果符合用户期望。这里难点在于我们需要去做很多的标准化,无论用户从 Word、Email、网页...拷贝,都能粘贴进来,并且可以格式化。
  • 例如我们会有一些很长链路的业务需求要保证用户在你的编辑器输入的内容不丢失,那么是你的编辑器提供暂存能力呢?还是暴露出接口来实现,这些都是处理编辑能力时候要解决的。
  • 例如我们会通过编辑器来完成很厉害的功能:要想实现多人实时编辑能力,在不同的客户端运行一个编辑器,那已经不是一个编辑器所能解决的了,再往下层看,会有网络层的技术 -- 长链接、断网重连、心跳机制等。再再深入,会有服务端的调度能力和 OT 操作与分发。

因此,这里想讲的强调的,编辑器不是一个组件就可以了,而是需要背后的一套体系化的技术来支撑。(这一套技术,我们是可以深入,再包成服务)

前世今生

回顾从做语雀到现在的钉钉文档,在编辑器技术这块领域,我想通过下面一张图来表示不同的阶段:

第一个阶段:文本编辑时代

最初的时候,我是用 CodeMirror 去包装了一个 MarkDown 编辑器的,虽然写源码,但特别受工程师喜欢。但它的技术实现复杂度就在那,和我们去使用一个 Textarea、Input 其实差异不是很大。

更多还是说它能按行处理,字符级元素这些特性。当然往深里去做,也会有难点,例如 WebIDE 也是个方向。

第二个阶段:协同编辑时代

在钉钉文档初期,我们就决定了做多人协同编辑的能力。它是在富文本编辑器的基础上去实现的,但如何实现多人实时编辑能力,在实现的复杂度和功能上对比,它是非常陡峭的。

经过说起来简单,但实现成本会很高。编辑器负责产出编辑动作指令,然后通过网络层与服务器进行 OT 转换然后分发,编辑器客户端的接收到消息之后继续 Merge 再呈现到用户面前。

这里导致我们在整个存储架构上发生了变化存储到服务器的不是一篇文档的全量内容而是一个个的指令因此我们会基于这个基础上去完成历史版本的回滚基于选区的划词评论等技术功能点

第三个阶段:排版计算时代

如今我们已经进入了复杂业务期,将要面临的更多的客户诉求是来自于 Office 的编辑能力,因此我们的一个方向将是排版计算能力。

排版的核心有两个,一个在于实时的分页计算能力一个在于图文混排能力。随着排版能力的深入,几乎整个文档流会在运行被进行内容的拆分,前端非常熟悉的重排重绘在这里被演义到淋漓尽致。

而我们还需要去做的事情是,多了一个重排计算的性能开销前提下,还得保障每一个字符的输入性能。尽可能地保持一个字符在 60ms 之内就能呈现出来。

四、一些有趣的技术点

这一章节,我会分享我们在做编辑器时候,发现的一些有趣的技术要点。编辑器领域虽然枯燥,但实际上兴趣所在,你会发现这不再是一个很垂直的领域,反而是一个要跨越多个维度的技术复合型领域。做一行,爱一行,大概就是说在其中找到一个与你 match 的点。

1、键盘按下的空格与其兄弟姐妹们

当你按下键盘空格键的时候,实际上是输入了最常见的一个空格 ,它的 Unicode 编码为 U+0020 ,在 HTML 中它的字符为 &#32; ,也是 ASCII 32 。 它在文本中代表着一个空白标识,在英语中还起到分割词汇的作用。

键盘按下时候产生的空格并不是唯一的。事实上,它有很多兄弟姐妹,有的前端工程师会非常熟悉,有的也深藏功与名。让我们来了解一下:

TAB

这个应该很熟悉吧,它其实也是空格的一种。我们可以通过 TAB 按键来完成。

NBSP

这一个词第一眼会眼熟,给他补充一下,就变成这样的一个 HTML 字符 &nbsp; ,当你再次记起的时候,我会告诉你。它上面键盘按下的空格,并不是同一个,而是两个不同的符号。

NBSP 的全称应该是 non-breaking space,表示不间断的空间。它的 Unicode 编码为 U+00A0 ,它与键盘按下的空格可以说几乎相同,所占据的空白宽度一致。但它不是一个点那么简单,它会使把一行给打断。

ZWSP

这又是什么?简单来说,你在使用语雀编辑器的时候,已经在不知不觉中使用了大量这种空格了。它的全称为 zero-width space,表示没有宽度,纯属占坑。他就在你的两个文字中间,很小很小的一个不存在的位置,用来给予光标定位。可以说光标一闪一闪的位置就是它了。

它的 Unicode 编码为 U+200B ,HTML 字符为 &#8203; 。它还有几个比较相似的兄弟, U+200CU+200D 它们之间又有点区别,一个允许插入字符,反之则不允许。

由此,我们可以得到两个信息:

  • 空格是有宽度的
  • 空格是有属性的,有的可以折行,有的不允许折行

回头来看,空格的大家庭共有 32 个成员,以上四个如下表:

2、光标其实是一个特殊的选区

Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。

对应 Selection 来说,大家应该了解下它的几个关键的技术术语:

  • 锚点 (anchor)
    锚指的是一个选区的起始点(不同于HTML中的锚点链接,译者注)。当我们使用鼠标框选一个区域的时候,锚点就是我们鼠标按下瞬间的那个点。在用户拖动鼠标时,锚点是不会变的。
  • 焦点 (focus)
    选区的焦点是该选区的终点,当您用鼠标框选一个选区的时候,焦点是你的鼠标松开瞬间所记录的那个点。随着用户拖动鼠标,焦点的位置会随着改变。
  • 范围 (range)
    范围指的是文档中连续的一部分。一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分。用户通常下只能选择一个范围,但是有的时候用户也有可能选择多个范围。“范围”会被作为 Range 对象返回。Range对象也能通过DOM创建、增加、删减。
注:这部分内容摘自 developer.mozilla.org/z

在做编辑器的时候,你会发现一个非常有趣的地方。我们非常熟悉的“光标”,blingbling 闪烁的居然是一个特殊的选区。这个选区的 anchorNode 和 focusNode 是同一个,且他们的 offset 均为 0。

3、回车换行与回车换段

在富文本编辑器中,你的光标在一个段落里面,按下回车,在不同的编辑器有不同的实现,大概是这两种区别:

  • 新增一行,内容还在这一个段落里面。通常是一个 br 占位符。
  • 新增一段,内容新起。通常是一个 p 标签。

Markdown 的语法中,我们探究到回车的时候,一行和一段的实现方式:

  • 在一段里面需要换行,一个回车就可以达到,俗称的 SoftBreak
  • 一段文字与下一段文字中间,会有至少 2 个回车符。

要在富文本实现回车换行、回车换段,需要两个不同的动作才能解决:

  • Enter – 换一段
  • Shift + Enter – 换一行

4、神奇的 Emoji 家族和删除键

在实现编辑器的删除动作时,比较常规的做法是回删一个字符。如何回删呢?即是取了一个字符的长度来做。然而,当你回删到一个 Emoji 的时候,有趣的事情便发生了 -- 你的 Delete 动作之后,看到的是几个乱码字符。如果你还不了解什么是 UAX #29 的话,那么会在写编辑器的时候发现这是多么神奇的一个事情。字符的长度大于 1 了,这样就导致你的删除动作并没有符合预期(坑不是一般大了)

举例:

详见 UAX#29 unicode.org/reports/tr2

It is important to recognize that what the user thinks of as a “character”—a basic unit of a writing system for a language—may not be just a single Unicode code point. Instead, that basic unit may be made up of multiple Unicode code points. To avoid ambiguity with the computer use of the term character, this is called a user-perceived character. For example, “G” + acute-accent is a user-perceived character: users think of it as a single character, yet is actually represented by two Unicode code points. These user-perceived characters are approximated by what is called a grapheme cluster, which can be determined programmatically.

但是,当你深入去了解完这些计算机基础知识之后,也就豁然开朗了 -- 原来写编辑器,还可以这么有趣,学到了不少东西。

五、结语

在本次的分享中,暂时也就摘取了我们在编辑器过程中的 4 个有趣的案例,其实我们还有很多很多,也有很多是未曾挖掘出来的。

很多人和我说,编辑器太难了,自己可能无法胜任。其实,我只想说:编辑器虽然是一个天坑,但并没有现象的那么难。我们刚好有这样的一个氛围 -- 愿意去挖掘和挑战这一个难以标准化的领域,会去追溯其原理本质,探索其中的奥秘,乐于其中。

钉钉文档团队正在招聘,欢迎志同道和的你一起加入。来吧,自荐或者推荐,加入我们(钉钉文档团队)。

我是展新,我在钉钉,以下是我的联系方式,欢迎交流。加我时,请备注(钉钉文档技术分享),感谢 :D

h5.dingtalk.com/zprojec (二维码自动识别)


编辑于 2020-07-06 15:46