低代码实践:原生 JS 实现简易低代码 demo + undo、redo 操作,这撤消、重做快给我整懵了!😤😤😤

2,452 阅读16分钟

扯皮

因为最近要返校开题答辩并且马上就要春招了,开始高强度肝自己的低代码项目,现在基本的业务流程已经实现了,正在补充这些边角料功能,后面有时间也会把自己学到的一些低代码业务抽离出来总结文章分享🤪

今天就来分享一下关于低代码中的撤销、重做功能实现,也就是我们熟知的 undo、redo,无论前端还是后端相信多多少少都听说过这两个名词

作为前端来说哪怕不了解 undo、redo,但撤销、重做这两个操作一定是知道的,哪怕这两个操作也不知道,那浏览器中网页的前进、后退也一定知道,实际上原理都类似

作者也是看了掘金上很多优秀大佬的文章以及相关的开源项目,总结下来关于撤销、重做的实现方案

这次为了方便演示就用原生JS手搓,实现一个简易低代码画布的业务配合讲解撤销重做,先来个效果图:

效果.gif

正文

根据这几天的调研,目前了解到的关于 undo、redo 的实现一共有三种方案:

  1. 快照存储实现:数组结构 + 位置索引
  2. 操作存储实现:后退栈 + 前进栈
  3. 借助相关 undo、redo 库

我们重点来说说前两种实现原理,至于一些开源库的使用,因为我自己的业务实现本身就比较简单,没必要再花费时间再去研究一些其他用法了🙁

快照存储实现方案

实现原理

首先快照是什么?可以理解为一个当前时间节点的状态数据集合,结合低代码业务场景中就是当前画布展示的内容状态,我们把这份状态拷贝一份就是当前的快照

或者再形象一点可以这样理解:我们每在画布上进行一次操作,就给画布拍一张照片记录当前画布的样子,这张照片就是快照

所谓的撤销、重做就是从这一大堆快照中找到你想要回到或者重做的画布状态的照片,根据该照片的画布状态将其还原到当前画布上

我们来画图展示一下这个过程:

现在画布上有一个盒子作为初始化状态,进行多次移动操作,然后进行撤销重做操作,观察画布状态:

snaposhots1.png

snapshots2.png

可以看到在移动三次之后,在当前画布上展示移动三次的状态下我们执行一次 undo 撤销操作,该操作会从所有快照中找到之前保存的 snapshots3,它记录着移动两次后的状态,将其拷贝至画布上

此时画布上展示的是移动两次后的状态,我们再执行一次 redo 重做操作,该操作会从所有快照中找到之前保存的 snapshots4,它记录着移动三次后的状态,将其拷贝至画布上

到目前为止有三个重要的操作,我们来一一说明:

  1. 保存画布的状态为快照:record
  2. 将快照还原到画布上:copy
  3. 根据操作选择对应的快照:move index

首先保存为快照的操作一定属于深拷贝,举个例子:你辛辛苦苦化完妆给自己拍了张照片,拍完照片后再卸妆,照片里肯定还是化妆的你对吧😏

快照还原到画布的操作一定属于深拷贝,举个例子:你照着照片里的妆容进行化妆,可能自己画的有瑕疵或者有一些新的想法加上去,但是这都不会影响你照片里的完美妆容对吧😏

也就是说我们整个流程下来一定会有两次深拷贝,这十分重要

最后就是选择快照,其实就是维护一个索引的问题,我们要进行快照保存的操作时先向后移动一个索引,然后在该索引下保存快照,至于撤销操作就是向前移动索引,然后拿到当前快照还原画布,重做就是向后移动索引,拿到当前快照还原画布

其实通过这个流程已经可以发现一些端倪了,比如重做这个操作一定是建立在撤销操作之上的,如果没有先执行撤销操作就不会有重做,因为重做和保存快照一样也是向后移动索引,但是你一直向后移动索引可能后面都没有快照内容了,所以对此一定要有限制

所以同时又引出了另外一种情形,我们还是直接上图:

这次我们的操作是先移动三次盒子,执行两次撤销操作之后继续执行移动操作,我们观察一下是什么样子:

snapshots3.png

snapshots4.png

注意观察 step6 我们移动盒子之后快照里发生的变化,我们理一遍流程:

当前快照索引位置是在 SnapShots2 上,移动盒子后索引向后移动至 SnapShot3,此时会保存画布为快照,覆盖原来的 SnapShot3,紧接着还需要将后面 SnapShot4 删除

