这里我们先说一下,现在市面上有的富文本。
在2021之前大家的认知是这样的:
类型 | 实现 | 典型产品 |
---|---|---|
L0 | 1、基于 contenteditable 2、使⽤ document.execCommand 3、⼏千~⼏万⾏代码 | 早期的轻量级编辑器 |
L1 | 1、基于 contenteditable 2、不⽤ document.execCommand,⾃主实现 3、⼏万⾏~⼏⼗万⾏代码 | CKEditor、TinyMCE Draft.js、Slate ⽯墨⽂档、 |
L2 | 1、不⽤ contenteditable,⾃主实现 2、不⽤ document.execCommand,⾃主实现 3、⼏⼗万⾏~⼏百万⾏代码 | Google Docs Office Word Online iCloud Pages WPS ⽂字在线版腾讯文档 |
类型 | 优势 | 劣势 |
---|---|---|
L0 | 技术门槛低,短时间快速研发 | 可定制的空间有限 |
L1 | 站在浏览器肩膀上,能够满足99%业务场景 | 无法突破浏览器本身的排版效果 |
L2 | 技术都掌握在自己手中,支持个性化排版 | 技术难度相当于自研浏览器、数据库 |
2021年后,国外notion使用了块级编辑器,一切皆组件,一炮走红。之后块级编辑器的思路被认可,做L1的notion一样可以有自己排版布局,再加上现代浏览器国内的不断加强,似乎L1没有足够的动力升级为L2编辑器了。典型的例子有飞书和语雀,他们是有足够人力和时间来升级到L2,但实际上他们引入更多的块级组件。用来实现“一切皆对象”概念,很好的实现了互联网最大的需求,“把信息连接起来”。
这是我们努力的方向,把携程的信息连接起来。
那么,连接信息,自然用到了协同,而且协同有一个最大的问题——如何合并?
首先要了解文档协同中几个概念,协同
、合并
、冲突
。
协同
是指从客户端A和客户端B 同时实时操作同一个文档。如果想要实现协同就需要,将客户端A和客户端B的消息进行实时的同步(尽可能快的传递给对方)。
合并
是指把两人分开操作的数据合并在一起,这里大家可以想一下自己用git。
冲突
是指两份数据,相同位置不同修改造成的冲突,想必大家都有过git合并过程中产生冲突(conflict)的经历吧,应该好理解的。
合并需要一个规则,且此规则应避免人工干预。而我们在协同编辑文档的时候,没有遇到过处理矛盾的时候,这是如何实现的呢?
CRDT(Conflict-free replicated data types的缩写) 的正式定义出现在 Marc Shapiro 2011 年的论文 Conflict-free replicated data types 中(而2006 的Woot可能是最早的研究)。提出的动机是因为设计实现 最终一致性(Eventual Consistency)
的冲突解决方案很困难,很少有文章给出设计指导建议,而随意的设计的方案容易出错。
所以这篇文章提出了简单的、有理论证明的方案来达到最终一致性,也就是 CRDT。(PS: 其实 Marc Shapiro 在 2007 年就写了一篇 Designing a commutative replicated data type,2011 年将 commutative(可交换的) 变成了 conflict-free(无冲突的),在其定义上扩充了 State-based CRDT(基于状态的CRDT)
在介绍实现原理前,我们先介绍一下,我们使用的协同仓库Yjs。
Yjs是基于CRDT(Conflict-free replicated data type 维基百科) 实现的协同库。Yjs 对使用者提供了如 YText、YArray 和 YMap 等常用数据类型(即所谓的 Shared Types),下面是一个简单的demo:
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ymap = ydoc.getMap()
ymap.set('keyA', 'valueA')
const ydocRemote = new Y.Doc()
const ymapRemote = ydocRemote.getMap()
ymapRemote.set('keyB', 'valueB')
const update = Y.encodeStateAsUpdate(ydocRemote)
Y.applyUpdate(ydoc, update)
console.log(ymap.toJSON()) // => { keyA: 'valueA', keyB: 'valueB' }
我们可以看到,ymap
和 ymapRemote
的操作成功合并为 { keyA: 'valueA', keyB: 'valueB' }
至此,我们关于yjs部分先告一段落。后面还会再讲。
那么,协同文档中又是如何接入yjs呢?
因为不⽤ document.execCommand,⾃主实现了文档操作。我们文档拥有自己mvc模式,model层有8种基础的原子操作,所有操作都可以分解成这8种,yjs存储的其实就是这些操作,前端展示的时候,会一步步重现这些操作,形成用户可以看到的文档
insert_node 插入节点
insert_text 插入文本
merge_node 合并节点
move_node 移动节点
remove_node 删除节点
remove_text 删除文本
set_node 设置节点
split_node 分割节点
export function withYjs<T extends Editor>(
editor: T,
sharedType: SharedType,
{ synchronizeValue = true }: WithYjsOptions = {}
): T & YjsEditor {
const e = editor as T & YjsEditor;
e.sharedType = sharedType;
SHARED_TYPES.set(editor, sharedType);
LOCAL_OPERATIONS.set(editor, new Set());
if (synchronizeValue) {
setTimeout(() => YjsEditor.synchronizeValue(e), 0);
}
const applyEvents = (events: Y.YEvent[]) => applyRemoteYjsEvents(e, events);
sharedType.observeDeep(applyEvents);
const { apply, onChange, destroy } = e;
e.apply = (op: Operation) => {
trackLocalOperations(e, op);
apply(op);
};
e.onChange = () => {
applyLocalOperations(e);
onChange();
};
e.destroy = () => {
sharedType.unobserveDeep(applyEvents);
if (destroy) {
destroy();
}
};
我们看到他实现了apply函数,apply函数传入的参数就是8种原子操作。
我们拿到原子操作后,如何转换为yjs的共享数据(sharedType)类型呢?
我们用insert_text为例子:
/**
* Applies a insert text operation to a SharedType.
*
* @param doc
* @param op
*/
export default function insertText(
doc: SharedType,
op: InsertTextOperation
): SharedType {
const node = getTarget(doc, op.path) as SyncElement;
const nodeText = SyncElement.getText(node);
invariant(nodeText, 'Apply text operation to non text node');
nodeText.insert(op.offset, op.text);
return doc;
}
在这里,SyncElement 对应 slate内部的 Element 类型, YText(nodeText )对应 slate内部的Text类型。
这里说一下,slate中Text相关的操作是通过String所自带的函数实现的,比如splice。YText为了内容不被一下子覆盖掉,也做了类似的处理,在他的合并函数中,有如下代码:
/**
* @param {number} offset
* @return {ContentString}
*/
splice (offset) {
const right = new ContentString(this.str.slice(offset));
this.str = this.str.slice(0, offset);
.....
return right
}
同样的,其他原子操作也有对应的处理。
那么输入有了,撤销呢?
yjs也提供了redo接口,但是目前有些问题在,比如回撤以后重复,而且它没有独立的撤销栈,所以我们使用的另一套回撤实现。
我们建立了独立的撤销栈(undo)和重做栈(redo),并把用户输入的原子操作放入撤销栈,撤销后的操作再放入重做栈。当用户撤销时候,我们把 undo 栈最上面的操作取出,并反转执行。
反转的具体对应表,是这样的:
输入 | 撤销 | 备注 |
---|---|---|
insert_node | remove_node | |
merge_node | split_node | |
insert_text | remove_text | |
remove_text | insert_text | |
move_node (path1,path2) | move_node(path2,path1) | 移动的路径反转 |
set_node | set_node | |
set_selection | set_selection | 路径反转 |
split_node | merge_node |
根据 CAP 定理,对于一个分布式计算系统来说,不可能同时完美地满足以下三点:
系统如果不能在时限内达成数据一致性,就意味着发生了分歧的情况,必须就当前操作在C和A之间做出选择,所以完美的一致性
与完美的可用性
是冲突的。一旦要求完美的一致性
,你会想到——git~
CRDT 不提供完美的一致性
,它提供了 强最终一致性 Strong Eventual Consistency (SEC) 。这意味着客户A文档无法立即反映客户B文档上发生的状态改动,但A B 同步后它们二者就可以恢复一致性。而强最终一致性
不与 可用性
、分区容错性
冲突的,所以 CRDT 同时提供了这三者,提供了很好的 CAP 上的权衡。
CRDT 有两种类型:
Op-based(基于操作) CRDT
和 State-based(基于状态) CRDT
,此处仅介绍 Op-based
的思路,因为yjs就是这样实现的。
Op-based CRDT 的思路为:如果两个用户的操作序列是完全一致的,那么最终文档的状态也一定是一致的。所以索性让各个用户保存对数据的所有操作(Operations),用户之间通过同步 Operations 来达到最终一致状态。
但我们怎么保证 Op 的顺序是一致的呢,如果有并行的修改操作应该谁先谁后?答案是按照用户加入时的id进行排序。
那他具体如何自动的解决冲突呢?
ymap.set('keyA', 'valueA');
ymap.set('keyA', 'value-AA-');
ymapRemote.set('keyA', 'valueAAR');
ymap.set('keyA', 'value-AA');
const idR: number = ymapRemote.doc?.clientID || 0;
const id: number = ymap.doc?.clientID || 0;
console.log(idR - id, ymap.toJSON());
// (idR - id) > 0 ---- { keyA: 'valueAR' }
// (idR - id) < 0 ---- { keyA: 'value-AA' }
显然,yjs是按照clientID的顺序,来实现覆盖的。接下来,我去翻源码也证实了这一假设。
// Write higher clients first ⇒ sort by clientID & clock and remove decoders without content
lazyStructDecoders = lazyStructDecoders.filter(dec => dec.curr !== null);
lazyStructDecoders.sort(
/** @type {function(any,any):number} */ (dec1, dec2) => {
if (dec1.curr.id.client === dec2.curr.id.client) {
const clockDiff = dec1.curr.id.clock - dec2.curr.id.clock;
if (clockDiff === 0) {
// @todo remove references to skip since the structDecoders must filter Skips.
return dec1.curr.constructor === dec2.curr.constructor
? 0
: dec1.curr.constructor === Skip ? 1 : -1 // we are filtering skips anyway.
} else {
return clockDiff
}
} else {
return dec2.curr.id.client - dec1.curr.id.client
}
}
);
yjs会按照clientID的排序来,划重点,和时间没有关系
,一个clientID可能比较晚产生,但是他可能会排在前面。当然,一次连接中,这个顺序是固定的。这时候,可能有人要说,这不对了。这样岂不是,一个人的数据永远会被另一个覆盖~~
先别担心,因为实际使用中,双方是持续不断输入的,绝大多数情况下,不会在同一次合并中,同时修改一个值。当然,如果真的触发了,则会覆盖。至于,做到不覆盖又体验良好,那恐怕只能人工了,像git一样。有时候,结合实际的妥协也是一种方案。