跳到主要内容

Undo/Redo 设计

概述

Undo/Redo 功能基于 RxDBChange 变更记录实现,通过记录和还原数据变更历史来支持撤销和重做操作。该机制利用了 inversePatchpatch 字段来存储变更的逆向操作和正向操作。

开发状态

Undo/Redo 功能当前处于规划和设计阶段,本文档描述了设计方案和实现原理。部分 API 已在 VersionManager 中定义但尚未完全实现。

核心原则

1. 时间边界限制

  • 只能操作 RxDB 初始化时间后的数据
  • ✅ 初始化之前的数据变更无法追踪,因此不支持撤销
  • ✅ 系统会记录 RxDB 初始化时间作为 undo/redo 的起始边界

2. 事务完整性

  • 事务操作必须作为一个整体进行 undo/redo
  • ✅ 通过 transactionId 字段标识同一事务中的多个变更
  • ✅ 撤销事务时,必须同时撤销该事务中的所有变更
  • ✅ 重做事务时,必须按照原始顺序恢复所有变更

3. 状态标记

  • Undo 操作:设置 revertChangedAt 为撤销时间,revertChangeId 为当前变更序列的最新 ID,增加 RxDBChange 表的自增 id 数量
  • Redo 操作revertChangeId 还原为 nullrevertChangedAt 保留时间用来标记被 redo 过
  • ✅ 通过这两个字段区分正常变更、已撤销变更和已恢复变更

API 设计

数据库级别操作

撤销/重做整个数据库的操作:

// 撤销整个数据库的最近 n 次操作
await rxdb.versionManager.undoDatabase(step?: number);

// 重做整个数据库的操作
await rxdb.versionManager.redoDatabase(step?: number);

使用场景:

  • 全局撤销操作(类似 Ctrl+Z)
  • 批量数据导入后的回滚
  • 测试场景下的快速重置

仓库级别操作

针对特定实体类型的撤销/重做:

// 撤销某个仓库(实体类型)的操作
await rxdb.versionManager.undoRepository(step?: number);

// 重做某个仓库的操作
await rxdb.versionManager.redoRepository(step?: number);

使用场景:

  • 只影响特定类型实体的操作
  • 更精细的撤销控制
  • 减少不必要的数据变更

实体级别操作

针对单个实体实例的撤销/重做:

// 撤销某个实体的操作
const updatedTodo = await rxdb.versionManager.undoEntity(todo, step?: number);

// 重做某个实体的操作
const redoTodo = await rxdb.versionManager.redoEntity(todo, step?: number);

使用场景:

  • 精确到单个对象的操作回滚
  • 用户对特定记录的编辑撤销
  • 细粒度的数据恢复

实体恢复

将实体恢复到历史版本:

interface RestoreEntityOptions {
changeId?: number; // 恢复到指定变更点
timestamp?: Date; // 恢复到指定时间点
createNew?: boolean; // 是否创建新实体而不是覆盖
}

// 恢复实体到历史版本
const restored = await rxdb.versionManager.restoreEntity(
todo,
{ changeId: 12345 }
);

// 创建历史版本的副本
const snapshot = await rxdb.versionManager.restoreEntity(
todo,
{ timestamp: new Date('2024-01-01'), createNew: true }
);

实现原理

变更记录结构

interface RxDBChange {
id: number; // 自增ID,用于排序
branchId: string; // 所属分支
namespace: string; // 命名空间
entity: string; // 实体类型
entityId: string; // 实体ID
type: 'insert' | 'update' | 'delete'; // 操作类型
patch: JSONPatch; // 正向变更
inversePatch: JSONPatch; // 逆向变更
revertChangedAt: Date | null; // 撤销时间
revertChangeId: number | null; // 撤销时的变更ID
transactionId: string; // 事务ID
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
}

Undo 流程

关键步骤:

  1. 查询变更:获取最近未撤销的事务(按 id 降序)
  2. 禁用触发器:避免撤销操作本身产生新的变更记录
  3. 应用逆向变更:按倒序执行每个变更的 inversePatch
  4. 标记状态:设置 revertChangedAtrevertChangeId
  5. 启用触发器:恢复正常的变更追踪

Redo 流程