覆盖操作是正常走保存为快照的流程,但是关于删除 SnapShot4 操作需要解释一下:

这就是关于重做操作要注意的一点,我们上面提到重做是基于撤销的,那不妨来看看在移动执行 step6 之前时的状态

可以发现 SnapShot4 其实是由 step2 -> step3 产生的,它依赖 step2 的画布状态,实际上也可以理解为依赖 SnapShot3

但执行 step6 之后会把原来 SnapShot3 的快照覆盖,相当于这时候如果还进一步执行重做操作到 SnapShot4 的话还原的是覆盖之前的 SnapShot3,那 SnapShot4 快照内容已经没有任何意义了,所以需要进行删除

所以我们重新梳理一下画布操作流程:每当进行一次画布操作后,先移动快照索引将当前画布状态保存为快照,之后根据当前的快照索引,将其后面的所有快照全部清空

实现代码

理清了思路就可以上手写代码了,我们直接封装一个 HistorySnapShot 类:

class HistorySnapShot {
  constructor() {
    this.snapshotList = [];
    this.currentIndex = -1;
  }
}

注意初始化索引 currentIndex 为 -1,因为当我们添加记录时是先向后移动索引再向 list 中添加

接下来就来实现保存快照操作 pushRecord:

class HistorySnapShot {
  constructor() {
    this.snapshotList = [];
    this.currentIndex = -1;
  }
  
  pushRecord(item) {
    this.snapshotList[++this.currentIndex] = JSON.parse(JSON.stringify(item));
    // 删除 currentIndex 后面的所有快照
    if (this.currentIndex < this.snapshotList.length - 1) {
      this.snapshotList = this.snapshotList.slice(0, this.currentIndex + 1);
    }
  }
}

注意保存快照的深拷贝操作,这是必不可少的

按照我们之前分析的保存快照流程,如果保存快照后当前索引并不是快照 list 的末尾,则要清空后面的所有快照,我们直接无脑截取前面的快照保留即可

下面来实现撤销操作 backAction:

class HistorySnapShot {
   // ...constructor(){}
  // ...pushRecord(item){}

  backAction() {
    if (this.currentIndex < 0) {
      alert("当前无撤销记录!");
      return;
    }
    const item = this.snapshotList[--this.currentIndex] || [];
    return JSON.parse(JSON.stringify(item));
  }
}

撤销操作的本质就是拿到对应的快照返回,因为第一步需要向前移动索引,因此需要根据当前索引判断一下前面是否还保存有快照

还有一点在于 currentIndex 为 0 执行撤销操作,可以想象我们刚从物料区将一个盒子拖到画布区的场景,此时会保存第一个快照

之后立即执行一次撤销操作,这里 this.snapshotList[--this.currentIndex] 就是 undefined 了,所以我给了一个空数组上去,因为考虑到画布区里的组件内容一般情况下都是通过数组遍历上去的,给一个空数组就代表当前画布区是空白,具体还是按照实际业务场景来定

当然正常情况下返回的快照也要进行一次深拷贝

下面实现重做 forwardAction:

class HistorySnapShot {
   // ...constructor(){}
  // ...pushRecord(item){}
  // ...backAction(){}
  
  forwardAction() {
    if (this.currentIndex >= this.snapshotList.length - 1) {
      alert("当前无前进记录!");
      return;
    }
    const item = this.snapshotList[++this.currentIndex];
    return JSON.parse(JSON.stringify(item));
  }
}

重做思路就没不需要考虑那么多了,判断下索引直接返回快照即可,一样需要注意深拷贝

可以看到整体代码并不多,但确实就这几十行代码实现了最基本的撤销、重做功能:

class HistorySnapShot {
  constructor() {
    this.snapshotList = [];
    this.currentIndex = -1;
  }
  
  // 保存快照
  pushRecord(item) {
    this.snapshotList[++this.currentIndex] = JSON.parse(JSON.stringify(item));
    if (this.currentIndex < this.snapshotList.length - 1) {
      this.snapshotList = this.snapshotList.slice(0, this.currentIndex + 1);
    }
  }
  
  // 撤销
  backAction() {
    if (this.currentIndex < 0) {
      alert("当前无回退记录!");
      return;
    }
    const item = this.snapshotList[--this.currentIndex] || [];
    return JSON.parse(JSON.stringify(item));
  }
  
