基于 Immutable.js 实现撤销重做功能的实例代码


Posted in Javascript onMarch 01, 2018

浏览器的功能越来越强大,许多原来由其他客户端提供的功能渐渐转移到了前端,前端应用也越来越复杂。许多前端应用,尤其是一些在线编辑软件,运行时需要不断处理用户的交互,提供了撤消重做功能来保证交互的流畅性。不过为一个应用实现撤销重做功能并不是一件容易的事情。 Redux官方文档中 介绍了如何在 redux 应用中实现撤销重做功能。基于 redux 的撤销功能是一个自顶向下的方案:引入 redux-undo 之后所有的操作都变为了「可撤销的」,然后我们不断修改其配置使得撤销功能变得越来越好用(这也是 redux-undo 有那么多配置项 的原因)。

本文将采用自底向上的思路,以一个简易的在线画图工具为例子,使用TypeScript 、 Immutable.js 实现一个实用的「撤消重做」功能。大致效果如下图所示:

基于 Immutable.js 实现撤销重做功能的实例代码

第一步:确定哪些状态需要历史记录,创建自定义的 State 类

并非所有的状态都需要历史记录。许多状态是非常琐碎的,尤其是一些与鼠标或者键盘交互相关的状态,例如在画图工具中拖拽一个图形时我们需要设置一个「正在进行拖拽」的标记,页面会根据该标记显示对应的拖拽提示,显然该拖拽标记不应该出现在历史记录中;而另一些状态无法被撤销或是不需要被撤销,例如网页窗口大小,向后台发送过的请求列表等。

排除那些不需要历史记录的状态,我们将剩下的状态用 Immutable Record 封装起来,并定义 State 类:

// State.ts
import { Record, List, Set } from 'immutable'
const StateRecord = Record({
 items: List<Item>
 transform: d3.ZoomTransform
 selection: number
})
// 用类封装,便于书写 TypeScript,注意这里最好使用Immutable 4.0 以上的版本
export default class State extends StateRecord {}

这里我们的例子是一个简易的在线画图工具,所以上面的 State 类中包含了三个字段,items 用来记录已经绘制的图形,transform 用来记录画板的平移和缩放状态,selection 则表示目前选中的图形的 ID。而画图工具中的其他状态,例如图形绘制预览,自动对齐配置,操作提示文本等,则没有放在 State 类中。

第二步:定义 Action 基类,并为每种不同的操作创建对应的 Action 子类

与 redux-undo 不同的是,我们仍然采用 命令模式 :定义基类 Action,所有对 State 的操作都被封装为一个 Action 的实例;定义若干 Action 的子类,对应于不同类型的操作。

在 TypeScript 中,Action 基类用 Abstract Class 来定义比较方便。

// actions/index.ts
export default abstract class Action {
 abstract next(state: State): State
 abstract prev(state: State): State
 prepare(appHistory: AppHistory): AppHistory { return appHistory }
 getMessage() { return this.constructor.name }
}

Action 对象的 next 方法用来计算「下一个状态」,prev 方法用来计算「上一个状态」。getMessage 方法用来获取 Action 对象的简短描述。通过 getMessage 方法,我们可以将用户的操作记录显示在页面上,让用户更方便地了解最近发生了什么。prepare 方法用来在 Action 第一次被应用之前,使其「准备好」,AppHistory 的定义在本文后面会给出。

Action 子类举例

下面的 AddItemAction 是一个典型的 Action 子类,用于表达「添加一个新的图形」。

// actions/AddItemAction.ts
export default class AddItemAction extends Action {
 newItem: Item
 prevSelection: number
 constructor(newItem: Item) {
 super()
 this.newItem = newItem
 }
 prepare(history: AppHistory) {
 // 创建新的图形后会自动选中该图形,为了使得撤销该操作时 state.selection 变为原来的值
 // prepare 方法中读取了「添加图形之前 selection 的值」并保存到 this.prevSelection
 this.prevSelection = history.state.selection
 return history
 }
 next(state: State) {
 return state
  .setIn(['items', this.newItem.id], this.newItem)
  .set('selection', this.newItemId)
 }
 prev(state: State) {
 return state
  .deleteIn(['items', this.newItem.id])
  .set('selection', this.prevSelection)
 }
 getMessage() { return `Add item ${this.newItem.id}` }
}

