服务端如何实现撤销恢复功能

194 阅读9分钟

撤销恢复功能

在文本编辑、图像与绘图设计、办公表格与演示文稿制作、代码编写等各类软件通常都需要撤销恢复功能。 有了撤销恢复功能,用户就不用担心误操作了,大可以放开手脚探索更多可能,犯错了也能快速恢复。不管是点击、敲代码,还是画画,都能轻松撤回、恢复,操作起来更灵活。

核心逻辑

如何描述步骤变更

  1. 假设我们画布上有一个初始的元素【步骤1】,我们用最简单的方式描述它就是 {x: 100, y: 100, width: 80, height: 30,bgColor: 'yellow'}
  2. 然后我们将这个元素拖到下方并更改它的大小,对它的描述就是 {x: 140, y: 160, width: 120, height: 70,bgColor: 'yellow'}
  3. 继续移动元素,并修改它的背景颜色,对它的描述就是 {x: 100, y: 200, width: 120, height: 70,bgColor: 'red'} image.png

那么步骤1 - 步骤3 产生了哪些变更呢。 可以看到下面红色标注的就是每个步骤之间产生的变更。

image.png

存储步骤

image.png

  • 数据结构:每个步骤都有oldValuenewValue两个属性。oldValue指向上一个步骤的newValue ,用于记录上一状态;newValue记录当前步骤图形的相关属性(如坐标xy,宽width,高height ,背景颜色bgColor等)。

  • 步骤内容

    • step1oldValuenull ,因为没有上一步;newValue包含图形初始属性,坐标x:100y:100 ,宽width:80 ,高height:30 ,背景颜色bgColor: 'yellow'
    • step2oldValue指向 step1 的newValue ;newValue中图形属性变为x:140y:160 ,宽width:120 ,高height:70 。
    • step3oldValue指向 step2 的newValue ;newValue里图形属性更新为x:100y:200 ,宽width:120 ,高height:70 ,背景颜色bgColor: 'red' 。
  • 当前指针:图中箭头表示当前指针指向 step3 ,意味着当前处于 step3 这个操作步骤。 整体通过这种数据结构记录各步骤图形状态变化,为实现撤销恢复功能提供基础。

撤销逻辑

伪代码如下:

  • 假设 steps 就是上图中的步骤栈
  • currentIndex 代表指针
  • 当我们要完成从步骤3撤销到步骤2时,执行以下逻辑
// 假设steps数组存储所有步骤,currentIndex指向当前步骤(这里是step3,假设索引为2 ) 
const steps = [step1, step2, step3]; 
let currentIndex = 2;
// 获取step3的oldValue ,即step2的状态
const prevState = steps[currentIndex].oldValue; // 更新当前状态为prevState 
currentShape = {...prevState }; 
// 将当前指针往前移动一位 
currentIndex--;

恢复逻辑

  • 当我们要完成从步骤2恢复到步骤3时,执行以下逻辑
// 假设steps数组存储所有步骤,currentIndex指向当前步骤(这里是step2,假设索引为1 )
const steps = [step1, step2, step3];
let currentIndex = 1; // 获取step2的newValue ,即step3的状态 
const nextState = steps[currentIndex].newValue; // 更新当前状态为nextState currentShape = {...nextState }; // 将当前指针往后移动一位 currentIndex++;

代码实现

有了上面的核心逻辑的基础,我来考虑如何在服务端实现撤销恢复的功能。

表设计

step 表(步骤记录)

  • projectId:项目id,每个项目有自己的一个步骤记录栈。
  • step:一个 step(步骤)可以包含 多个 change 修改操作,例如在某个步骤中对节点进行多次编辑、删除或新增等操作,这些操作会被批量记录在同一个 step 的 changes 数组中。
  • index:表示步骤所处的序号,当执行撤销或恢复时将对应指针修改为对应 index。
  • nullable: false 强制要求 每个步骤必须包含至少一条修改记录,避免出现 “空步骤” 的无效数据。
import { Change } from '@hfdraw/types';
import { Entity, Column, PrimaryColumn, Index } from 'typeorm';

@Entity({
  name: 'step'
})
export class StepEntity {

  @PrimaryColumn()
  id_: string;

  @Column({
    nullable: false,
    type: Number
  })
  @Index("projectId")
  projectId: string; // 项目id

  @Column({
    type: 'simple-json',
    nullable: false
  })
  changes: Change[];

  @Column({
    type: String,
    default: ''
  })
  desc = ''; // 描述