  // 重做
  forwardAction() {
    if (this.currentIndex >= this.snapshotList.length - 1) {
      alert("当前无前进记录!");
      return;
    }
    const item = this.snapshotList[++this.currentIndex];
    return JSON.parse(JSON.stringify(item));
  }
}

实现缺点

可以看到快照的实现十分简单,只需要理清撤销、重做思路无脑保存画布的快照就行了🧐

但是无法具体到每次撤销、重做的内容,因为我们每次操作保存的是整个画布快照

比如我想要查看操作历史记录,该记录会展示添加某某某组件、移动某某某组件、删除某某某组件等一系列操作内容,这些用快照都无法实现

而且毫无疑问这种方式一定会带来性能问题🤔

一般能想到的优化思路就是限制保存快照的容量,超出容量直接删除前面最早的快照即可

毕竟每个快照都保存画布上的所有内容,如果操作过多的话会有很大的内存占用,站在用户的视角来讲保存过多的快照其实用处也不大

但即便限制容量也依旧会有性能瓶颈,不妨想象一下如果画布上的内容随着组件堆积变得越来越复杂,那针对于每个快照它的大小是巨大的,而且会随着后面的操作越来越大

且不说内存占用问题,后面当我们执行撤销、重做时都会清空画布然后将快照内容重新渲染到画布上,如果快照内容过于复杂甚至会出现卡顿现象

因此这个问题的根本原因还是出在快照的实现方案上,如果想要解决就需要更换方案

操作存储实现方案

实现思路

接下来介绍的一种方案就是对于上述快照方案的一种优化,我们不再存储整个画布的快照,而是存储每一次具体的操作

而针对于每个操作我们会具体到类别,比如:添加、删除、移动、锁定、隐藏...

为了更好使用这些操作信息我们会以栈结构进行存储,其实就是类似浏览器中的网页前进、后退,我们会用前进栈、后退栈两个栈来完成重做、撤销功能

这样的话就可以遍历后退栈、前进栈里的数据来展示出当前每个具体操作的历史记录以及前进记录

我们依旧来几张图感受一下流程:

每当在画布上进行一次操作时,我们会将这个操作 push 到后退栈,同时清空前进栈(和之前删除后面的快照思想一样)它的本质也是一个快照,但这是针对于操作对象的一个快照,而非整个画布

stack1.png

执行一次撤销操作时,我们会从后退栈中 pop 出最近的一次操作,将这个操作 push 到前进栈中,同时将这个操作存储的内容返回给调用者来还原画布内容

stack2.png

执行一次前进操作时,我们会从前进栈中 pop 出最近的一次操作,将这个操作 push 到后退栈中,同时将这个操作存储的内容返回给调用者来还原画布内容

stack3.png

其实整体下来步骤还是比较清晰的,但看完之后你一定会有一个很大的疑问,就是怎么根据这个操作信息还原到画布上呢?

这其实就是该方案一个最大的难点,像之前的快照实现无脑根据每个快照更新整个画布就行,现在的实现是会找到对应的组件,然后根据这里的操作信息,只更新这个组件,如果这个画布有其他组件都不会受影响

这里以移动操作为例,当我们选择画布上的组件触发 mouseDown 事件时我们保存当前组件的位置,当触发 mouseUp 表示移动结束,我们再保存移动后的位置

这时候我们就需要添加新字段记录信息 current:移动后的位置,before:移动前的位置

当进行撤销时,pop、push 之后拿到这个操作信息里的 before 字段进行重新设置位置信息

当进行重做时,pop、push 之后拿到这个操作信息里的 current 字段进行重新设置位置信息

实现代码

我们依旧封装一个类 HistoryStack 来实现,只不过这次只需要两个栈,无需索引指针了

不过需要区分出操作类型,这里根据本次要展示的 demo我划分出了这四个类型:创建、移动、改变尺寸、改变颜色

const HISTORYTYPE = {
  CREATE: "CREATE",
  MOVE: "MOVE",
  SIZE: "SIZE",
  COLOR: "COLOR",
};

class HistoryStack {
  constructor() {
    this.backStack = [];
    this.forwardStack = [];
  }
}

我们还是来看保存记录操作 pushRecord:

class HistoryStack {
  constructor() {
    this.backStack = [];
    this.forwardStack = [];
  }

  pushRecord(item, type) {
    const records = {
      id: Date.now().toString(),
      historyType: type,
      historyData: JSON.parse(JSON.stringify(item)),
    };
    this.backStack.push(records);
    this.forwardStack = [];
  }
}