关键步骤:

  1. 查询已撤销的变更:获取最近被撤销的事务(revertChangeId 不为 null)
  2. 禁用触发器:避免重做操作产生新变更
  3. 应用正向变更:按正序执行每个变更的 patch
  4. 清除撤销标记:将 revertChangeId 设为 null(保留 revertChangedAt
  5. 启用触发器:恢复正常追踪

事务处理

// 示例:事务中的多个变更
const transaction = db.transaction([
{
id: 101,
transactionId: 'tx-123',
type: 'insert',
entity: 'Todo',
entityId: '1',
patch: { op: 'add', path: '', value: { id: '1', title: 'Task 1' } },
inversePatch: { op: 'remove', path: '' }
},
{
id: 102,
transactionId: 'tx-123',
type: 'update',
entity: 'Todo',
entityId: '1',
patch: { op: 'replace', path: '/completed', value: true },
inversePatch: { op: 'replace', path: '/completed', value: false }
}
]);

// 撤销时必须同时撤销整个事务
await rxdb.versionManager.undoDatabase(1); // 撤销 id: 102 和 101

状态转换

查询策略

查询可撤销的变更

-- 查询当前分支上未被撤销的最新事务
SELECT DISTINCT transactionId
FROM RxDBChange
WHERE branchId = ?
AND revertChangeId IS NULL
AND createdAt > ? -- RxDB 初始化时间
ORDER BY id DESC
LIMIT ?;

查询可重做的变更

-- 查询最近被撤销的事务
SELECT DISTINCT transactionId
FROM RxDBChange
WHERE branchId = ?
AND revertChangeId IS NOT NULL
ORDER BY revertChangedAt DESC
LIMIT ?;

查询实体历史

-- 查询特定实体的所有变更
SELECT *
FROM RxDBChange
WHERE entity = ?
AND entityId = ?
AND branchId = ?
ORDER BY id ASC;

实际应用示例

示例 1:全局撤销

// 用户按下 Ctrl+Z
async function handleUndo() {
try {
await rxdb.versionManager.undoDatabase(1);
console.log('撤销成功');
} catch (error) {
console.error('撤销失败:', error);
}
}

// 用户按下 Ctrl+Y 或 Ctrl+Shift+Z
async function handleRedo() {
try {
await rxdb.versionManager.redoDatabase(1);
console.log('重做成功');
} catch (error) {
console.error('重做失败:', error);
}
}

示例 2:编辑器撤销

// 编辑器中的撤销栈管理
class EditorUndoManager {
private undoStack: number[] = []; // 存储可撤销的步数
private redoStack: number[] = []; // 存储可重做的步数

async undo() {
if (this.undoStack.length === 0) return;

const steps = this.undoStack.pop()!;
await rxdb.versionManager.undoDatabase(steps);
this.redoStack.push(steps);
}

async redo() {
if (this.redoStack.length === 0) return;

const steps = this.redoStack.pop()!;
await rxdb.versionManager.redoDatabase(steps);
this.undoStack.push(steps);
}

// 新操作时清空 redo 栈
onNewChange() {
this.undoStack.push(1);
this.redoStack = [];
}
}

示例 3:实体级撤销

// 针对单个 Todo 的撤销操作
async function undoTodoChanges(todo: Todo) {
try {
// 撤销该 todo 的最后一次修改
const updated = await rxdb.versionManager.undoEntity(todo, 1);
console.log('Todo 已恢复到上一个状态:', updated);
} catch (error) {
console.error('撤销失败:', error);
}
}

// 查看 Todo 的历史版本
async function showTodoHistory(todo: Todo) {
const changes = await rxdb.query(RxDBChange)
.where({ entity: 'Todo', entityId: todo.id })
.orderBy('id', 'asc')
.findAll();

console.log('Todo 变更历史:', changes);
}

示例 4:批量操作撤销

// 批量删除后的撤销
async function batchDeleteWithUndo(todos: Todo[]) {
// 记录操作前的状态
const beforeChangeId = await getCurrentChangeId();

try {
// 批量删除
await Promise.all(todos.map(todo => todo.remove()));

// 用户后悔了?
const shouldUndo = await confirm('是否撤销删除?');
if (shouldUndo) {
// 计算需要撤销的步数
const afterChangeId = await getCurrentChangeId();
const steps = afterChangeId - beforeChangeId;
await rxdb.versionManager.undoDatabase(steps);
}
} catch (error) {
console.error('操作失败:', error);
}
}

性能优化

1. 索引优化

-- 为常用查询创建索引
CREATE INDEX idx_change_branch_revert ON RxDBChange(branchId, revertChangeId);
CREATE INDEX idx_change_transaction ON RxDBChange(transactionId);
CREATE INDEX idx_change_entity ON RxDBChange(entity, entityId, branchId);

2. 批量操作

// 批量应用变更而不是逐个执行
async function applyChangesInBatch(changes: RxDBChange[]) {
const statements = changes.map(change =>
generatePatchSQL(change.inversePatch)
);

await db.executeBatch(statements);
}

3. 变更记录清理

// 定期清理过期的变更记录
async function cleanupOldChanges(daysToKeep: number = 30) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);