  @Column({
    nullable: true,
    type: Number
  })
  index: number; // 序号,第几步, 从0开始
}

Change 对象类型

  • ChangeType: 表示一个 Change 对下变更属于的操作类型。
  • oldValue: 用于撤销时还原上一步。
  • newValue: 用于恢复时还原下一步。
  • shapeId: 用于将这些变更应用到哪个具体的图形。
  • projectId: 所属项目
  export enum ChangeType {
    INSERT = 1, // 插入对象
    UPDATE = 2, // 更新某个或多个字段
    DELETE = 3, // 删除对象
  }
  export interface  Change    {
  
    type: ChangeType;
  
    oldValue?: string; // 更新前的key-value对象的 json串,只记录变更的字段即可,undo的时候会用这个keyValue去update对应的table
  
    newValue?: string; // 更新后的key-value对象的 json串,只记录变更的字段即可,redo的时候会用这个keyValue去update对应的table
    
    shapeId: number // 当前操作的图形 id_
  
    projectId: string
  }

currentStep

  • stepSize: 步骤的总数,当我们操作一次时,步骤总数就会增加1,用于判断是否能够进行恢复功能。
  • index: 指针指向对应的步骤序号。用于判断是否可以进行撤销功能。当 index=0时就不可以再撤销了。
  • stepId: 对应的 stepId,用于找到对应的 step 记录。
  • projectId:项目id,每个项目有自己的步骤指针记录。
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, RelationId } from "typeorm";
@Entity({
    name: 'current_step'
  })
  export class CurrentStep {
  
    @PrimaryGeneratedColumn()
  
    id_: number;
  
    @Column({
      type: Number,
      nullable: false,
      default: 0
    })
    stepSize: number; // step的总数,用于判断是否有下一步
    @Column({
      type: Number,
      nullable: false,
      default: 0
    })
    index: number // 当前步骤对应的序号

    @Column({
      type: String,
      nullable: true
    })
  
    stepId:string|null // 无记录时 stepId 为 null
  
    @Column({
      type: String,
      nullable: false
    })
    projectId:string
  
  }

socket 消息推送到客户端

设计 socket 消息统一推送

当我们调用接口接口的时候,为啥不直接将数据返回客户端,还要通过 socket 将数据推送给客户端呢?

  1. 统一客户端处理逻辑
    客户端可以集中处理所有图形数据变更,逻辑内聚性更强。通过统一的 Step 数据结构接收和解析数据,避免了因数据格式不一致导致的处理逻辑分散和复杂性增加。
  2. 多场景通用性
    不论是图形属性修改、图形位置调整,还是图形的添加与删除,都可以使用同一套逻辑处理数据变更。这种通用性减少了重复代码,提高了代码的复用性和可维护性。
  3. 数据格式统一
    客户端和服务端定义了一套统一的变更数据结构(如 Step),使得数据交互更加规范和清晰。这种标准化的数据格式便于扩展和维护,同时也降低了因数据格式不一致导致的错误。
  4. 支持复杂交互
    基于 Socket 的双向通信机制,可以轻松实现多用户协同编辑等复杂功能。多个用户对图形的操作可以实时同步到其他用户的画布上,增强了软件的协作能力。

服务端推送步骤消息

以移动图形为例:

  1. 移动图形并记录一次操作的 Changes。
  2. 调用 initStep 创建 step 记录,并更新 currentStep。
  3. 将最新 step 记录同步给客户端
  // 服务端实现
  @Post('move')
  async moveShape(@Body() dto: MoveShapeDto) {
    const changes = await this.shapeService.moveShape(dto);
    await this.stepService.initStep({ projectId: dto.projectId, changes });
    await this.wsService.sendToSubscribedClient(dto.projectId, {
      type: WsMessageType.step,
      data: {
        projectId: dto.projectId,
        changes,
        stepType: StepType.edit,
      },
    });
    return new ResData(null);
  }
  
      // 生成一个 step,并且更新 currentStep
    async initStep(dto: { projectId: string, changes: Change[] }) {
      const step = await this.createStep({projectId: dto.projectId, changes: dto.changes});
      const currentStep = await this.currentStepService.findCurrentStep(dto.projectId);
      const stepSize = await this.stepRepository.count();
      if (currentStep) {
        await this.currentStepService.updateCurrentStep(currentStep.id_, {projectId: dto.projectId,stepId: step.id_, stepSize: stepSize, index: step.index})
      } else {
        await this.currentStepService.createCurrentStep({
          projectId: dto.projectId,
          stepId: step.id_,
          index: step.index,
          stepSize: stepSize
        })
      }
    }

