京东低代码平台:通天塔楼层撤销重做功能实现简介

lxf2023-05-05 02:36:01

撤销(Undo)重做(Redo)功能是绝大部分Web应用中都有的功能。该功能的存在给用户提供了操作上的后悔药,给用户提供更优的使用体验。本文将介绍关于通天塔-楼层画布的撤销重做实现方案。

一、背景概述

通天塔

通天塔是一个面向京东运营和商家的活动搭建平台,提供了丰富和强大的模板,平台用户可基于模板自由搭建活动页面

楼层画布

通天塔提供用户灵活的楼层画布,用户可通过低代码搭建而非硬编码方式生产楼层,搭建出个性化的样式。 简单来说,产研通过指定元素协议和规范,保障系统性能与稳定的同时支持生产各种样式灵活、数据多元的元素。用户在画布中实现元素的增删、自由拖拽,修改样式等操作,实现生成可适配多端的活动楼层。

京东低代码平台:通天塔楼层撤销重做功能实现简介

体验示意图

撤销重做

用户在楼层画布里可以进行的操作存在多样化,比如删除、添加元素,表单变动,移动元素位置等。无论是什么类型的操作,终究都是用户对某个对象的操作,每一步操作都会对操作对象产生一定的副作用,使其状态发生改变。如下图所示,页面初始化后,操作对象的初始化状态为S1,用户进行A、B操作后,此时操作对象的状态为S3。如果没有撤销重做功能,我们只可以头也不回的往前走(始终都是新的状态),无法再次回到之前的操作状态。为了提升用户体验,我们想要给用户提供一瓶“后悔药”,用以更加高效的生产模板。

京东低代码平台:通天塔楼层撤销重做功能实现简介

有了撤销重做功能后我们可以做的事情有哪些呢?

  1. 可以记录用户的系列操作过程,并能根据记录恢复到当时的状态
  2. 用户在进行某个操作过后,能通过撤销(Undo)回到操作之前的状态 京东低代码平台:通天塔楼层撤销重做功能实现简介
  3. 用户在撤销之后,能通过重做(Redo)恢复前一次撤销的操作 京东低代码平台:通天塔楼层撤销重做功能实现简介
  4. 用户在任意状态下,如果有新的操作(如 S6) ,原来保存在该状态之后的操作记录(如 S5)会被废弃掉 京东低代码平台:通天塔楼层撤销重做功能实现简介

二、撤销重做功能实现

维护一个队列用来存放用户操作过程,维护一个指针指向当前操作位置

用户的每一步操作,存放到相应的队列中,并将指针指向当前的节点。当进行撤销重做时,位移指针,并取出相应指针下的数据,恢复到当前操作数据即可。

class Store {
   constructor(){
       this.queue = []
       this.pointer = null
   }
}

提供一个 record 方法,当用户有操作时,会对应用数据造成变更,此时调用 record 方法记录当前状态。注意记录的位置:在这次操作之前应用的状态保存在队列的 pointer 指针处,有新的操作时,pointer 指针处后面的操作记录都应当清空。然后操作应该保存在指 pointer 后面。

class Store {
  // ...
  record(data) {
    while (this.pointer < this.queue.length - 1) {
      this.queue.pop()
    }
    this.queue.push(data)
  }
}

实现撤销(Undo)重做(Redo)方法。只需要控制指针走向并抛出当前指针指向数据即可。

class Store {
   // ...
   undo = (step = 1) => {
     this.pointer -= step
     return this.queue[this.pointer]
   }
  redo = (step = 1) => {
    this.pointer += step
    return this.queue[this.pointer]
  }
}

三、楼层画布的撤销重做

撤销重做的方案经过第二步的实现之后,我们就可以和搭建类楼层进行功能关联了。我们需要思考需要将哪些动作添加到“队列”中,貌似一切用户所走过的操作都应该被存储进去(确实如此),那是不是应该对操作对象的每一次修改都进行监听并添加到“队列”中呢?

因为业务的复杂性,我们这里每一次对操作对象的修改,可能不仅含有用户的操作,还有一些副作用的产生(副作用也会修改到操作对象的数据)。那么有了这样的一个前提条件后,我们就不能以监听操作对象的变化进行处理了。

那应该怎么样进行处理呢?

  1. 我们可以对操作对象的每一次修改动作进行整理,对用户的操作动作(元素的添加、删除、位置移动等)进行打标,针对已经打标的动作进行 撤销重做 处理。
  2. 用户操作和其产生的副作用修改会在一个时间段内发生,我们也可以利用时间差来进行处理(在一定的时间差内的修改都算作一次操作)。

这里考虑到功能实现的周期长度,我们这里采用时间差的方式进行处理。

let timer = 0, interval = 400;
/**
 * newState: 更改后的新data数据
 * store: 当前画布撤销重做store
**/
onDataChange = (newState, store) => {
  const _timer = new Date().getTime()
  if (_timer - timer < 0) return newState
  if (_timer - timer < interval) {
    // 如果是在时间差内触发的修改,则使用更新操作,对当前数据进行修改操作
 		store.update(data)
  } else {
  	store.record(data)
  }
  timer = _timer
  return newState
}

// 撤销重做下的更新动作
class Store {
  // ...
  update = (data, pointer = this.queue.length - 1) => {
        this.queue[pointer] = data
   }
}

四、落地效果

关于搭建楼层画布的撤销重做目前已经落地,效果呈现如下图所示。通过撤销重做功能+快捷键的支持为用户提供了更加自由便捷的模板搭建方式。

京东低代码平台:通天塔楼层撤销重做功能实现简介

灵活化模板撤销重做功能示意图

五、实践思考

一、撤销重做功能实现

在本次用例实现中,我们采用了快照式的方案,也可以采用命令式的方案进行实现。关于命令式的实现方案:针对用户的每一次操作都实现一个正向操作和逆操作的方法,统一提交到一个命令执行器中。命令执行器保存当前的一对方法,并立即执行正向操作方法,当需要撤销的时候,执行对应的逆操作方法,回到当此操作之前的状态,重做时再执行正向操作方法。

二、撤销重做功能集成

对于撤销重做的功能集成,我们做了两点的思考:

一种是通过用户操作打标的方式,这种方式的优点是我们可以明确的知道哪些改变需要加入到撤销重做的暂存区中,同理这种情况只会存放有意义的数据,而会忽略掉需要同时修改的副作用数据,那么这些副作用数据的改变是否会对整体的展示效果,功能等有影响呢?这个问题也是需要我们去衡量的。

另一种是通过时间差的方式处理,这种方式的优点是代码入侵量小、开发周期短,同时副作用的修改也会被同步到撤销重做的暂存区中,我们无需再去考虑副作用的改变。对于灵活化模板来说,用户操作有面向元素的单个操作,比如修改元素的样式,也存在面向元素的连续操作,比如使用快捷操作位移当前元素,面对用户的连续操作行为,使用时间差的方式,我们也会对同一时间差内的偏移量进行合并,一定程度上,我们节省了资源空间。我们这种方式有什么缺点呢?采用当前方式的话,如果遇到异步IO的情况,就会存在一定的风险;面对存在连续的操作行为,如果有保存连续数据的需求,显然我们这里是无法满足的。

具体选择哪种方案还是需要衡量好具体的业务需求。