这次我们对每个操作单独创建一个数据结构,主要是存储该操作的类型以及数据信息的存储,这里的信息也还是需要深拷贝,同时清空前进栈内容

再来看撤回操作 backAction:

class HistoryStack {
  // ...constructor(){}
  // ...pushRecord(item, type){}

  backAction() {
    if (!this.backStack.length) {
      alert("无后退操作记录!");
      return;
    }
    const historyItem = this.backStack.pop();
    this.forwardStack.push(historyItem);
    return historyItem;
  }
}

只需要实现之前描述的 pop、push 操作即可,剩下的就是把当前的操作信息返回给用户让其自己修改画布

重做操作 forwardAction 也是同理:

class HistoryStack {
  // ...constructor(){}
  // ...pushRecord(item, type){}
  // ...backAction(){}
  
  forwardAction() {
    if (!this.forwardStack.length) {
      alert("无前进操作记录!");
      return;
    }
    const historyItem = this.forwardStack.pop();
    this.backStack.push(historyItem);
    return historyItem;
  }
}

全部代码如下:

class HistoryStack {
  constructor() {
    this.backStack = [];
    this.forwardStack = [];
  }

  pushRecord(item, type) {
    const records = {
      id: Date.now().toString(),
      historyType: type,
      historyData: JSON.parse(JSON.stringify(item)),
    };
    this.backStack.push(records);
    this.forwardStack = [];
  }

  backAction() {
    if (!this.backStack.length) {
      alert("无后退操作记录!");
      return;
    }
    const historyItem = this.backStack.pop();
    this.forwardStack.push(historyItem);
    return historyItem;
  }

  forwardAction() {
    if (!this.forwardStack.length) {
      alert("无前进操作记录!");
      return;
    }
    const historyItem = this.forwardStack.pop();
    this.backStack.push(historyItem);
    return historyItem;
  }
}

代码量依旧很少,但是交给用户处理信息还原到画布上的工作量可是很大的☹️,我们继续往下看吧

实现缺点

关于这个方案的优点我们开始就提到了,这里还是来聊聊缺点🤓

可以看到这种实现方式最大的疑惑点就在于如何将操作信息还原到画布上,由于每个操作现在都会区分类型,也就是说拿到返回的操作信息后要枚举出每一种类型做出处理

所以当操作类型越来越多时代码维护的难度也会越来越高,每增加一个类型就要再写一个单独处理的逻辑

但任何方案都要结合实际业务场景的,我的项目中操作类型就那几个还可以接受,所以毅然决然的还是选择了这种方案🧐

搭建低代码 demo

以上两种方案讲完之后就要结合实际业务啦😃,为了方便展示就用原生三件套来快速搭建一个简易的低代码场景,实现最基本的添加组件、移动组件、改变尺寸、颜色基本操作

搭建基本结构样式

首先还是老生常谈的,搭建基本结构样式

主要分为两大区:画布区、历史记录区,画布区中又细分出操作区以及画布内容区,直接上代码吧

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div class="container">
      <!-- 画布区 -->
      <div class="canvas">
        <div>
          <h3>画布区</h3>
        </div>
        <!-- 操作区 -->
        <div class="operator">
          <button>添加组件!</button>
          <button>瞬间移动!</button>
          <button>改变形态!</button>
          <button>改变外观!</button>
          <button>撤回</button>
          <button>前进</button>
        </div>
        <!-- 选择组件提示,后续 JS 操作控制显示内容 -->
        <div class="tip">未选中组件</div>
        <!-- 画布内容区 -->
        <div id="canvas-container"></div>
      </div>
      <!-- 历史记录区 -->
      <div class="stack">
        <h3>历史记录</h3>
      </div>
    </div>
  </body>
</html>

再来点基本样式完事:

body {
  margin: 0;
  padding: 0;
}
div {
  box-sizing: border-box;
}
.container {
  margin: 5vh auto;
  width: 90vw;
  min-width: 1100px;
  height: 90vh;
  display: flex;
  gap: 20px;
}
.operator {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-bottom: 20px;
}
.canvas {
  position: relative;
  padding: 10px;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 700px;
  border: 1px solid blue;
}

.tip {
  position: absolute;
  top: 10px;
  right: 10px;
  color: #aaa;
  font-size: 14px;
}

/* 组件公共样式 */
.box {
  position: absolute;
  cursor: pointer;
}

