目录

探索-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();
	}

https://i-blog.csdnimg.cn/direct/65596ae958ec4bf4b408bba591f42de7.gif

      例如基于命令模式:

// 执行命令并添加到历史记录
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();
}

https://i-blog.csdnimg.cn/direct/7a394478ea294fe6bb46c3a6cc76ae7e.gif

        上诉仅是实现历史记录的一种方法,具体的还得根据自己的项目实际,选择合适的方案。今天,为大家介绍下 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 :

https://i-blog.csdnimg.cn/direct/db4ef22d90584513b7d25d7bf6e7c2d7.png

构造函数

new Y.UndoManager(
    scope: Y.AbstractType | Array<Y.AbstractType> 
    [
        {
            captureTimeout: number, 
            trackedOrigins: Set<any>, 
            deleteFilter: function(item):boolean
        }
    ])

        构造函数中,必须传递一个 Yjs 的共享数据类型;可选参数中,captureTimeout 表示时间间隔(默认为 500 毫秒)内创建的编辑,会合并成一个记录,将其设置为 0 以单独捕获每个更改;trackedOrigins 则表示跟踪不同源的修改;

        而 deleteFilter 则是删除过滤函数,源码中,当记录被弹出时,会判断当前记录项是否需要删除,文档中没有指明其作用,不过判断应该是为了弹出时,可以保留其记录,某些特殊场景下做拓展使用。

https://i-blog.csdnimg.cn/direct/ead9d27bdd5b4e6db880d5a5cb4785d7.png

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:

https://i-blog.csdnimg.cn/direct/c854232566b34e5a99c38e79272e8395.png

        官网推荐我们,将每一个修改,都封装到事务中,以便减少事件调用及指定事件源,因此,我们通过事务来调整共享数据类型。

https://i-blog.csdnimg.cn/direct/1936f19acbbf46ca8878ba829dc951fc.png

        这个没这么难理解的,之前我们都是直接调用的数据方法,下面的两次 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)

https://i-blog.csdnimg.cn/direct/6a7d2996beac43b4bb5c91aec4a9bed7.png

https://i-blog.csdnimg.cn/direct/4df48556d83a418c99a47f84eede1c49.png

        而 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执行操作,使用事务跟踪并记录当前的操作,此时,会引起共享数据类型的更新,(如果协同场景下,那么会广播此更新,使得所有客户端的共享数据类型同步),远端监听到更新后,进行视图更新。

https://i-blog.csdnimg.cn/direct/4f9ca010f5d640b0b61d0c6d4aae5428.png

关键步骤

        协同中心 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]);
		});
	}

        实现效果:

https://i-blog.csdnimg.cn/direct/4ae743b1751f48009253429bd621b291.gif

拓展:

        目前我们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]);
	});
});

https://i-blog.csdnimg.cn/direct/a8ecd0a6688e4526942a595a9fae1cbb.gif

        这样设计,每次更新位置,都会引起 Array 的先删除后增加,但是实际的元素又没有变化!但是,YArray 不支持修改单个属性,array[idx] = [newShapeAttrs] 类似这样修改是不支持的

https://i-blog.csdnimg.cn/direct/08392d94570c4f25b90d8f18e87a42e7.png

        可以单个修改属性的是 YMap ,因此,可以设计为 YArray 的数据结构,拿到单个数据项,做属性修改,这部分大家可以自行优化下。

富文本案例

        上述画布案例中,画布数据就是一个个对象属性 (konva.ShapeConfig),因此使用数组存储,如何是富文本实现呢? Yjs 为我们提供了   类型,做富文本最合适。

定义基础页面结构

https://i-blog.csdnimg.cn/direct/dc3ea3b554e047f7a60bb42d396251ac.png

 监听输入:

/**
 * @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("");
}

实现效果如下:

https://i-blog.csdnimg.cn/direct/79c496e08f3e4893ae92857cd265937b.gif

        这就是协同场景下的分布式撤销,这仅是一个简单的示例哈,还有很多细节需要完善,例如,直接执行 editor.innerHTML 会导致用户光标丢失,协同用户光标优化、用户光标存储等等,大家可以自行处理。

总结

        Y.UndoManager 不仅仅可以用于协同场景下的分布式撤销,当然,也可以用于本地实现历史记录管理,只是一般情况下,都是自己适配项目实现。

        上诉两个案例,从协同控制、历史记录,以及底层数据操作,原理都是类似的,都需要初始化一个 UndoManager ,并且指定追踪的源,通过保持一致的共享数据类型,实现页面展示的一致性。

        大家可以针对Yjs 官网的各个案例进行阅读,相信大家对协同的理解会更加深刻。