富文本的发展史

200 阅读6分钟

发展历史

通常大家把编辑器技术分为3个阶段:L0、L1、L2。每个阶段都比上一阶段定制程度更高,由浏览器导致问题也更少。

类型描述典型产品
L01.基于contenteditable 2.使用document.execCommand早期轻量级编辑器CKEditor 1-4(2008)UEditor(2012)
L11.基于contenteditable 2.不使用document.execCommand,自主实现qull.js(2012)Slate.js(2016)Draft.js(2015)CKEditor 5
L21.不使用contentediable 2.不使用document.execCommandGoogle docus(2010)腾讯文档

L0

L0 阶段的编辑器主要就是依赖于 contentEditable API 来实现功能的。首先,任何 HTML 元素加上 contenteditable="true" 之后里面的内容都可以被编辑,然后,如果想点击某个按钮来操控这些内容,则可以通过浏览器提供 document.execCommand API 来实现。document.execCommand 支持的操作类型多种多样,包括加粗、改背景、绑定链接、复制、剪切等等。

优势

  • 技术门槛低
  • 基于浏览器原生编辑,输入非常流畅

缺点

  • 直接操作html,容易发生xss攻击
  • 不可以预测的交互,容易出现数据混乱(拖拽、复制粘贴、删除)
  • 相同操作不同浏览器可能有不同的实现(比如基本的加粗、斜体、Enter),很难实现表现和数据完全统一(选区、光标位置等)
  • 组合输入问题(操作系统、输入法、浏览器 )
  • 无法实现协同编辑

未来

editContext API - W3C 2021/12 公开工作草案

L1

传统模式:

  • DOM树基于数据,调用各种DOM API 进行操作
  • 比如:tinyMCE ,(自行封装实现效果,通过工具栏调用)

MVC模式:

  • 数据和渲染分离,有抽象的数据模型来描述富文本编辑器的内容与状态。

抽象modal:

Quill.js的modal(Quill管这种modal叫Delta):

Quill 抛弃了 DOM 的节点树的层次,因此完全看不出包裹文字的标签和节点关系,只有一个扁平化后的数组 ops。

Delta只有3种动作和1种属性:

  • Insert:插入
  • delete:删除
  • retain:保留
  • 使用可选的 attributes 属性来标记内容的一些特性
"ops": [
        {
            "insert": "Hello "
        },
        {
            "attributes": {
                "bold": true
        },
            "insert": "Quill"
        },
        {
            "insert": "!"
        }
    ]

比如Slate编辑器的modal:

[{
    type: 'paragraph',
    children: [
      {
        text: '请编辑固定文案确保报告可读性',
        color: 'rgb(215, 215, 215)',
      },
      {
          text:'A'
      }
    ]
 }]

View - 将 Modal 渲染出来

View 层类似 vue 中的 render,将 Modal 数据给渲染出来,渲染出来的内容包括编辑器内的内容和选区等。这样一来,就能够自己决定什么样的内容输出什么样的 DOM 结构,不依靠浏览器的实现,从而避免 L0 中 DOM 结构多样性的问题

Selection - 选区与光标

无论是 L0 还是 L1 阶段,选区都需要在原生 Selection API 的基础上进行封装来实现。原生的 Selection 对象是由多个 Range 对象组成的,Range 对象内包含以下四个属性:

  • anchorNode :返回该选区起点所在的节点
  • anchorOffset:返回一个数字,其表示的是选区起点在 anchorNode 中的位置偏移量
  • focusNode:返回该选区终点所在的节点
  • focusOffset:返回一个数字,其表示的是选区终点在 focusNode 中的位置偏移量

相比 L0 阶段,L1 阶段的编辑器会将 Modal 的一些数据封装到 Selection 对象中去。比如:

Slate - Selection

它 的 Selection 对象参考了原生的实现,有 anchor 和 focus 两个对象

{
"anchor": { "path": [1,0], "offset":8 },
"focus": { "path": [1,0], "offset":10 }
}

L1 阶段的编辑器对 Selection 的封装方式使得选区也有一种数据结构,从而使得同一份数据结构有唯一的渲染,避免 L0 中选区的问题

Commands

L1 阶段的编辑器摒弃了浏览器的 document.execCommand,从而完全自己来实现对编辑器内容的操作,它能在很大程度上避免 L0 中浏览器操作的不确定性。

事件监听

image.png 如果用户点击编辑器上的按钮,那么这种操作就是非常确定的。如果用户在编辑区域进行输入的话,那么可以通过 beforeinput 事件知道用户准备输入什么。beforeinput 他除了能拿到当前改变的值,还能通过inputType知道当前输入的类型。

beforeinput 是一个比较新的 DOM 事件,对于还不支持的浏览器,可以用 keydown/keypress 来兼容。

DOM 变更监听

除了事件监听,另一个方式就是 DOM 变更监听。我们依然使用 contenteditable="true" 来使得编辑器内容是可以被编辑的。然后使用 Mutation Observer 来监听编辑器内容的变化,接着根据内容的变化反推用户的操作,从而修改 Modal。

mutation observer 无法监听所有的改动,比如:回车 缺陷:

  • 反推的过程完全是根据经验,某些情况没考虑到的情况
  • 可能发生错误的推测,造成错误的渲染

Slate 的 Commands 实现

Slate 完全重写了 Commands API,它是结合事件监听和 Mutation Observer 一起来实现的,它自己定义了一套 Transform API 去更改 Modal,使用者可以自定义 Modal 到 DOM 的渲染逻辑

劣势

  • 稳定性
  • 不易维护
  • 粘贴样式不识别

L2

Google Docs 的问世开创了 L2 级别编辑器的时代,它完全不依赖 Content Editable API,包括选区、光标等,都是是自己绘制的,甚至自己实现了一个基于元素和绝对定位的排版引擎,基本上脱离了浏览器自身的大部分排版规则,可以说是非常复杂了。这样做带来的好处是显而易见的:

  • 所有浏览器无论做什么操作进行选区的选中,都能够保持一致性,例如在不同浏览器中双击选中,可能有些浏览器选中的是一个词语,有些浏览器选中的是一句话,这是由浏览器自身逻辑决定的,而自己绘制则完全能避免这些问题;
  • 不会有光标的兼容性问题,比如光标位置问题偏移、光标显示不正常等等,自行绘制光标无论是在哪个浏览器下,都是一致的;
  • 不依赖于浏览器大部分的排版,虽然说 L1 阶段我们可以控制渲染的 DOM 结构和 CSS,但仍然依赖浏览器去排版绘制,像 Google Docs 这种直接基于绝对定位等少量的浏览器特性去自行计算各元素的位置,进行排版,就能极大程度上避免浏览器在排版上的问题。

多人协同

Operations - 多人协同必备

Operations 就是记录了一系列原子化操作的 数组。

slate/quill/CKEditor 5都用 OT(Operational Transformation) 或类似的技术,将操作转化成 OP (operations),发送到协作服务,再转发给其它在线用户。所以都具备原子化的操作 API,所有的高级操作都通过原子化 API 完成,实时协同只需要将这些原子化 API 的调 用信息转化成 OP 即可

多人协同技术演进