#canvas-container {
  position: relative;
  flex: 1;
  padding: 10px;
  box-sizing: border-box;
  width: 100%;
  height: 300px;
  border: 1px solid red;
}
.stack {
  width: 200px;
  height: 100%;
  border: 1px solid red;
}

h3 {
  text-align: center;
}

效果如下:

基本结构.png

初始化状态

我们依旧是封装一个 LowCodeCanvas 类来实现所有业务逻辑,先初始化所需要的状态,之后获取页面上需要的 DOM,后需要对它们进行操作:

// 组件公共 style
const initStyle = {
  width: "100px",
  height: "100px",
  backgroundColor: "#f00",
  top: "10px",
  left: "10px",
  zIndex: null,
};

// 组件公共 config
const publicComponentConfig = {
  id: null,
  dom: null,
  style: initStyle,
};

class LowCodeCanvas {
  constructor() {
    this.state = {
      canvasWidth: 0,
      canvasHeight: 0,
      // 存储画布上的所有组件
      componentList: [],
    };
    // 画布内容 DOM
    this.OCanvasContainer = document.querySelector("#canvas-container");
    // 选择组件提示 DOM
    this.Otip = document.querySelector(".tip");
    // 操作按钮 DOM
    const Ooperators = document.querySelector(".operator");
    const buttons = Ooperators.children;
    this.OAddButton = buttons[0];
    this.OMoveButton = buttons[1];
    this.OSizeButton = buttons[2];
    this.OColorButton = buttons[3];
    this.OBackButton = buttons[4];
    this.OForwardButton = buttons[5];
  }

  init() {
    this.state.canvasWidth = this.OCanvasContainer.clientWidth;
    this.state.canvasHeight = this.OCanvasContainer.clientHeight;
  }

}

实现选择组件显示

为了展示出当前选择了画布上的哪个组件要对其进行操作,我们增加一个 currentSelect 状态表示当时选取的组件 id,并对其进行劫持代理,每当修改 value 时就修改视图上的 tip

需要注意的是由于用类封装牵扯到 this 指向问题,需要把 set 改为箭头函数保证访问的 this 是实例对象,这样才能拿到 Otip DOM:

class LowCodeCanvas {
  constructor() {
    this.state = {
      // ...
      currentSelect: new Proxy(
        { value: null },
        {
          get(target, key) {
            return target[key];
          },
          set: (target, key, value) => {
            this.Otip.innerHTML = value ? `已选中组件id:${value}` : `未选中组件`;
            target[key] = value;
            return true;
          },
        }
      ),
    };
   //...
}

操作事件绑定

下面就要对几个按钮绑定事件了,我们一个一个来看:

首先是创建组件,我们统一创建一个 div 代表组件,为它设置其初始样式和状态,并添加点击事件,主要用来改变 currentSelect 表示选中,最后将其 push 到 componentList 里,由于我们没有使用框架进行视图双向绑定,还需要自己手动 appendChild 到视图上:

class LowCodeCanvas {
  // ...
  init() {
    this.canvasWidth = this.OCanvasContainer.clientWidth;
    this.canvasHeight = this.OCanvasContainer.clientHeight;
    this.bindEvent();
  }

  bindEvent() {
    this.OAddButton.addEventListener("click", this.handleCreateComponent.bind(this));
  }

  // 创建组件
  handleCreateComponent() {
    const div = document.createElement("div");
    // 深拷贝公共配置
    const config = JSON.parse(JSON.stringify(publicComponentConfig));
    // 设置组件层级,根据添加的顺序依次递增
    config.style.zIndex = this.state.componentList.length;
    config.id = Date.now().toString();
    config.dom = div;
    div.id = config.id;
    div.classList.add("box");
    div.addEventListener("click", this.handleSelectComponent.bind(this, div.id));
    this.setComponentStyle(div, config.style);
    this.state.componentList.push(config);
    this.OCanvasContainer.appendChild(div);
  }
  // 选中组件
  handleSelectComponent(id) {
    currentSelect.value = id;
  }
  // 设置组件 DOM 样式
  setComponentStyle(dom, style) {
    for (const key in style) {
      dom.style[key] = style[key];
    }
  }
}

我们来初始化再看看效果,启动!

const lowCode = new LowCodeCanvas();
lowCode.init();

可以看到添加组件和选中组件都实现了:

添加组件.gif

下面来实现移动组件,这里就不再实现拖拽了,我们直接生成一个随机位置然后设置样式就行🤪

需要注意的是移动的前提肯定是先选中组件,所以如果 currentSelect 是空的话是不允许操作的,再限制一下随机生成位置的范围,最好不要超出画布:


class LowCodeCanvas {
  // ...
  bindEvent() {
    this.OAddButton.addEventListener("click", this.handleCreateComponent.bind(this));
    // new
    this.OMoveButton.addEventListener("click", this.handleRandomMoveComponent.bind(this));
  }

