协同编辑通常指多个参与方共同合作完成某项任务或目标的行为,它一直是一个技术上具有挑战的领域,其中数据一致性问题尤为复杂,不过现如今已经有比较成熟的方式来解决它,本文主要介绍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中的顺序不太相同,但在数据层面上是相同的并不影响最终一致。
协同编辑
目标效果
这里采用浏览器及无痕模式下的浏览器模拟多用户登录,实现在线用户展现、展现非本地用户光标、协同增删图形等功能。
协同编辑器
- 实时性:操作能够短时间内同步给其他用户(indexedDB、webrtc、websocket、Provider)。
- 冲突解决:多用户对于同一内容的编写,能够合理解决对应冲突。
需求调研
-
产品:Notion、飞书文档、我来
-
协同部分
- Yjs
- provider:面向连接:y-webrtc、y-websocket(数据同步)、面向存储:y-indexedDB(本地弱网存储)、y-redis、面向协议:y-protocal
- Yjs
-
光标、在线用户、数据同步、undo/redo
- 光标 clientX、clientY
- Yjs awareness
- Yjs Ydoc
- Yjs UndoManager
技术方案
- y-websocket(数据转发、数据同步)+ Yjs 实现基于CRDT(冲突解决)的分布式数据同步
- 状态管理、用户标识
功能与实现方案分层
- 用户信息、光标借助awareness(local、remote)
- 数据同步层,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算法去解决并发冲突。