探索-Yjs-协同应用场景-分布式撤销管理
探索 Yjs 协同应用场景 - 分布式撤销管理
前言
历史记录管理器是系统中的重要功能,而实现历史记录,有多种方案,例如: 存储数据快照、使用命令模式等。
在协同场景中,分布式撤销是非常重要的功能,如果历史记录通过快照存储,是很难实现的;命令式需要调整添加协同用户标识,在执行命令时,进行业务判断。
今天,我们使用 Yjs 的一个工具函数 UndoManager 功能,实现一个分布式撤销功能,支持协同场景下,独立撤销操作(自己只能撤销自己的操作)。
撤销实现案例
例如基于 Konva 图层快照:
public patchHistory() {
// 当前图层的JSON串 - 不直接使用 toJSON(),避免影响原图层
const layerClone = mainLayer.clone();
// 当前图层的 MD5
const layerJson = JSON.stringify(layerClone.toObject());
const layerMD5 = MD5(layerJson);
// 被添加图层与最后缓存的记录是否一致
const lastLayer = this.undoStack[this.undoStack.length - 1];
const lastLayerJson = JSON.stringify(lastLayer?.toObject());
const lastLayerMD5 = MD5(lastLayerJson);
// 如果最后一个记录与当前记录一致,则不添加记录
if (layerMD5 === lastLayerMD5) return console.log("历史记录一致");
this.undoStack.push(layerClone);
// 如果记录数大于 HISTORY_MAX_RECORED,则删除最前的记录
while (this.undoStack.length > HistoryManager.MaxHistoryCount) this.undoStack.shift();
}
例如基于命令模式:
// 执行命令并添加到历史记录
executeCommand(command) {
// 如果当前不是最新状态,移除后面的历史记录
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
// 执行命令
command.execute();
// 添加到历史记录
this.history.push(command);
this.currentIndex++;
// 如果历史记录超过最大数量,移除最旧的记录
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
this.updateUI();
}
上诉仅是实现历史记录的一种方法,具体的还得根据自己的项目实际,选择合适的方案。今天,为大家介绍下 Yjs 协同中的历史记录管理器使用案例,支持分布式撤销(自己只能撤销自己的操作)。
Y.UndoManager
官网链接: ,其用法也非常简单,如下:
import * as Y from 'yjs'
// 可以是任何的 Yjs AbstractType
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
请注意,Y.UndoManager 是自带两个操作栈(undoStack|redoStack),堆栈中存放的是由 Yjs 维护的堆栈对象 StackItem :
构造函数
new Y.UndoManager(
scope: Y.AbstractType | Array<Y.AbstractType>
[
{
captureTimeout: number,
trackedOrigins: Set<any>,
deleteFilter: function(item):boolean
}
])
构造函数中,必须传递一个 Yjs 的共享数据类型;可选参数中,captureTimeout
表示时间间隔(默认为 500 毫秒)内创建的编辑,会合并成一个记录,将其设置为 0 以单独捕获每个更改;trackedOrigins
则表示跟踪不同源的修改;
而 deleteFilter
则是删除过滤函数,源码中,当记录被弹出时,会判断当前记录项是否需要删除,文档中没有指明其作用,不过判断应该是为了弹出时,可以保留其记录,某些特殊场景下做拓展使用。
API 详解
undoManager.undo()
撤消 UndoManager 堆栈上的最后一个作,反向作将放在重做堆栈上。
undoManager.redo()
重做重做堆栈上的最后一个操作,即之前的撤消被撤消。
undoManager.stopCapturing()
调用 stopCapturing()
以确保放在 UndoManager 上的下一个作不会与上一个作合并 。前面说了,captureTimeout
是一定时间内的操作,都会被合并为一个记录存储,如果在下一个操作之前,手动调用 stopCapturing
,则下一个操作会被记录为一个独立的操作。
// without stopCapturing
ytext.insert(0, 'a')
ytext.insert(1, 'b')
undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed)
// with stopCapturing
ytext.insert(0, 'a')
// 手动调用 stopCapturing 那么下一个操作会被单独记录
undoManager.stopCapturing()
ytext.insert(0, 'b')
undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed)
undoManager.on(‘stack-item-added’ … )
undoManager.on(‘stack-item-popped’ …)
undoManager.on(‘stack-item-updated’, …)
事件都比较简单,这里不展开说了哈
指定跟踪的来源
共享文档上的每个更改都有一个来源,也就是每一个共享数据类型的变更,都会有一个 origin 对象,标记着更改来源,默认是 null:
官网推荐我们,将每一个修改,都封装到事务中,以便减少事件调用及指定事件源,因此,我们通过事务来调整共享数据类型。
这个没这么难理解的,之前我们都是直接调用的数据方法,下面的两次 insert 会引起两次数据更新:
ytext.insert(0, 'a')
ytext.insert(1, 'b')
// ymap.set(key,value)
// yarray.push(...)
官网推荐我们这样,事务中的执行的更改,只会引起一次数据更新:
doc.transact(()=>{
ytext.insert(0, 'a')
ytext.insert(1, 'b')
// ymap.set(key,value)
// yarray.push(...)
},origin)
而 UndoManager 中的 trackedOrigins
,就是跟踪指定源的修改(这个源的修改需要记录,其他源的修改不需要记录)。
画布案例
有了以上的基础知识后,我们来实现基础的分布式撤销就简单多了。
但是请注意!StackItem 存储的是共享数据类型哈!并不是快照、命令,我们需要结合实际的项目,去做适配。下面简单实现下多人协同场景下的撤销,实现一个基础的画布应用:
Konva
Konva 的知识我就不多介绍了,它不是本章的重点,仅是作为绘制工具类使用。
constructor(container: HTMLDivElement, width: number, height: number) {
this.stage = new Konva.Stage({ container, width, height });
this.layer = new Konva.Layer();
this.stage.add(this.layer);
}
这是最简单的 Konva 代码了。
Yjs
yjs 主要处理的事,是创建 Doc,维护共享数据类型,维护历史记录管理器:
export class Collaborate {
private doc: Y.Doc;
private yArray: Y.Array<unknown>;
private undoManager: Y.UndoManager;
private localOrigin: { userid: string; clientID: number };
constructor(room: string, url?: string) {
this.doc = new Y.Doc();
// 初始化 origin
this.localOrigin = { userid: "local", clientID: this.doc.clientID };
// provider 不一定需要创建,如果非协同场景,则不需要,但是一定需要 Yjs 的数据结构,使用 Array 实现数据存储
this.yArray = this.doc.getArray();
// 如果用户传递了 url 则尝试连接该地址
if (url) {
// this.provider = new WebsocketProvider(url, room, this.doc);
}
this.undoManager = new Y.UndoManager(this.yArray, {
trackedOrigins: new Set([this.localOrigin]),
captureTimeout: 400,
});
// 监听变化 - 数据驱动更新,因此此处应该直接调用 draw.render 方法
this.yArray.observeDeep(() => draw.render());
}
}
当然!别忘了,**所有的共享数据类型,需要使用 doc.transact 事务封装!!!**不然,UndoManager无法捕获!
HistoryManager
这里的历史记录管理器仅作为外部调用使用,内部不创建堆栈,依赖 Y.UndoManager 实现:
export class HistoryManager {
private collaborate: Collaborate;
constructor(collaborate: Collaborate) {
this.collaborate = collaborate;
}
undo() {
this.collaborate.getUndoManager().undo();
}
redo() {
this.collaborate.getUndoManager().redo();
}
}
其原理图大致如下:用户A执行操作,使用事务跟踪并记录当前的操作,此时,会引起共享数据类型的更新,(如果协同场景下,那么会广播此更新,使得所有客户端的共享数据类型同步),远端监听到更新后,进行视图更新。
关键步骤
协同中心 yjs 提供事务执行
// 提供事务执行
transact(fn: () => void) {
this.doc.transact(fn, this.localOrigin);
}
draw中提供视图更新方法(本例执行数据驱动,大家可以根据自己项目实际调整)
/**
* @description render 渲染函数 - konva 图形由 Yjs Doc 数据驱动
*/
render() {
// 先清空当前图层,再根据 YArray 重建视图
this.layer.destroyChildren();
// 从 YArray 解析数据
const yArray = this.collaborate.getYArray();
for (const yShape of yArray.toArray() as Array<Konva.ShapeConfig>) {
const { type } = yShape as { type?: string };
if (type === "rect") {
const rect = new Konva.Rect(yShape);
this.layer.add(rect);
}
if (type === "circle") {
const circle = new Konva.Circle(yShape);
this.layer.add(circle);
}
}
this.layer.batchDraw();
}
用户操作封装到事务中,目前是将所有的图形属性添加到 shape 中
/**
* @description 添加图形 - 添加的图形是需要同步到 Yjs Doc
*/
addShape(shape: Konva.ShapeConfig) {
// 同步到 Yjs Doc(放入带有本地来源的事务中,便于撤销/重做)
const yArray = this.collaborate.getYArray();
this.collaborate.transact(() => {
yArray.push([shape]);
});
}
实现效果:
拓展:
目前我们Array 中直接存储的是 shape 的配置项,但是我们想更新一个数据(移动元素|更改颜色)等场景时,该如何操作?
draw.getStage().on("dragend", (e) => {
const node = e.target;
const id = node.getAttr("id") as string | undefined;
if (!id) return;
const collaborate = draw.getCollaborate();
const yarray = collaborate.getYArray();
const arrayMap = yarray.toArray();
const idx = arrayMap.findIndex((m) => m.id === id);
if (idx === -1) return;
const newShapeAttrs = Object.assign({}, yarray.get(idx), node.attrs);
console.log("==> ", newShapeAttrs);
// 重新更新数据
collaborate.transact(() => {
// 删除原数据
yarray.delete(idx, 1);
// 在同一位置新增
yarray.insert(idx, [newShapeAttrs]);
});
});
这样设计,每次更新位置,都会引起 Array 的先删除后增加,但是实际的元素又没有变化!但是,YArray 不支持修改单个属性,array[idx] = [newShapeAttrs] 类似这样修改是不支持的
可以单个修改属性的是 YMap ,因此,可以设计为 YArray
富文本案例
上述画布案例中,画布数据就是一个个对象属性 (konva.ShapeConfig),因此使用数组存储,如何是富文本实现呢? Yjs 为我们提供了 类型,做富文本最合适。
定义基础页面结构
监听输入:
/**
* @description 监听输入事件
*/
editor.addEventListener("compositionend", (e) => {
const text = e.data;
handleInput(text);
});
editor.addEventListener("input", (e: Event) => {
// 需要兼容换行 空格等其他特殊的字符
const event = e as InputEvent;
if (event.isComposing) return;
const text = (e as InputEvent).data ?? "";
handleInput(text);
});
封装协同控制中心
还是上一个案例类似,协同控制中心仅负责创建共享数据类型,维护 UndoManager,
const ydoc = new Y.Doc();
const ytext = ydoc.getText("text");
// 创建本地 origin
const localOrigin = { userId, clientID: ydoc.clientID };
// 创建 y-websocket provider
const provider = new WebsocketProvider("ws://localhost:9999", "easy-painting", ydoc);
// 创建 Y.UndoManager
const undoManager = new Y.UndoManager([ytext], { trackedOrigins: new Set([localOrigin]) });
// 监听更新
ytext.observeDeep((_e, transaction) => {
// 本地更新不执行
if (transaction.origin === localOrigin) return;
updateView();
});
// 封装 doc.transact
function transact(fn: () => void) {
ydoc.transact(() => {
fn();
undoManager.stopCapturing();
}, localOrigin);
}
创建历史记录管理
核心思想还是调用 undomanager 哈
// 创建 history
const history = {
undo: () => {
undoManager.undo();
},
redo: () => {
undoManager.redo();
},
};
数据处理
本例采用 作为共享数据类型,插入数据时使用 insert ,执行格式化时使用 format,删除时用 **delete,**更多的操作大家自行拓展哈。
// 输入框输入事件执行函数
function handleInput(text: string) {
if (!text) return;
console.log("input text: ", text);
// 取当前光标位置 || ytext.length 作为插入位置
const index = getTextIndex().start || ytext.length;
// 执行 ytext.insert 命令
transact(() => {
ytext.insert(index, text);
});
}
/**
* @description 执行命令
*/
function executeCommand(command: string, value?: string) {
// 调用命令
document.execCommand(command, false, value);
// 获取当前的 索引
const { start, end } = getTextIndex();
transact(() => {
// 对选择区域进行 format
if (start !== end) {
ytext.format(start, end - start, { [command]: value ?? true });
} else {
// 否则对当前光标位置进行 format
ytext.format(start, 0, { [command]: value ?? true });
}
});
}
// 监听 delete 实现
editor.addEventListener("keydown", (e) => {
if (e.key === "Backspace") {
// 获取当前光标位置
const { start, end } = getTextIndex();
if (start === end) {
// 如果当前光标位置为0,则不执行删除
if (start === 0) return;
// 否则删除当前光标位置前一个字符
transact(() => {
ytext.delete(start, end - start);
});
}
}
});
视图更新
这里采用的简单的实现方式哈,执行 **document.execCommand(command, false, value)**;
,实现简单的样式,因此视图更新,就是识别 Delta 数据,转换为 HTML,赋给编辑器:
/**
* @description 视图更新
*/
function updateView() {
console.log("==> 视图更新", ytext.toDelta());
editor.innerHTML = ytext
.toDelta()
.map((item: DeltaItem) => {
// 如果没有属性,则直接返回文本
if (!item.attributes) {
// 处理换行符
return item.insert.replace(/\n/g, "<br>");
}
// 需要根据属性类型,返回对应的 html 标签
let content = item.insert;
// 处理换行符
content = content.replace(/\n/g, "<br>");
// 按照优先级和兼容性顺序应用样式
// 多个属性需要嵌套应用,例如: {"strikeThrough": true, "bold": true} => <s><strong>text</strong></s>
if (item.attributes.bold) {
content = `<strong>${content}</strong>`;
}
if (item.attributes.italic) {
content = `<em>${content}</em>`;
}
if (item.attributes.strikeThrough) {
content = `<s>${content}</s>`;
}
if (item.attributes.underline) {
content = `<u>${content}</u>`;
}
if (item.attributes.foreColor) {
content = `<span style="color: ${item.attributes.foreColor}">${content}</span>`;
}
return content;
})
.join("");
}
实现效果如下:
这就是协同场景下的分布式撤销,这仅是一个简单的示例哈,还有很多细节需要完善,例如,直接执行 editor.innerHTML 会导致用户光标丢失,协同用户光标优化、用户光标存储等等,大家可以自行处理。
总结
Y.UndoManager 不仅仅可以用于协同场景下的分布式撤销,当然,也可以用于本地实现历史记录管理,只是一般情况下,都是自己适配项目实现。
上诉两个案例,从协同控制、历史记录,以及底层数据操作,原理都是类似的,都需要初始化一个 UndoManager ,并且指定追踪的源,通过保持一致的共享数据类型,实现页面展示的一致性。
大家可以针对Yjs 官网的各个案例进行阅读,相信大家对协同的理解会更加深刻。