Yjs实现简单的协同编辑demo

协同编辑通常指多个参与方共同合作完成某项任务或目标的行为,它一直是一个技术上具有挑战的领域,其中数据一致性问题尤为复杂,不过现如今已经有比较成熟的方式来解决它,本文主要介绍Yjs实现协同编辑。

Yjs是一种高性能的基于状态的CRDT(State-based CRDT,CvRDT),常用于构建自动同步的协作应用程序。它将其内部 CRDT 模型公开为可并发操作的共享数据类型。共享数据类型通常有类似于Map或者常见数据类型Array等,在这些数据类型发生改变时,会触发事件,并自动合并且不会发生合并冲突,即实现最终一致性。而且Yjs不会对使用的网络有任何假象,只需要更改能够最终到达,就能将数据同步,不在乎到达顺序。

实现基于状态的CRDT

在使用Yjs前,先来回顾一下CvRDT的定义并实现。

State-based Object的定义包括:

  • State 节点的内部状态的类型。
  • state_zero 内部状态的初始值。
  • 定义了 state 之间的顺序。
  • update(s, u) 定义 state 的更新方式。
  • merge(s, s') 函数可以用于合并两个状态得到新状态。
  • 副本之间通过传递自己的 state,并进行 merge 操作来达到一致性。

根据定义CvRDT类如下:

js 复制代码
class CvRDT {
  constructor(stateZero, orderFn, updateFn, mergeFn, getTotalValueFn) {
    this.state = stateZero;  // 内部状态,初始为 state_zero
    this.orderFn = orderFn;  // 定义 state 之间的顺序的函数
    this.updateFn = updateFn;  // 定义 state 的更新方式
    this.mergeFn = mergeFn;  // 定义两个 state 合并的方式
    this.getTotalValueFn = getTotalValueFn;  // 定义计算总和的函数
  }

  // 更新 state
  update(u) {
    this.state = this.updateFn(this.state, u);
  }

  // 与其他 CRDT 实例合并并返回合并后的总和
  merge(otherCRDT) {
    this.state = this.mergeFn(this.state, otherCRDT.state);
    return this.getTotalValueFn(this.state);
  }

  // 获取当前状态
  getState() {
    return this.state;
  }

  // 输出当前状态,用于调试
  debug() {
    console.log(this.state);
  }
}

初始值及辅助方法:

js 复制代码
const stateZero = new Map();

const orderFn = (stateA, stateB) => {
  // 定义两个 state 之间的顺序
  return true;
};

const updateFn = (state, u) => {
  const newState = new Map(state);
  if(orderFn(newState,state)){
    newState.set(u.node, (newState.get(u.node) || 0) + u.value);
    return newState;
  }
  return state;
};

const mergeFn = (stateA, stateB) => {
  const newState = new Map(stateA);
  for (const [key, value] of stateB) {
    newState.set(key, Math.max(newState.get(key) || 0, value));
  }
  return newState;
};

const getTotalValue = (state) => {
  let total = 0;
  for (const value of state.values()) {
    total += value;
  }
  return total;
};

实例创建:

js 复制代码
// 创建两个 G-Counter 实例
const counter1 = new CvRDT(stateZero, orderFn, updateFn, mergeFn, getTotalValue);
const counter2 = new CvRDT(stateZero, orderFn, updateFn, mergeFn, getTotalValue);

// 更新两个实例的值
counter1.update({ node: 'node1', value: 1 });
counter1.update({ node: 'node1', value: 1 });
counter2.update({ node: 'node2', value: 1 });

// 合并两个实例
console.log("Total in Counter 1 after merge:", counter1.merge(counter2)); // 输出 3
console.log("Total in Counter 2 after merge:", counter2.merge(counter1)); // 输出 3

// 输出合并后计数器的状态
counter1.debug();  // 输出 Map { 'node1': 2, 'node2': 1 }
counter2.debug();  // 输出 Map { 'node2': 1 ,'node1': 2 }

从merge操作中看出,不论合并顺序最终结果都是一致,虽说两个Map中的顺序不太相同,但在数据层面上是相同的并不影响最终一致。

协同编辑

目标效果

这里采用浏览器及无痕模式下的浏览器模拟多用户登录,实现在线用户展现、展现非本地用户光标、协同增删图形等功能。

协同编辑器

  1. 实时性:操作能够短时间内同步给其他用户(indexedDB、webrtc、websocket、Provider)。
  2. 冲突解决:多用户对于同一内容的编写,能够合理解决对应冲突。

需求调研

  1. 产品:Notion、飞书文档、我来

  2. 协同部分

    • Yjs
      • provider:面向连接:y-webrtc、y-websocket(数据同步)、面向存储:y-indexedDB(本地弱网存储)、y-redis、面向协议:y-protocal
  3. 光标、在线用户、数据同步、undo/redo

    • 光标 clientX、clientY
    • Yjs awareness
    • Yjs Ydoc
    • Yjs UndoManager

技术方案

  1. y-websocket(数据转发、数据同步)+ Yjs 实现基于CRDT(冲突解决)的分布式数据同步
  2. 状态管理、用户标识

功能与实现方案分层

  1. 用户信息、光标借助awareness(local、remote)
  2. 数据同步层,yjs Y.doc实现,选择Y.Array数据结构来处理

具体实现

定义CollaborativeDoc类,为每个用户进入时创建一个实例,并借助y-websocket连接至同一服务器实现数据同步。

  • 处理在线用户(websocket创建连接、awareness存储用户信息)

    创建WebsocketProvider实例,只要ydoc未被销毁,更改就会通过连接的服务器同步至其它客户端

CollaborativeDoc.tsx 复制代码
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";
// 用户名颜色
const randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
export class CollaborativeDoc {
  provider: WebsocketProvider;
  awareness: any;

  constructor(userName: string) {
    this.provider = new WebsocketProvider(
      "ws://localhost:1234",
      "test",
      this.ydoc
    );
    this.awareness = this.provider.awareness;
    this.awareness.setLocalState({
      //用户信息
      user: {
        name: userName,
        color: randomColor,
      },
      //用户光标位置
      cursor: {
        x: undefined,
        y: undefined,
      },
    });
    
    // 本地用户进入
    addUser(name: string|null,color: string) {
        this.awareness.setLocalStateField("user", {
          name,
          color,
    });
    // 同步清除
    destroy() {
        this.provider.disconnect();
      }
  }
}
  • 确认用户身份、监听用户加入
App.tsx 复制代码
import { useEffect, useRef, useState } from "react";
import { CollaborativeDoc } from "./Yjs/CollaborativeDoc";
import "./App.css";

const randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16);

function App() {
  // 存储在线用户的状态
  const [users, setUsers] = useState(new Map());

  const collaborativeRef = useRef<CollaborativeDoc | null>(null);

  useEffect(() => {
  // 模拟用户登录存储userName
    if (localStorage.getItem("userName")) {
      localStorage.getItem("userName");
    } else {
      localStorage.setItem(
        "userName",
        "John" + "-" + (Math.random() * 10).toFixed(0) + "号"
      );
    } 
    const userName = localStorage.getItem("userName");
    // 设置本地用户信息
    collaborativeRef.current?.addUser(userName, randomColor);

    // 创建实例,建立连接
    const collaborative = new CollaborativeDoc(JSON.stringify(userName));
    collaborativeRef.current = collaborative;
    // 监听其他用户加入,更新在线用户
    collaborative.awareness.on("change", (updates: any) => {
      setUsers(collaborativeRef.current?.awareness.getStates());
    });
    return ()=>{
    collaborative.destroy();
    }
}, []);
return (
    <>
      <div className="container">
        <div className="user-name" style={{ color: randomColor }}>
          当前用户名:
          {
            collaborativeRef.current?.provider.awareness.getLocalState()?.user
              ?.name
          }
        </div>
        <div className="online-user" style={{ backgroundColor: "#f0f0f0" }}>
          在线用户:
          { Array.from(users).map(([id, state]) => {
              return (
                <div key={id} style={{ color: state.user.color }}>
                  {state.user.name}
                </div>
              )
            })}
        </div>
      </div>
     </>
     );
}
export default App;
  • 光标实现

CollaborativeDoc类提供光标更新、操作处理

CollaborativeDoc.tsx 复制代码
  // 光标更新
  updateCursor(x: number | undefined, y: number | undefined) {
    this.provider.awareness.setLocalStateField("cursor", { x, y });
  }
  // 清除本地操作引发的状态改变
  onAwarenessChange(callback: (state: any) => void) {
    this.provider.awareness.on("change", (changed: any, origin: any) => {
      if (origin === "local") return;
      callback(this.provider.awareness.getStates() as any);
    });
  }
  • 监听鼠标事件更新鼠标位置
App.tsx 复制代码
import { useEffect, useRef, useState } from "react";
import { CollaborativeDoc } from "./Yjs/CollaborativeDoc";
import "./App.css";

const randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16);