客户端处理消息

  1. 客户端通过 start 连接 socket。
  2. 通过 onmessage 监听服务端消息,并处理对应的逻辑。
  3. 对于 step 消息,遍历 changes 并将消息 emit 出去,在使用了图形信息的地方监听,并撤销或者恢复对应的变更。
    • 注意点是,如果是撤销操作,先将 changes 反转一下,因为步骤前后可能有依赖,所以要一步步会退回去。
export class SocketService {
  ws: WebSocket | undefined = undefined;
  reconnectTime = 0;
  status: ConnectStatus = ConnectStatus.UNCONNECT;
  uri: string;
  maxReconnectTime = 3;
  msgHandler: {[key:string]:Function} = {
    connect:() => {
      this.sendJSON({ type: "subscribeProject", projectId: 'p1' });
    },
    async step(messageData:{ type:'step', data: Step}) {
      let { data: { changes, stepType } } = messageData;
      const isUndo = stepType === StepType.undo;
      const isEdit = stepType === StepType.edit;
      // 如果是撤销操作,先将 changes 反转一下,因为步骤前后可能有依赖,所以要一步步会退回去
      if (isUndo) {
          changes.reverse();
      }
      changes.forEach(change => {
        if (change.type === ChangeType.INSERT) {
          if (isUndo) {
            emitter.emit(BusEvent.DELETE_SHAPE, change)

          } else {
            emitter.emit(BusEvent.INSERT_SHAPE, change);
          }
        } else if (change.type === ChangeType.DELETE) {

          if (isUndo) {
           
            emitter.emit(BusEvent.INSERT_SHAPE, change)

          } else {
            emitter.emit(BusEvent.DELETE_SHAPE, change)
          }
        } else if (change.type === ChangeType.UPDATE) {
          if (isUndo) {
            const oldValue = change.newValue;
            const newValue = change.oldValue;
            emitter.emit(BusEvent.UPDATE_SHAPE, {...change,oldValue, newValue});
          } else {
            emitter.emit(BusEvent.UPDATE_SHAPE, change);
          }
        }
      })
    }
  };
  constructor(option: SocketOption) {
    const { uri, maxReconnectTime } = option;
    this.uri = uri;
    this.maxReconnectTime = maxReconnectTime;
  }
  start() {
    if (this.ws) {
      try {
        this.ws?.close();
      } catch (error) {
        console.error(error);
      }
    }
    this.ws = new WebSocket(this.uri + "?clientId=0");
    this.ws.onopen = this.onOpen.bind(this);
    this.ws.onmessage = this.onMessage.bind(this);
    this.ws.onclose = this.onClose.bind(this);
    this.ws.onerror = this.onError.bind(this);
  }
  onOpen() {
    this.status = ConnectStatus.CONNECTED;
    this.reconnectTime = 0;
  }

  onMessage(e: MessageEvent<string>) {
    const res = JSON.parse(e.data) as { type: string; data: StepMessageData};
    console.log('res:',res)
    res.type
    if (this.msgHandler[res.type]) {
      this.msgHandler[res.type](res);
    } else {
      console.error("[消息格式错误] unKnow msg type:" + res.type, res);
    }
  }
  onClose() {
    // 主动关闭,由于后端关闭都会触发此处的onClose
    if (this.status === ConnectStatus.CLOSED) {
      return;
    }
  }
  onError(e: Event) {
    console.error(e);
  }

  sendJSON(obj: any) {
    this.ws?.send(JSON.stringify(obj));
  }
}

6. 总结

本文详细介绍了软件撤销恢复功能的设计与实现。从功能重要性出发,我们认识到撤销恢复功能对提升用户体验和操作灵活性的关键作用。通过定义清晰的步骤变更逻辑和存储结构,我们实现了撤销与恢复的核心功能,支持多种操作类型。服务端采用 step 表和 currentStep 表记录变更,客户端通过 Socket 实时接收步骤数据,统一处理图形变更,支持复杂交互与协同编辑。

回顾整个过程,我们成功实现了高效、灵活的撤销恢复功能,提升了软件的可靠性和用户体验。

在下一篇文章我会继续介绍如何加入事务处理,将每个项目存储为一个文件,保存相关的项目数据库文件,并添加应用数据库。如果感兴趣可以持续关注!