一文带你了解富文本是如何协同工作的

2022-12-27 14:43:29 浏览数 (1)

前言

这里我们先说一下,现在市面上有的富文本。

在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)的经历吧,应该好理解的。

合并需要一个规则,且此规则应避免人工干预。而我们在协同编辑文档的时候,没有遇到过处理矛盾的时候,这是如何实现的呢?

Yjs

起源

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:

代码语言:javascript复制
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' }

我们可以看到,ymapymapRemote 的操作成功合并为 { keyA: 'valueA', keyB: 'valueB' }

至此,我们关于yjs部分先告一段落。后面还会再讲。

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 分割节点

代码语言:javascript复制
export function withYjs<T extends Editor>(
  editor: T,
  sharedType: SharedType,
  { synchronizeValue = true }: WithYjsOptions = {}
): T &amp; YjsEditor {
  const e = editor as T &amp; 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为例子:

代码语言:javascript复制
/**
 * 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为了内容不被一下子覆盖掉,也做了类似的处理,在他的合并函数中,有如下代码:

代码语言:javascript复制
  /**
   * @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

Yjs如何保证信息是对的呢

  • 可用性(Availability): 每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据
  • 分区容错性(Partition tolerance): 以实际效果而言,分区相当于对通信的时限要求

根据 CAP 定理,对于一个分布式计算系统来说,不可能同时完美地满足以下三点:

  • 一致性(Consistency): 每一次读都会收到最近的写的结果或报错;表现起来像是在访问同一份数据

系统如果不能在时限内达成数据一致性,就意味着发生了分歧的情况,必须就当前操作在C和A之间做出选择,所以完美的一致性完美的可用性是冲突的。一旦要求完美的一致性,你会想到——git~

CRDT 不提供完美的一致性,它提供了 强最终一致性 Strong Eventual Consistency (SEC) 。这意味着客户A文档无法立即反映客户B文档上发生的状态改动,但A B 同步后它们二者就可以恢复一致性。而强最终一致性不与 可用性分区容错性冲突的,所以 CRDT 同时提供了这三者,提供了很好的 CAP 上的权衡。

CRDT 有两种类型:

Op-based(基于操作) CRDTState-based(基于状态) CRDT,此处仅介绍 Op-based 的思路,因为yjs就是这样实现的。

Op-based CRDT 的思路为:如果两个用户的操作序列是完全一致的,那么最终文档的状态也一定是一致的。所以索性让各个用户保存对数据的所有操作(Operations),用户之间通过同步 Operations 来达到最终一致状态。

但我们怎么保证 Op 的顺序是一致的呢,如果有并行的修改操作应该谁先谁后?答案是按照用户加入时的id进行排序。

那他具体如何自动的解决冲突呢?

代码语言:javascript复制
    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的顺序,来实现覆盖的。接下来,我去翻源码也证实了这一假设。

代码语言:javascript复制
    // Write higher clients first ⇒ sort by clientID &amp; 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一样。有时候,结合实际的妥协也是一种方案。

0 人点赞