function App() {
  // 存储光标位置等意识状态。
  const [awareness, setAwareness] = useState<any>(new Map());

  const collaborativeRef = useRef<CollaborativeDoc | null>(null);

  useEffect(() => {
    // 监听光标位置变化,同步到 awareness
    const handleMousemove = (e: MouseEvent) => {
      collaborativeRef.current?.updateCursor(e.clientX, e.clientY);
    };
    window.addEventListener("mousemove", handleMousemove);
    
    const handleMouseout = () => {
      collaborativeRef.current?.updateCursor(undefined, undefined);
    };
    window.addEventListener("mouseout", handleMouseout);
    collaborativeRef.current?.onAwarenessChange((state) => {
      setAwareness(new Map([...state]));
    });

    return () => {
      // 用户退出以及移除事件监听
      collaborative.destroy();
      collaborativeRef.current = null;
      window.removeEventListener("mousemove", handleMousemove);
      window.removeEventListener("mouseout", handleMouseout);
    };
  }, []);
return (
    <>
     <div>
        // 光标样式
        {Array.from(awareness)
          .filter(([id]: number[]) => {
            return id !== collaborativeRef.current?.ydoc.clientID;
          }) // 过滤掉自己的光标
          .filter(([, state]) => {
            return state.cursor.x !== undefined;
          }) // 过滤跑出网页的用户
          .map(([id, state]) => {
            return (
              <div
                key={id}
                style={{
                  position: "fixed",
                  left: state.cursor.x,
                  top: state.cursor.y,
                  color: state.user.color,
                  pointerEvents: "none",
                }}
              >
                <IconCursor />
                {state.user.name}
              </div>
            );
          })}
      </div>
     </>
     );
}
export default App;
  • 数据同步处理(增删操作)

选取Array的数据结构进行存储,CollaborativeDoc类中通过Y.doc创建Y.Array,并且提供监听、增加、删除Array元素的方法

CollaborativeDoc.tsx 复制代码
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";

const randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16);

export class CollaborativeDoc {
  provider: WebsocketProvider;
  ydoc: Y.Doc;
  awareness: any;
  yArray: Y.Array<any>;

  constructor(userName: string) {
    this.ydoc = new Y.Doc();
    this.provider = new WebsocketProvider(
      "ws://localhost:1234",
      "test",
      this.ydoc
    );
    this.awareness = this.provider.awareness;
    this.awareness.setLocalState({
      user: {
        name: userName,
        color: randomColor,
      },
      cursor: {
        x: undefined,
        y: undefined,
      },
    });
    this.yArray = this.ydoc.getArray("my array type");
  }
  // 监听Array元素变化
  onArrayChange(
    callback: (event: Y.YArrayEvent<any>, transation: Y.Transaction) => void
  ) {
    this.yArray.observe(callback);
  }
  // 增
  addArrayItem(index:number,id: any, color: string) {
    this.yArray.insert(index, [{id, color}]);
  }
  // 删
  deleteArrayItem(id: number) {
    this.yArray.delete(id,1);
  }
}
  • 按钮事件处理