  // 随机设置组件位置
  handleRandomMoveComponent() {
    if (!this.state.currentSelect.value) {
      alert("未选中组件!");
      return;
    }
    const { componentList, canvasHeight, canvasWidth } = this.state;
    const item = componentList.find((item) => item.id === this.state.currentSelect.value);
    const rect = item.dom.getBoundingClientRect();
    item.style.top = `${this.getRandomNumber(0, canvasHeight - rect.height)}px`;
    item.style.left = `${this.getRandomNumber(0, canvasWidth - rect.width)}px`;
    this.setComponentStyle(item.dom, item.style);
  }

  // 随机生成范围数
  getRandomNumber(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
}

来看效果,没什么大问题:

瞬间移动.gif

下面来看改变形态,采取同样的思路随机生成一个大小完事:

class LowCodeCanvas {
  // ...
  bindEvent() {
    this.OAddButton.addEventListener("click", this.handleCreateComponent.bind(this));
    this.OMoveButton.addEventListener("click", this.handleRandomMoveComponent.bind(this));
    // new
    this.OSizeButton.addEventListener("click", this.handleRandomSizeComponent.bind(this));
  }

  // 随机设置组件大小
  handleRandomSizeComponent() {
    if (!this.state.currentSelect.value) {
      alert("未选中组件!");
      return;
    }
    const { componentList } = this.state;
    const item = componentList.find((item) => item.id === this.state.currentSelect.value);
    item.style.width = `${this.getRandomNumber(50, 250)}px`;
    item.style.height = `${this.getRandomNumber(50, 250)}px`;
    this.setComponentStyle(item.dom, item.style);
  }

  // 随机生成范围数
  getRandomNumber(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
}

改变形态.gif

最后就是改变外观了,我们随机生成一个随机十六进制颜色字符串设置样式即可:

class LowCodeCanvas {
  // ...
  bindEvent() {
    this.OAddButton.addEventListener("click", this.handleCreateComponent.bind(this));
    this.OMoveButton.addEventListener("click", this.handleRandomMoveComponent.bind(this));
    this.OSizeButton.addEventListener("click", this.handleRandomSizeComponent.bind(this));
    // new
    this.OColorButton.addEventListener("click", this.handleRandomColorComponent.bind(this));
  }

  // 随机设置组件颜色
  handleRandomColorComponent() {
    if (!this.state.currentSelect.value) {
      alert("未选中组件!");
      return;
    }
    const { componentList } = this.state;
    const item = componentList.find((item) => item.id === this.state.currentSelect.value);
    item.style.backgroundColor = this.getRandomHexColor();
    this.setComponentStyle(item.dom, item.style);
  }

  // 随机生成颜色
  getRandomHexColor() {
    let color = "#";
    for (let i = 0; i < 6; i++) {
      color += Math.floor(Math.random() * 16).toString(16);
    }
    return color;
  }
}

改变外观.gif

以上就是实现了低代码的几个基本操作 demo,全部的源代码如下👇:

demo 结合快照存储方案

下面就回到了我们文章的主题,把上面实现的过程结合快照方案来看看效果

把之前快照实现的代码拉下来,然后在 LowCode 的构造函数中实例化快照类

class LowCodeCanvas {
    constructor() {
        // ...
        this.historySnapShot = new HistorySnapShot();        
    }
}

再给撤销、重做这两个按钮绑定点击事件,快照方案就无脑拿到存储的快照 list 重新挂载即可:

class LowCodeCanvas {
  constructor() {
    // ...
    this.historySnapShot = new HistorySnapShot();        
  }
  bindEvent() {
    this.OAddButton.addEventListener("click", this.handleCreateComponent.bind(this));
    this.OMoveButton.addEventListener("click", this.handleRandomMoveComponent.bind(this));
    this.OSizeButton.addEventListener("click", this.handleRandomSizeComponent.bind(this));
    this.OColorButton.addEventListener("click", this.handleRandomColorComponent.bind(this));
    // new
    this.OBackButton.addEventListener("click", this.handleOperator.bind(this, "back"));
    this.OForwardButton.addEventListener("click", this.handleOperator.bind(this, "forward"));
 }
 