运行时行为

应用运行时,用户交互产生一个 Action 流,每次产生 Action 对象时,我们调用该对象的 next 方法来计算后一个状态,然后将该 action 保存到一个列表中以备后用;用户进行撤销操作时,我们从 action 列表中取出最近一个 Action 并调用其 prev 方法。应用运行时,next/prev 方法被调用的情况大致如下:

// initState 是一开始就给定的应用初始状态
// 某一时刻,用户交互产生了 action1 ...
state1 = action1.next(initState)
// 又一个时刻,用户交互产生了 action2 ...
state2 = action2.next(state1)
// 同样的,action3也出现了 ...
state3 = action3.next(state2)
// 用户进行撤销,此时我们需要调用最近一个action的prev方法
state4 = action3.prev(state3)
// 如果再次进行撤销,我们从action列表中取出对应的action,调用其prev方法
state5 = action2.prev(state4)
// 重做的时候,取出最近一个被撤销的action,调用其next方法
state6 = action2.next(state5)
Applied-Action

为了方便后面的说明,我们对 Applied-Action 进行一个简单的定义:Applied-Action 是指那些操作结果已经反映在当前应用状态中的 action;当 action 的 next 方法执行时,该 action 变为 applied;当 prev 方法被执行时,该 action 变为 unapplied。

第三步:创建历史记录容器 AppHistory

前面的 State 类用于表示某个时刻应用的状态,接下来我们定义 AppHistory 类用来表示应用的历史记录。同样的,我们仍然使用 Immutable Record 来定义历史记录。其中 state 字段用来表达当前的应用状态,list 字段用来存放所有的 action,而 index 字段用来记录最近的 applied-action 的下标。应用的历史状态可以通过 undo/redo 方法计算得到。apply 方法用来向 AppHistory 中添加并执行具体的 Action。具体代码如下:

// AppHistory.ts
const emptyAction = Symbol('empty-action')
export const undo = Symbol('undo')
export type undo = typeof undo // TypeScript2.7之后对symbol的支持大大增强
export const redo = Symbol('redo')
export type redo = typeof redo
const AppHistoryRecord = Record({
 // 当前应用状态
 state: new State(),
 // action 列表
 list: List<Action>(),
 // index 表示最后一个applied-action在list中的下标。-1 表示没有任何applied-action
 index: -1,
})
export default class AppHistory extends AppHistoryRecord {
 pop() { // 移除最后一项操作记录
 return this
  .update('list', list => list.splice(this.index, 1))
  .update('index', x => x - 1)
 }
 getLastAction() { return this.index === -1 ? emptyAction : this.list.get(this.index) }
 getNextAction() { return this.list.get(this.index + 1, emptyAction) }
 apply(action: Action) {
 if (action === emptyAction) return this
 return this.merge({
  list: this.list.setSize(this.index + 1).push(action),
  index: this.index + 1,
  state: action.next(this.state),
 })
 }
 redo() {
 const action = this.getNextAction()
 if (action === emptyAction) return this
 return this.merge({
  list: this.list,
  index: this.index + 1,
  state: action.next(this.state),
 })
 }
 undo() {
 const action = this.getLastAction()
 if (action === emptyAction) return this
 return this.merge({
  list: this.list,
  index: this.index - 1,
  state: action.prev(this.state),
 })
 }
}

第四步:添加「撤销重做」功能

假设应用中的其他代码已经将网页上的交互转换为了一系列的 Action 对象,那么给应用添上「撤销重做」功能的大致代码如下:

type HybridAction = undo | redo | Action
// 如果用Redux来管理状态,那么使用下面的reudcer来管理那些「需要历史记录的状态」
// 然后将该reducer放在应用状态树中合适的位置
function reducer(history: AppHistory, action: HybridAction): AppHistory {
 if (action === undo) {
 return history.undo()
 } else if (action === redo) {
 return history.redo()
 } else { // 常规的 Action
 // 注意这里需要调用prepare方法,好让该action「准备好」
 return action.prepare(history).apply(action)
 }
}
// 如果是在 Stream/Observable 的环境下,那么像下面这样使用 reducer
const action$: Stream<HybridAction> = generatedFromUserInteraction
const appHistory$: Stream<AppHistory> = action$.fold(reducer, new AppHistory())
const state$ = appHistory$.map(h => h.state)
// 如果是用回调函数的话,大概像这样使用reducer
onActionHappen = function (action: HybridAction) {
 const nextHistory = reducer(getLastHistory(), action)
 updateAppHistory(nextHistory)
 updateState(nextHistory.state)
}

