通常使用 L1 方案的富文本编辑器都是基于浏览器自身 contentEditable 属性实现的,共用了浏览器的光标和选区;对数据层进行了抽象,依赖 DOM 对内容进行渲染。
L1 富文本编辑器的重点在于实现视图层和数据层的双向绑定,确保视图层的改动。
本文将对以下三个的 L1 富文本编辑器进行横向比较。
三者对于视图层绑定到数据层的实现各不一样。
slate-react 进行渲染,但是也可以用 Angular、Vue 等前端框架实现视图层的渲染。lexical-react 进行渲染,但是并不拘泥于特定框架实现视图层。由于 Lexical 的数据结构是 Map 映射集合而不是普通对象,在渲染时需要先使用 reconcileNode() 这个方法进行节点映射集合的遍历。ProseMirror 编辑器实例,使用 class 实现,pm.doc 代表文档的根 Node 节点,pm.sel代表文档的当前选区。
ProseMirror 的文档节点可以分为三大类型,Node、Fragment 和 Mark,分别代表基本节点、基本节点数组和节点标记。
Node 可以拓展成为 TextNode,或者按照给定的 schema 拓展成为特定的 NodeType 进而用于代表段落、标题等。Fragment 类似一个容器,主要是将其 content 属性中的基本节点数组包起来。Mark 类似一个占位符,用来表现某一个 TextNode 所含有的特征。定义:packages/slate/src/create-editor.ts
Slate 的实例对象,与 ProseMirror 用类实现不同,Slate 采用了纯对象表示编辑器实例。
Slate 的早期也是基于 class 实现的,但是从 Immutable.js 切换到 Immer 的重构后,转向了使用纯 JS 对象作为数据结构。
该实例节点就是文档的根节点,可以从 editor.children 获取到整个文档所有的子节点;editor.selection 代表文档的当前选区。
定义:packages/lexical/src/LexicalEditor.ts
Lexical 编辑器实例,使用 class 实现,editor._editorState._nodeMap 代表文档的节点合集,editor._editorState._selection 代表文档的当前选区。
Lexical 的独特之处
Lexical 的节点是通过 Map 存储的(如下图),这和 Slate、ProseMirror 的树状数据结构有本质差异,主要体现在单个节点修改的效率和内存占用上。
ProseMirror 通过 poll的方式确定选区,也即轮询。每隔 100ms 就会对当前光标位置进行一次轮询,调用 readFromDOM() 从 DOM 读取真实选区并设置到编辑器实例的sel属性中。
通过 window.getSeleciton() 获取的真实选区会被转化成 TextSelection 并存储在 sel.range 中。此外还存储了上一次的真实选区在 sel 中,目的是用来比较判断 DOM 选区是否发生了变化。若没有发生变化,则不需要执行 readFromDOM()。
PS: 因为轮询更新选区的特性,在 demo 中快速输入中文时出现了光标的跑到行尾的问题。
定义:packages/slate/src/interfaces/editor.ts
Slate 的选区是原生浏览器的 Selection 之上的一层抽象,形如:
type Path = number[]
interface Point {
path: Path
offset: number
}
interface Range {
anchor: Point
focus: Point
}
interface Selection = Range | null
Slate 强大的地方在于它将 DOM 渲染出来的节点的可选区域抽象成 Path、Point、Range 等数据结构,一旦理解了它的设计逻辑,就能够很方便地定位到编辑器内某一个特定的范围,从而轻松实现插入、删除、移动等节点变换操作。
Lexical 的选区包含 anchor 和 focus 两个点,并且在每个 Point 中存储了一份对当前 _seletion 的引用(循环引用)。
定义:packages/lexical/src/LexicalSelection.ts
规范化(Normalize)处理决定了一个编辑器的形状是否稳定。剪贴板中的 HTML 千奇百怪、不可预测,在富文本编辑器中粘贴时,未知的 HTML 处理起来十分棘手。兜底的方法是将 HTML 转成纯文本,但是这样就显得不够“富文本”了。
采用了 Schema 定义文档的形状:SchemaSpec 类定义文档支持的 marks 和 nodes,Schema 类接收 SchemaSpec 为参数,并定义文章的形状。
SchemaItem 是所有的 NodeType 的父类,也就是说,所有的元素都继承了 SchemaItem.register() 方法用于注册各元素的规则。并且是根据事件进行触发,对所有继承了 SchemaItem 类的元素节点进行 register 注册相应命名空间 namespace 的某个类型的 name ,并指明对应要做的操作,这样就能够在不同的处理步骤(如解析 DOM 节点)中对各个节点进行特定的处理。
Slate 在早期(v0.47 前)使用了和 ProseMirror 一样采用了 Schema 的形式,用 JS 模板对象限定了不同类型的操作。但是 v0.50 后 Slate 将组件进行了插件化拆分,每个组件都作为一个插件有一套独立的处理逻辑,通过组件插件的 normalizeNode 可以对组件进行修剪等处理操作。
Lexical 采用的规范化处理方式包括 _htmlConversions (负责剪贴板内容的粘贴)、LexicalUpdates (负责合并同类型文本节点)等。
后者的 Update 是 Lexical 中定时处理步骤,每当 editor._observer (即 MutationObserver)监听到 DOM 节点发生变化,就会批量更新对应的虚拟节点,实现数据的同步。在 Update 的过程中,就会对编辑器内容进行规范化操作。
这三款编辑器都支持使用 Yjs 实现协同编辑,底层满足 CRDT 的数据结构模型,ProseMirror 和 Slate 均是基于操作实现 CRDT 的,而 Lexical 则是基于状态实现 CRDT 的。
ProseMirror 中操作变化都被当作 Operation 存储起来,在每个 requestAnimationFrame (宏任务)的循环中通过 pm.flush 被批量调用。
Operation 决定了更新 DOM 的最少步骤,存储在 pm.operation 中。
class Operation {
constructor(pm) {
this.doc = pm.doc
this.sel = pm.sel.range
this.scrollIntoView = false
this.focus = false
this.composingAtStart = !!pm.input.composing
}
}
Slate 是基于 Operation 的操作的。每个原子操作都通过了 editor.apply 去执行,实现上和 ProseMirror 类似,不过是通过 Promise.resolve() (微任务)的循环中通过 editor.onChange() 被批量调用。
定义:packages/slate/src/transforms/general.ts
Operation 的种类包括:insert_text、remove_text、insert_node、merge_node、 remove_node、move_node、set_node、split_node。
Lexical 中存储的数据结构是散列表映射,因此对于这个数据结构来说,只需要进行映射记录之间的更新即可让数据实现同步。
Lexical 中使用了 CollabElementNode 作为共享数据类型的存储,通过 $createCollabNodeFromLexicalNode() 函数将普通的节点转化为共享数据类型节点,该节点上会挂载一个实现了 Y.Map 类的 _map 的属性。
本文通过对比不同富文本编辑器框架的一些实现,分析了编辑器实例、选区、规范化、原子操作等。
ProseMirror 登场比较早,使用文档详尽,插件丰富,功能强大,但是 API 略显晦涩。
Slate 最受欢迎(star 数领先),支持纯 JS 对象作为文档结构、个性化组件、丰富的 API、上手成本低,是很多编辑器的灵感来源,如语雀、Aomao。
Lexical 新兴力量,背靠 Facebook,映射结构、可以基于状态实现协同。此外,它的 DOM 节点不受外部插件影响以及原生支持 React 18+ 的 Cocurrency 实现局部渲染性能优化。
以上三者均未实现 1.0 的突破,未能保证稳定,使用时还需要进行一些额外的开发。