在多设备同步过程中,需要处理两类主要事件:本地文件变更云端文件变更。事件处理在同步算法中起着核心作用——后续的同步操作(如上传、下载、删除、重命名、移动和合并)都基于事件处理的结果。

在 Obsidian 中,同步涉及 4 种类型的事件:

  1. 创建文件或文件夹。
  2. 重命名文件或文件夹。
  3. 删除文件或文件夹。
  4. 修改文件。

以上是同步过程中关注的四种事件类型,每种都对应 Obsidian 中相关的用户操作。

事件类型

使用 vault.on() 方法监听文件变更事件:

vault.on('create', () => {}) // 新建文件/文件夹
vault.on('rename', () => {}) // 重命名(移动)
vault.on('delete', () => {}) // 删除文件/文件夹
vault.on('modify', () => {}) // 修改文件

代码级监控

此外,还可以通过使用 cross 等工具进行方法修补 (method patching) 来实现。这种方法尚未探索——以下所有内容均基于 vault.on 触发的事件进行描述。

了解如何监听事件后,让我们检查这四种事件类型的特征,以实施适当的处理。正确识别每个事件的唯一性对于设计有效的解决方案至关重要。

事件特征

1. 创建事件

  • 触发方式:当用户创建新文件或文件夹时。
  • 特征:由于用户一次只能创建一个文件/文件夹,因此一个创建操作正好对应一个创建事件。

2. 重命名事件

  • 触发方式:(1) 点击重命名文件/文件夹;(2) 将文件/文件夹移动到另一个位置。
  • 特征:重命名或移动文件夹时,文件夹本身及其所有子项都会触发重命名事件。事件顺序遵循 广度优先遍历——首先是文件夹本身,然后是其直接子项,依此类推。

3. 删除事件

  • 触发方式:当用户删除文件或文件夹时。
  • 特征:删除文件夹时,文件夹及其所有子项都会触发删除事件。然而,与重命名事件不同,没有明显的顺序——事件基本上是批量同时触发的。

4. 修改事件

  • 触发方式:当用户修改文件内容时。
  • 特征:仅当文件内容更改时触发;文件夹永远不会触发此事件。

收集与预处理

创建和修改事件不需要额外的预处理,因为每个操作映射到一个事件。然而,重命名或删除文件夹带来了特殊的挑战。例如:如果您删除了包含子文件夹 A-1 的文件夹 A,假设删除事件的触发顺序为:

  1. 删除文件夹 A
  2. 删除文件夹 A-1

直接在云端执行这些事件会导致:

  • 删除云端文件夹 A
  • 尝试删除云端文件夹 A-1(该文件夹已不存在),导致无效操作。

删除或移动大文件夹会产生大量无效操作。因此,在向云端发送请求之前,必须进行预处理。

此外,事件收集也存在问题。使用上面的删除示例,我们需要收集“删除 A”和“删除 A-1”两个事件,过滤掉冗余操作,最后向云端发送单个“删除 A”请求。但是,我们无法假设事件触发的顺序或间隔。

解决方案:

  • 对于删除事件:在典型场景中,删除事件会批量发生,间隔仅为几毫秒。因此,可以使用 debounce (防抖) 方法延迟处理并在指定窗口内收集事件。
  • 对于重命名事件:由于事件遵循特定顺序,我们可以检查父文件/文件夹是否已在处理中。如果是,则可以忽略子项的事件。

这里我们得到了处理本地文件事件的基本工作流:

flowchart LR

A[事件生成] --> 收集 --> 过滤 --> D[更多...]

事件收集和过滤的代码示例:

debounce(() => {
	// 事件预处理:fileEventTemp 将原始事件存储为队列
	this.fileEventTemp.sort((a, b) => {
		if (a.timestamp !== b.timestamp) {
			return a.timestamp - b.timestamp;
		} else {
			return a.targetPath.length - b.targetPath.length;
		}
});
	// historyEvents 存储预处理后的事件
	const historyEvents: FileEvent[] = [];
	let nextEvent = this.fileEventTemp.shift();
 
	while (nextEvent) {
		if (historyEvents.some(e => nextEvent?.targetPath.startsWith(e.targetPath))) {
			logger.info(`Ignore event: ${nextEvent}`);
		} else {
			historyEvents.push(nextEvent);
		}
		nextEvent = this.fileEventTemp.shift();
	}
}, 1000, true)(); // 延迟 1 秒以收集过去一秒内的事件到 fileEventTemp

压缩事件 (Compact Events)

考虑这种情况:文件 A 重命名为 B,然后再次重命名为 C。这会生成两个重命名事件:

  1. 重命名 AB
  2. 重命名 BC

在预处理期间,这两个事件都被收集。实际上,这两个事件可以合并为一个: 3. 重命名 AC

Sync Vault 支持重命名事件的链式聚合,以减少云端请求的数量。

本地事件预处理完成后,将触发实际的 Synchronization Flow (同步流程)。