第五步:合并 Action,完善用户交互体验

通过上面这四个步骤,画图工具拥有了撤消重做功能,但是该功能用户体验并不好。在画图工具中拖动一个图形时,MoveItemAction 的产生频率和 mousemove 事件的发生频率相同,如果我们不对该情况进行处理,MoveItemAction 马上会污染整个历史记录。我们需要合并那些频率过高的 action,使得每个被记录下来的 action 有合理的撤销粒度。

每个 Action 在被应用之前,其 prepare 方法都会被调用,我们可以在 prepare 方法中对历史记录进行修改。例如,对于 MoveItemAction,我们判断上一个 action 是否和当前 action 属于同一次移动操作,然后来决定在应用当前 action 之前是否移除上一个 action。代码如下:

// actions/MoveItemAction.ts
export default class MoveItemAction extends Action {
 prevItem: Item
 // 一次图形拖动操作可以由以下三个变量来进行描述:
 // 拖动开始时鼠标的位置(startPos),拖动过程中鼠标的位置(movingPos),以及拖动的图形的 ID
 constructor(readonly startPos: Point, readonly movingPos: Point, readonly itemId: number) {
 // 上一行中 readonly startPos: Point 相当于下面两步:
 // 1. 在MoveItemAction中定义startPos只读字段
 // 2. 在构造函数中执行 this.startPos = startPos
 super()
 }
 prepare(history: AppHistory) {
 const lastAction = history.getLastAction()
 if (lastAction instanceof MoveItemAction && lastAction.startPos == this.startPos) {
  // 如果上一个action也是MoveItemAction,且拖动操作的鼠标起点和当前action相同
  // 则我们认为这两个action在同一次移动操作中
  this.prevItem = lastAction.prevItem
  return history.pop() // 调用pop方法来移除最近一个action
 } else {
  // 记录图形被移动之前的状态,用于撤销
  this.prevItem = history.state.items.get(this.itemId)
  return history
 }
 }
 next(state: State): State {
 const dx = this.movingPos.x - this.startPos.x
 const dy = this.movingPos.y - this.startPos.y
 const moved = this.prevItem.move(dx, dy)
 return state.setIn(['items', this.itemId], moved)
 }
 prev(state: State) {
 // 撤销的时候我们直接使用已经保存的prevItem即可
 return state.setIn(['items', this.itemId], this.prevItem)
 }
 getMessage() { /* ... */ }
}

从上面的代码中可以看到,prepare 方法除了使 action 自身准备好之外,它还可以让历史记录准备好。不同的 Action 类型有不同的合并规则,为每种 Action 实现合理的 prepare 函数之后,撤消重做功能的用户体验能够大大提升。

一些其他需要注意的地方

撤销重做功能是非常依赖于不可变性的,一个 Action 对象在放入 AppHistory.list 之后,其所引用的对象都应该是不可变的。如果 action 所引用的对象发生了变化,那么在后续撤销时可能发生错误。本方案中,为了方便记录操作发生时的一些必要信息,Action 对象的 prepare 方法中允许出现原地修改操作,但是 prepare 方法只会在 action 被放入历史记录之前调用一次,action 一旦进入纪录列表就是不可变的了。

总结

以上就是实现一个实用的撤销重做功能的所有步骤了。不同的前端项目有不同的需求和技术方案,有可能上面的代码在你的项目中一行也用不上;不过撤销重做的思路应该是相同的,希望本文能够给你带来一些启发。

以上所述是小编给大家介绍的基于 Immutable.js 实现撤销重做功能的实例代码,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对三水点靠木网站的支持!