await db.execute(`
DELETE FROM RxDBChange
WHERE createdAt < ?
AND revertChangeId IS NULL
`, [cutoffDate]);
}

边界情况处理

1. 初始化时间边界

// RxDB 初始化时记录时间
const rxdb = new RxDB({
dbName: 'myapp',
entities: [Todo],
sync: { /* ... */ }
});

// 系统自动记录初始化时间
const initTime = await rxdb.versionManager.getInitializationTime();

// 查询时只考虑初始化后的变更
const changes = await db.query(`
SELECT * FROM RxDBChange
WHERE createdAt > ?
`, [initTime]);

2. 事务中断处理

// 确保事务的原子性
async function safeUndo(steps: number) {
const transaction = await db.beginTransaction();

try {
await rxdb.versionManager.undoDatabase(steps);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}

3. 并发冲突

// 使用乐观锁避免并发冲突
async function undoWithConflictCheck() {
const currentChangeId = await getCurrentChangeId();

await rxdb.versionManager.undoDatabase(1);

const newChangeId = await getCurrentChangeId();
if (newChangeId !== currentChangeId - 1) {
throw new Error('并发修改冲突,请重试');
}
}

UI 集成建议

撤销/重做按钮状态

import { computed, signal } from '@angular/core';

// Angular 示例
const undoCount = signal(0);
const redoCount = signal(0);

const canUndo = computed(() => undoCount() > 0);
const canRedo = computed(() => redoCount() > 0);

// 监听变更更新计数
rxdb.versionManager.changes$.subscribe(async () => {
undoCount.set(await getUndoableCount());
redoCount.set(await getRedoableCount());
});

变更历史面板

// 显示变更历史
interface ChangeHistoryItem {
id: number;
timestamp: Date;
type: 'insert' | 'update' | 'delete';
entity: string;
description: string;
canUndo: boolean;
}

async function getChangeHistory(): Promise<ChangeHistoryItem[]> {
const changes = await rxdb.query(RxDBChange)
.where({ branchId: 'main' })
.orderBy('id', 'desc')
.limit(50)
.findAll();

return changes.map(change => ({
id: change.id,
timestamp: change.createdAt,
type: change.type,
entity: change.entity,
description: formatChangeDescription(change),
canUndo: change.revertChangeId === null
}));
}

最佳实践

1. 合理设置撤销深度

// 限制可撤销的步数
const MAX_UNDO_STEPS = 100;

async function undo() {
const currentSteps = await getUndoableCount();
if (currentSteps >= MAX_UNDO_STEPS) {
// 清理最早的变更
await cleanupOldestChanges(10);
}
await rxdb.versionManager.undoDatabase(1);
}

2. 提供清晰的用户反馈

async function undoWithFeedback() {
try {
const changes = await getLastTransaction();
const description = formatChanges(changes);

await rxdb.versionManager.undoDatabase(1);

showNotification(`已撤销: ${description}`);
} catch (error) {
showError('撤销失败,请重试');
}
}

3. 批量操作的事务管理

// 确保批量操作在同一事务中
async function batchUpdate(todos: Todo[]) {
const transactionId = generateUUID();

for (const todo of todos) {
// 设置事务ID
todo.setTransactionId(transactionId);
await todo.save();
}

// 用户可以一次性撤销整个批量操作
}

4. 定期清理变更历史

// 自动清理策略
setInterval(async () => {
await cleanupOldChanges(30); // 保留30天
}, 24 * 60 * 60 * 1000); // 每天执行

注意事项

  1. 性能影响:大量变更记录会影响查询性能,需要定期清理
  2. 存储空间RxDBChange 表会持续增长,需要监控存储使用情况
  3. 事务边界:确保相关操作在同一事务中,避免部分撤销
  4. 并发控制:多用户环境下需要处理并发冲突
  5. 初始化边界:只能撤销 RxDB 初始化后的操作

相关文档