精读《设计模式 - Memoto 备忘录模式》

Memento(备忘录模式)

Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。

意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 getStatesetState 一存一取两个方法,后面会详细讲解。

举例子

如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。

撤销重做

如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 setState 设置对象状态与 getState 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。

游戏保存

玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理:

// 创建一盘游戏。
const game = new Game()
// 玩一会。
game.play()
// 设置一个存档(archive) 1。
const gameArchive1 = game.createArchive()
// 再玩一会。
game.play()
// 设置一个存档(archive) 2。
const gameArchive2 = game.createArchive()
// 再玩一会。
game.play()
// 这个时候角色挂了,提示 “请读取存档”,玩家此时选择了存档 1。
game.loadArchive(gameArchive1)
// 此时游戏恢复存档 1 状态,又可以愉快的玩耍了。

其实在游戏保存的例子中,存档就是备忘录(Memoto),而主进程管理游戏状态时,只是简单调用了 createArchive 创建存档,与 load 读取存档,即可实现复杂的游戏保存与读取功能,全程是不需要关心游戏内部状态到底有多少,以及这么多状态需要如何一一恢复的,这就是得益于备忘录模式的设计。

文章草稿保存

富文本编辑器的文档草稿保存也是一样的原理,简单一点只需要一个 Memoto 对象即可,如果要实现复杂一点的多版本状态管理,只需要类似游戏保存机制,存储多个 Memoto 存档即可。

意图解释

看到这里,会发现备忘录模式与前端状态管理的保存与恢复很像。以 Redux 类比:

setState 就像 reducer 处理的最终 state 状态一样,对 redux 全局状态来说,它不用关心业务逻辑(有多少 reducer,以及每个 reducer 做了什么),它只需要知道任何 reducer 最后处理完后都是一个 state 对象,将其生成出来并存下来即可。

恢复也是一样,initState 就类似 getState,只要将上一次生成的 state 灌进来,就可以完全还原某个时刻的状态,而不需要关心这个状态内部是怎样的。

所以其实备忘录模式早已得到广泛的应用,仔细去理解后,会发现没必要去扣的太细,以及原始设计模式是如何定义的,因为经过几十年的演化,这些设计模式思路早已融入了编程框架的方方面面。

但依照惯例,我们还是再咬文嚼字解释一下意图:

意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。

重点在于 “不破坏封装性” 这几个字上,程序的可维护性永远是设计模式关注的重点,无论是游戏存档的例子,还是 Redux 的例子,上层框架使用状态时,都不需要知道具体对象状态的细节,而实现这一点的就是 Memoto 这个抽象的备忘录类。

结构图

  • Originator:创建、读取备忘录的发起者。
  • Memento:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。
  • Caretaker:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。

代码例子

下面例子使用 typescript 编写。

下面是备忘录模式三剑客的定义:

// 备忘录
class Memento {
  public state: any

  constructor(state: any) {
    this.state = state
  }

  public getState() {
    return this.state
  }
}

// 备忘录管理者
class Caretaker {
  private stack: Memento[] = []

  public getMemento(){
    return this.stack.pop()
  }

  public addMemento(memoto: Memento){
    this.stack.push(memoto)
  }
}

// 发起者
class Originator {
  private state: any

  public getState() {
    return this.state
  }

  public setState(state: any) {
    this.state = state
  }

  public createMemoto() {
    return new Memoto(this.state)
  }

  public setMemoto(memoto: Memoto) {
    this.state = memoto.getState()
  }

  public void setMemento(Memento memento) {
      state = memento.getState();
  }
}

下面是一个简化版客户端使用的例子:

// 实例化发起者,比如画布、文章管理器、游戏管理器
const originator = new Originator()

// 实例化备忘录管理者
const caretaker = new Caretaker()

// 设置状态,分别对应:
// 画布的组件操作。
// 文章的输入。
// 游戏的 .play()
originator.setState('hello world')

// 备忘录管理者记录一次状态,分别对应:
// 画布的保存。
// 文章的保存。
// 游戏的保存。
caretaker.setMemento(originator.createMento())

// 从备忘录管理者还原状态,分别对应:
// 画布的还原。
// 文章的读取。
// 游戏读取存档。
originator.setMemento(caretaker.getMemento())

在上面例子中,备忘录管理者存储状态是数组,所以可以实现撤销重做,如果要实现任意读档,可以将备忘录变为 Map 结构,按照 key 来读取,如果没有这些要求,存一个单一的 Memoto 也够用了。

弊端

备忘录模式存储的是完整状态而非 Diff,所以可能会在运行时消耗大量内存(当然在 Immutable 模式下,通过引用共享可以极大程度缓解这个问题)。

另外就是,备忘录模式已经很大程度上被融合到现代框架中,你在使用状态管理工具时就已经使用了备忘录模式了,所以很多情况下,不需要机械的按照上面的代码例子使用。设计模式重点在于利用它优化了程序的可维护性,而不用强求使用方式和官方描述一模一样。

总结

备忘录模式通过备忘录对象,将对象内部状态封装了起来,简化了程序复杂度,这符合设计模式一贯遵循的 “高内聚、低耦合” 原则。

其实践行备忘录模式最好的例子就是 Redux,当项目所有状态都使用 Redux 管理时,你会发现无论是撤销重做,还是保存读取,都可以非常轻松完成,这时候,不要质疑为什么备忘录模式还在解决这种 “遇不到的问题”,因为 Redux 本身就包含了备忘录设计模式的理念。

讨论地址是:精读《设计模式 - Memento 备忘录模式》· Issue #301 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):