  handleOperator(type) {
    const newList = type === "back" ? this.historySnapShot.backAction() : this.historySnapShot.forwardAction();
    if (!newList) return;
    this.state.componentList = newList;
    this.rerenderComponentList();
  }
  
  rerenderComponentList() {
    // 清空原画布
    this.OCanvasContainer.innerHTML = "";
    const fragment = document.createDocumentFragment();
    // 将新的 componentList 挂载到画布上
    this.state.componentList.forEach((item, index) => {
      const div = document.createElement("div");
      item.style.zIndex = index;
      item.dom = div;
      div.id = item.id;
      div.classList.add("box");
      this.setComponentStyle(div, item.style);
      div.addEventListener("click", this.handleSelectComponent.bind(this, div.id));
      fragment.appendChild(div);
    });
    this.OCanvasContainer.appendChild(fragment);
  }
}

这里的重新挂载到画布方法和一开始添加组件的方法其实有很多重复代码,实际上可以进行一次抽离,但写到这懒了就这样吧,摆了🤣

最后我们给四个操作方法末尾都增加一个保存为快照的操作:

class LowCodeCanvas {
  handleCreateComponent() {
    // ...
    this.historySnapShot.pushRecord(this.state.componentList);
  }

  handleRandomMoveComponent() {
    // ...
    this.historySnapShot.pushRecord(this.state.componentList);
  }

  handleRandomSizeComponent() {
    // ...
    this.historySnapShot.pushRecord(this.state.componentList);
  }

  handleRandomColorComponent() {
    // ...
    this.historySnapShot.pushRecord(this.state.componentList);
  }
}

注意一定是要放到操作完成之后,我们保存的是操作之后画布上的状态快照

来看效果,虽然整体实现有点边角瑕疵,但是不影响正常业务😁

可以看到结合快照方案其实对原来的 demo 代码侵入较少,这都得益于快照方案的无脑替换,但是问题还是之前讨论的哪些缺点,而且也没法具体到历史记录操作:

快照方式.gif

demo 结合操作存储方案

操作存储的方案实现起来就比较麻烦了,首先我们给原来的 publicComponentConfig 增加一个 beforeStyle 字段,它将会用来保存组件在操作之前的状态样式

比如移动一个盒子,beforeStyle 就是移动前的位置,移动后会更新 style 里的位置

// 组件公共 config
const publicComponentConfig = {
  id: null,
  dom: null,
  style: initStyle,
  // new
  beforeStyle: initStyle,
};

紧接着我们把之前封装的操作存储类拿过来,在初始化中进行实例化:

class LowCodeCanvas {
  constructor() {
    // ...
    // new
    this.historyStack = new HistoryStack();
  }
 }

然后先侵入创建、位置、尺寸、外观这四个操作的事件处理函数,除了创建操作,其余都需要在操作前保存样式信息至 beforeStyle,操作后再 pushRecord 存入回退历史栈:

class LowCodeCanvas {
  handleCreateComponent() {
    // ...
    // 记录
    this.historyStack.pushRecord(config, HISTORYTYPE.CREATE);
  }

  handleRandomMoveComponent() {
    // ...
    item.beforeStyle = { ...item.style };
    // ... 移动操作设置样式
    // 记录
    this.historyStack.pushRecord(item, HISTORYTYPE.MOVE);
  }

  handleRandomSizeComponent() {
    // ...
    item.beforeStyle = { ...item.style };
    // ...尺寸操作设置样式
    // 记录
    this.historyStack.pushRecord(item, HISTORYTYPE.SIZE);
  }

  handleRandomColorComponent() {
    // ...
    item.beforeStyle = { ...item.style };
    // ...颜色操作设置样式
    // 记录
    this.historyStack.pushRecord(item, HISTORYTYPE.COLOR);
  }
}

下面开始实现撤销、重做事件处理函数,我们依旧使用 type 区分,只不过这次要处理的内容就比较多了

我们拿到 history 中的 item 信息时需要根据其 historyType 来区分是什么操作,根据这个操作再来更改画布上组件的内容:

class LowCodeCanvas {
  // ...
  
