专栏名称: 前端外刊评论
最新、最前沿的前端资讯,最有深入、最干前端相关的技术译文。
目录
相关文章推荐
前端早读课  ·  【第3453期】圈复杂度在转转前端质量体系中的应用 ·  22 小时前  
奇舞精选  ·  从 DeepSeek 看25年前端的一个小趋势 ·  昨天  
奇舞精选  ·  从 DeepSeek 看25年前端的一个小趋势 ·  昨天  
前端早读课  ·  【第3452期】React 开发中使用开闭原则 ·  昨天  
51好读  ›  专栏  ›  前端外刊评论

基于 Immutable.js 实现撤销重做功能

前端外刊评论  · 公众号  · 前端  · 2018-03-01 06:30

正文

本文作者 @肥超,浙江大学计算机研究生在读,喜欢前端/Node.js,Github 账号 https://github.com/shinima,欢迎勾搭。

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

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

上图看不清的话,可以看这里。

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

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

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

  1. // State.ts

  2. import { Record, List, Set } from 'immutable'

  3. const StateRecord = Record({

  4.  items: List<Item>

  5.  transform: d3.ZoomTransform

  6.  selection: number

  7. })

  8. // 用类封装,便于书写 TypeScript,注意这里最好使用Immutable 4.0 以上的版本

  9. export default class State extends StateRecord {}

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

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

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

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

  1. // actions/index.ts

  2. export default abstract class Action {

  3.  abstract next(state: State): State

  4.  abstract prev(state: State): State

  5.  prepare(appHistory: AppHistory): AppHistory { return appHistory }

  6.  getMessage() { return this.constructor.name }

  7. }

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

Action 子类举例

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

  1. // actions/AddItemAction.ts

  2. export default class AddItemAction extends Action {

  3.  newItem: Item

  4.  prevSelection: number

  5.  constructor(newItem: Item) {

  6.    super()

  7.    this.newItem = newItem

  8.  }

  9.  prepare(history: AppHistory) {

  10.    // 创建新的图形后会自动选中该图形,为了使得撤销该操作时 state.selection 变为原来的值

  11.    // prepare 方法中读取了「添加图形之前 selection 的值」并保存到 this.prevSelection

  12.    this.prevSelection = history.state.selection

  13.    return history

  14.  }

  15.  next (state: State) {

  16.    return state

  17.      .setIn(['items', this.newItem.id], this.newItem)

  18.      .set('selection', this.newItemId)

  19.  }

  20.  prev(state: State) {

  21.    return state

  22.      .deleteIn(['items', this.newItem.id])

  23.      .set('selection', this.prevSelection)

  24.   }

  25.  getMessage() { return `Add item ${this.newItem.id}` }

  26. }

运行时行为

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

  1. // initState 是一开始就给定的应用初始状态

  2. // 某一时刻,用户交互产生了 action1 ...

  3. state1 = action1.next(initState)

  4. // 又一个时刻,用户交互产生了 action2 ...

  5. state2 = action2.next(state1)

  6. // 同样的,action3也出现了 ...

  7. state3 = action3.next(state2)

  8. // 用户进行撤销,此时我们需要调用最近一个action的prev方法

  9. state4 = action3.prev(state3)

  10. // 如果再次进行撤销,我们从action列表中取出对应的action,调用其prev方法

  11. state5 = action2.prev(state4)

  12. // 重做的时候,取出最近一个被撤销的action,调用其next方法

  13. 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。具体代码如下:

  1. // AppHistory.ts

  2. const emptyAction = Symbol('empty-action')

  3. export const undo = Symbol('undo')

  4. export type undo = typeof undo // TypeScript2.7之后对symbol的支持大大增强

  5. export const redo = Symbol('redo')

  6. export type redo = typeof redo

  7. const AppHistoryRecord = Record({

  8.  // 当前应用状态

  9.  state: new State(),

  10.  // action 列表

  11.  list: List<Action>(),

  12.  // index 表示最后一个applied-action在list中的下标。-1 表示没有任何applied-action

  13.  index: -1,

  14. })

  15. export default class AppHistory extends AppHistoryRecord {

  16.  pop() { // 移除最后一项操作记录

  17.    return this

  18.      .update('list', list => list.splice(this.index, 1))

  19.      .update('index', x => x - 1)

  20.  }

  21.  getLastAction() { return this.index === -1 ? emptyAction : this.list.get(this.index) }

  22.  getNextAction() { return this.list.get(this.index + 1, emptyAction) }

  23.  apply(action: Action) {

  24.    if (action === emptyAction) return this

  25.    return this.merge({

  26.      list: this.list.setSize(this.index + 1).push(action),

  27.      index: this.index + 1,

  28.      state: action.next(this.state),

  29.    })

  30.  }

  31.  redo() {

  32.    const action = this.getNextAction()

  33.    if (action === emptyAction) return this

  34.    return this.merge({

  35.      list: this.list,

  36.      index: this.index + 1,

  37.      state: action.next(this.state),

  38.    })

  39.  }

  40.  undo() {

  41.    const action = this.getLastAction()

  42.    if (action === emptyAction) return this

  43.    return this.merge({

  44.      list: this.list,

  45.      index : this.index - 1,

  46.      state: action.prev(this.state),

  47.    })

  48.  }

  49. }

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

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

  1. type HybridAction = undo | redo | Action

  2. // 如果用Redux来管理状态,那么使用下面的reudcer来管理那些「需要历史记录的状态」

  3. // 然后将该reducer放在应用状态树中合适的位置

  4. function reducer(history: AppHistory, action: HybridAction): AppHistory {

  5.  if (action === undo) {

  6.    return history.undo ()

  7.  } else if (action === redo) {

  8.    return history.redo()

  9.  } else { // 常规的 Action

  10.    // 注意这里需要调用prepare方法,好让该action「准备好」

  11.    return action.prepare(history).apply(action)

  12.  }

  13. }

  14. // 如果是在 Stream/Observable 的环境下,那么像下面这样使用 reducer

  15. const action$: Stream<HybridAction> = generatedFromUserInteraction

  16. const appHistory$: Stream<AppHistory> = action$.fold(reducer, new AppHistory())

  17. const state$ = appHistory$.map(h => h.state)

  18. // 如果是用回调函数的话,大概像这样使用reducer

  19. onActionHappen







请到「今天看啥」查看全文