Javascript 相关文章推荐
javascript 清空form表单中某种元素的值
Dec 26 Javascript
JavaScript 判断浏览器是否支持SVG的代码
Mar 21 Javascript
JS文本获得焦点清除文本文字的示例代码
Jan 13 Javascript
IE6已终止操作问题的2种情况及解决
Apr 23 Javascript
jquery仿百度经验滑动切换浏览效果
Apr 14 Javascript
jQuery插件expander实现图片翻转特效
May 21 Javascript
javascript中Date()函数在各浏览器中的显示效果
Jun 18 Javascript
js实现选中页面文字将其分享到新浪微博
Nov 05 Javascript
Node.js中JavaScript操作MySQL的常用方法整理
Mar 01 Javascript
详解vue.js的devtools安装
May 26 Javascript
详解如何在项目中使用jest测试react native组件
Feb 09 Javascript
vue中利用iscroll.js解决pc端滚动问题
Feb 15 Javascript
浅谈vue的几种绑定变量的值 防止其改变的方法
Mar 01 #Javascript
JavaScript 中定义函数用 var foo = function () {} 和 function foo()区别介绍
Mar 01 #Javascript
详解用Node.js写一个简单的命令行工具
Mar 01 #Javascript
vue-prop父组件向子组件进行传值的方法
Mar 01 #Javascript
Node.js 利用cheerio制作简单的网页爬虫示例
Mar 01 #Javascript
vue父组件向子组件传递多个数据的实例
Mar 01 #Javascript
React Native 自定义下拉刷新上拉加载的列表的示例
Mar 01 #Javascript
You might like
在线竞拍系统的PHP实现框架(一)
2006/10/09 PHP
PHP 变量定义和变量替换的方法
2009/07/30 PHP
PHP网页游戏学习之Xnova(ogame)源码解读(三)
2014/06/23 PHP
Codeigniter购物车类不能添加中文的解决方法
2014/11/29 PHP
Zend Framework常用校验器详解
2016/12/09 PHP
PHP实现统计代码行数小工具
2019/09/19 PHP
有关DOM元素与事件的3个谜题
2010/11/11 Javascript
在多个页面使用同一个HTML片段的代码
2011/03/04 Javascript
jquery仿QQ商城带左右按钮控制焦点图片切换滚动效果
2013/06/27 Javascript
JS弹出可拖拽可关闭的div层完整实例
2015/02/13 Javascript
jQuery实现自动调整字体大小的方法
2015/06/15 Javascript
纯CSS3代码实现滑动开关效果
2015/08/19 Javascript
Bootstrap每天必学之折叠(Collapse)插件
2016/04/25 Javascript
ES6概念 ymbol.for()方法
2016/12/25 Javascript
EasyUi 打开对话框后控件赋值及赋值后不显示的问题解决办法
2017/01/19 Javascript
JavaScript轻松创建级联函数的方法示例
2017/02/10 Javascript
javascript trie前缀树的示例
2018/01/29 Javascript
JavaScript DOM元素常见操作详解【添加、删除、修改等】
2018/05/09 Javascript
深入理解Vue 组件之间传值
2018/08/16 Javascript
Vue动画事件详解及过渡动画实例
2019/02/09 Javascript
微信小程序动态添加view组件的实例代码
2019/05/23 Javascript
vue style width a href动态拼接问题的解决
2020/08/07 Javascript
使用python实现递归版汉诺塔示例(汉诺塔递归算法)
2014/04/08 Python
python多线程编程中的join函数使用心得
2014/09/02 Python
Django1.7+python 2.78+pycharm配置mysql数据库
2016/10/09 Python
Python实现在线音乐播放器
2017/03/03 Python
Pytorch之Variable的用法
2019/12/31 Python
详解HTML5中的picture元素响应式处理图片
2018/01/03 HTML / CSS
屈臣氏马来西亚官网:Watsons马来西亚
2019/06/15 全球购物
意大利时尚精品店:Nugnes 1920
2020/02/10 全球购物
党员群众路线承诺书
2014/05/20 职场文书
2014年国庆节寄语
2014/09/19 职场文书
2014年银行年终工作总结
2014/12/19 职场文书
歌舞青春观后感
2015/06/10 职场文书
小学语文教师研修感悟
2015/11/18 职场文书
解决Goland 同一个package中函数互相调用的问题
2021/05/06 Golang