  handleOperator(type) {
    const historyItem = type === "back" ? this.historyStack.backAction() : this.historyStack.forwardAction();
    if (!historyItem) return;

    const isCreate = historyItem.historyType === HISTORYTYPE.CREATE;
    
    // 当前 item 是 CREATE 且进行撤销操作,说明是要把创建的组件撤销掉,即删除
    if (isCreate && type === "back") {
      this.removeComponent(historyItem.historyData);
      return;
    }
    // 当前 item 是 CREATE 且进行重做操作,说明已经把创建的组件撤销掉了,需要进行还原,即添加
    if (isCreate && type === "forward") {
      this.addComponent(historyItem.historyData);
      return;
    }
    
    // 其余类型的撤销操作,只需要根据 beforeStyle 进行设置
    if (type === "back") {
      this.backComponentStyle(historyItem.historyData, historyItem.historyType);
      return;
    }
    
    // 其余类型的重做操作,只需要再还原 style
    if (type === "forward") {
      this.forwardComponentStyle(historyItem.historyData);
      return;
    }
  }
  
  // 撤销操作:移除组件
  removeComponent(item) {
    const dom = this.state.componentList.find((i) => i.id === item.id).dom;
    this.state.componentList = this.state.componentList.filter((i) => i.id !== item.id);
    this.OCanvasContainer.removeChild(dom);
  }
 
  // 重做操作:将原来撤销的组件还原
  addComponent(item) {
    const div = document.createElement("div");
    const config = item;
    config.dom = div;
    div.id = config.id;
    div.classList.add("box");
    div.addEventListener("click", this.handleSelectComponent.bind(this, div.id));
    this.setComponentStyle(div, config.style);
    this.state.componentList.push(config);
    this.OCanvasContainer.appendChild(div);
  }
  
  // 撤销操作:根据之前保存的 beforeSyle 样式和要进行的操作类型区分出变化的 style 进行设置
  backComponentStyle(item, historyType) {
    const component = this.state.componentList.find((i) => i.id === item.id);
    const newStyle = { ...item.style };
    switch (historyType) {
      case HISTORYTYPE.MOVE:
        newStyle.left = item.beforeStyle.left;
        newStyle.top = item.beforeStyle.top;
        break;
      case HISTORYTYPE.SIZE:
        newStyle.width = item.beforeStyle.width;
        newStyle.height = item.beforeStyle.height;
        break;
      case HISTORYTYPE.COLOR:
        newStyle.backgroundColor = item.beforeStyle.backgroundColor;
        break;
    }
    component.style = newStyle;
    this.setComponentStyle(component.dom, newStyle);
  }
  
  // 重做操作:使用原来的 style 样式进行设置
  forwardComponentStyle(item) {
    const component = this.state.componentList.find((i) => i.id === item.id);
    component.style = { ...item.style };
    this.setComponentStyle(component.dom, item.style);
  }
}

到此我们就实现了撤销、重做操作,可以看到这里处理函数十分麻烦,必须要根据保存的 historyItem 先找到在 componentList 里的 component 位置,再根据存储的 historyItem 类型区分出操作

不像之前的快照实现无需区分,直接无脑设置 componentList 即可

来看效果:

操作存储方案展示.gif

最后我们把历史记录展示出来,在 HTML 记录区中增加一个 history-list div 来存储历史记录,css 增加 history-item 单条样式:

<!-- 历史记录区 -->
<div class="stack">
    <h3>历史记录</h3>
    <div class="history-list"></div>
</div>
.history-item {
  font-size: 12px;
  color: #6b6a6a;
  margin: 10px 0;
  padding: 0 10px;
  width: 200px;
  height: 20px;
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}

后面我们需要改造之前构造函数中获取 DOM 操作,为了方便可以采用事件代理的方式,我们把渲染 historylist 的操作统一放到 operators 上,它是所有 button 的父节点,每当点击 button 会事件冒泡到 operators 上:

class LowCodeCanvas {
  constructor() {
     // ...
    // 操作按钮 DOM
    this.Ooperators = document.querySelector(".operator");  
  }
  
  bindEvent() {
    // ...
    this.Ooperators.addEventListener("click", this.handleRenderHistory.bind(this));
  }
  
  handleRenderHistory() {
    const list = this.historyStack.backStack;
    this.OHistoryList.innerHTML = list
      .map((i) => `<div class="history-item">${i.historyType} 操作 - 组件id: ${i.historyData.id}</div>`)
      .join("");
  }
}

最终效果:

效果.gif

END

本次只是展示了一个低代码的 demo,实际的 undo、redo 配合业务可能会更加复杂,所以具体采用什么样的方案一定是需要结合项目业务来选择

全部源码如下👇: