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算法去解决并发冲突。

相关推荐
却尘5 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare6 分钟前
浅浅看一下设计模式
前端
Lee川9 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix36 分钟前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人39 分钟前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl43 分钟前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空1 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust