前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文带你了解富文本是如何协同工作的

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

作者头像
爱吃大橘
发布2022-12-27 14:43:29
7450
发布2022-12-27 14:43:29
举报
文章被收录于专栏:前端笔记薄前端笔记薄

前言

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

在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一样。有时候,结合实际的妥协也是一种方案。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-10-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 如何解决协同中的合并问题
    • Yjs
      • 起源
    • Yjs
      • Yjs如何保证信息是对的呢
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档