App.tsx 复制代码
import { useEffect, useRef, useState } from "react";
import { CollaborativeDoc } from "./Yjs/CollaborativeDoc";
import "./App.css";
function App() {
  // 初始化存储Array元素
  const [array, setArray] = useState<any>([{ id: 0, color: randomColor }]);
  useEffect(()=>{
    const collaborative = new CollaborativeDoc(JSON.stringify(userName));
    collaborativeRef.current = collaborative;
    
    // 数组变化监听同步至其他用户
    collaborative.onArrayChange((event) => {
      setArray(event.target.toArray());
    });

    return () => {
      collaborative.destroy();
      collaborativeRef.current = null;
    };
  }, []);

  // 增
  const addArrayItem = (index: number, id: number, color: string) => {
    collaborativeRef.current?.addArrayItem(index, id, color);
    const newArray = [...array];
    newArray.splice(index, 0, { id, color });
    setArray(newArray);
  };
  // 删
  const deleteArrayItem = (index: number) => {
    collaborativeRef.current?.deleteArrayItem(index);
    const newArray = [...array];
    newArray.splice(index, 1);
    setArray(newArray);
  };

  return (
    <>
      <div className="array-container">
        {array.map((item: any, index: any) => {
          const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
          return (
            <div key={index} className="item">
              <div className="item-btn">
              <button
                  onClick={() => addArrayItem(index , array.length, color)}
                >
                  +
                </button>
                <button onClick={() => deleteArrayItem(index)}>-</button>
                <button
                  onClick={() => addArrayItem(index + 1, array.length, color)}
                >
                  +
                </button>
              </div>
              <div className="round" style={{ backgroundColor: item.color }}>
                {item.id}
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
}

export default App;
  • 撤销/重做/清空(undo/redo/deleteAll)

undo/redo是Yjs附带的一个选择性撤销/重做管理器,可以有效限制处理对应来源的操作。

CollaborativeDoc类提供对应方法

CollaborativeDoc.tsx 复制代码
  // 清空Array,并初始化
  deleteAll() {
    this.yArray.delete(0, this.yArray.length);
    this.yArray.insert(0, [{id: 0, color: randomColor}]);
  }

  undo() {
    this.todoUndoManager.undo();
    
  }

  redo() {
    this.todoUndoManager.redo();
  }

对应按钮实现

App.tsx 复制代码
import { useEffect, useRef, useState } from "react";
import { CollaborativeDoc } from "./Yjs/CollaborativeDoc";
import "./App.css";
function App() {
  const undo = () => {
    collaborativeRef.current?.undo();
  };
  const redo = () => {
    collaborativeRef.current?.redo();
  };
  const deleteAll = () => {
    collaborativeRef.current?.deleteAll();
  };

  return (
    <>
     <div>
        <button onClick={undo}>撤销</button>
        <button onClick={redo}>重做</button>
        <button onClick={deleteAll}>清空所有</button>
      </div>
    </>
  );
}

export default App;

到此,整个demo的功能就全部实现了

总结

从整个demo实现过程中,可知Yjs实现了一种去中心化(p2p),无需服务器进行数据处理后再将数据分发给其他用户,而是将数据给到客户端,在客户端本地由CRDT算法去解决并发冲突。

相关推荐
半点寒12W28 分钟前
CSS3 2D 转换介绍
前端·css·css3
Zaly.35 分钟前
【前端】CSS学习笔记
前端·css·学习
Mr_sun.1 小时前
Nginx安装&配置&Mac使用Nginx访问前端打包项目
前端·nginx·macos
16年上任的CTO1 小时前
一文大白话讲清楚webpack基本使用——3——图像相关loader的配置和使用
前端·webpack·node.js·file-loader·url-loader
dami_king1 小时前
PostCSS安装与基本使用?
前端·javascript·postcss
小满zs1 小时前
React第二十三章(useId)
前端·javascript·react.js
布Coder2 小时前
Flowable 工作流API应用与数据库对应展示
java·服务器·前端
梦魇星虹2 小时前
使用Edge打开visio文件
前端·edge
几道之旅2 小时前
Electron实践继续
前端·javascript·electron
多多*3 小时前
Java锁 从乐观锁和悲观锁开始讲 面试复盘
java·开发语言·前端·python·算法·面试·职场和发展