Typescript 实践中的命令模式

javascript/jquery

浏览数:250

2020-5-24

前言

这篇文章是平时开发中对设计模式的体会与总结。
其中关于设计模式定义的描述主要来自 head first design pattern,UML 图来源于一个很优秀的 设计模式学习文档

定义

Encapsulate a request as an object, thereby letting you parameterize other objects with different requests, queue or log requests,and support undoable operations. —
head first design pattern

「命令模式」将「请求」封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,同时支持可撤消的操作。

这里的「请求」的定义,并不是我们前端常说的「Ajax 请求」,而是一个「动作请求」,也就是发起一个行为。例如,通过遥控器关闭电视,这里的「关闭」就是一个请求。在命令模式中,我们将请求抽象成一个命令,这个命令是可复用的,它只关心它的接受者(电视);而对于动作的发起者(遥控器)来说,它只关心它所支持的命令有哪些,而不关心这些命令具体是做什么的。

结构

命令模式的类图如下:

在该类图中,我们看到五个角色:

  • Client – 创建 Concrete Command 与 Receiver(应用层)。
  • Invoker – 命令的发出者,可以持有很多的命令对象。
  • Receiver – 命令接收者,真正被执行命令的对象。
  • Command – 命令接口。
  • ConcreteCommand – 命令接口的实现。

Reciver 与 Invoker 没有耦合,当需要拓展功能时,通过新增 Command,因此命令模式符合开闭原则。

实例

自定义快捷键

在一个文本编辑器中,通常支持自定义快捷键的功能,通过命令模式,我们可以写出一个将键位与键位逻辑解耦的结构。本例中我们将完成一个自定义复制、粘贴与剪切快捷键的功能。

interface Command {
    exec(): void
}

class CopyCommand implements Command {
    editor: Editor
    constructor(editor: Editor) {
        this.editor = editor
    }
    exec() {
        const { editor } = this
        editor.clipboard = editor.text.slice(...editor.range)
    }
}

class CutCommand implements Command {
    editor: Editor
    constructor(editor: Editor) {
        this.editor = editor
    }
    exec() {
        const { editor } = this
        editor.clipboard = editor.text.slice(...editor.range)
        editor.text = editor.text.slice(0, editor.range[0]) + editor.text.slice(editor.range[1])
    }
}

class PasteCommand implements Command {
    editor: Editor
    constructor(editor: Editor) {
        this.editor = editor
    }
    exec() {
        const { editor } = this
        editor.text = editor.text.slice(0, editor.cursorIndex) + editor.clipboard + editor.text.slice(editor.cursorIndex)
    }
}

type Editor = {
    cursorIndex: number;
    range: [number, number];
    text: string;
    clipboard: string;
}
const editor: Editor = {
    cursorIndex: 0,
    range: [0, 1],
    text: 'some text',
    clipboard: ''
}

type Keymap = { [key: string]: Command }
class Hotkey {
    keymap: Keymap = {}

    constructor(keymap: Keymap) {
        this.keymap = keymap
    }

    call(e: KeyboardEvent) {
        const prefix = e.ctrlKey ? 'ctrl+' : ''
        const key = prefix + e.key
        this.dispatch(key)
    }

    dispatch(key: string) {
        this.keymap[key].exec()
    }
}

const keymap = {
    'ctrl+x': new CutCommand(editor),
    'ctrl+c': new CopyCommand(editor),
    'ctrl+v': new PasteCommand(editor)
}
const hotkey = new Hotkey(keymap)

document.onkeydown = (e) => {
    hotkey.call(e)
}

在本例中,hotkey 是 Invoker,editor 是 Receiver。当我们需要修改已有的 keymap 时,只需要新增或替换已有的 keyCommand 即可,例如将粘贴键位与剪切互换。

const keymap = {
    'ctrl+x': new CopyCommand(editor),
    'ctrl+c': new CutCommand(editor),
    'ctrl+v': new PasteCommand(editor)
}

是不是觉得这个写法似曾相识?没错 Redux 也是应用了命令模式,Store 相当于 Receiver,Action 相当于 Command,Dispatch 相当于 Invoker。

撤销与重做

基于命令模式,我们只需要一些简单的拓展,就能使它支持撤销功能。撤销之后再次执行 exec 就能实现重做。

interface Command {
    exec(): void
    undo(): void
}
class PasteCommand implements Command {
    editor: Editor
    prevText: '' // added
    constructor(editor: Editor) {
        this.editor = editor
    }
    exec() {
        const { editor } = this
        this.prevText = editor.text // added
        editor.text = editor.text.slice(0, editor.cursorIndex) + editor.clipboard + editor.text.slice(editor.cursorIndex)
    }
    undo() {
        this.editor.text = this.prevText // added
    }
}

录制与回放

想想我们在游戏中的录制与回放功能,如果将角色的每个动作都作为一个命令的话,那么在录制时就能够得到一连串的命令队列。本例中,我们将记录 控制 ezio 的一系列命令。

interface IPerson {
    moveTo(x: number, y: number): void
}

class Person implements Person {
    x = 0
    y = 0

    moveTo(x: number, y: number) {
        this.x = x
        this.y = y
    }
}

interface Command {
    exec(): void
}

class MoveCommand implements Command {
    person: Person

    constructor(person: Person) {
        this.person = person
    }

    exec() {
        this.person.moveTo(this.person.x++, this.person.y++)
    }
}

class Control {
    commands: Command[] = []
    
    exec(command) {
        this.commands.push(command)
        command.exec(this.person)
    }
}

const ezio = new Person()
const control = new Control()
control.exec(new MoveCommand(ezio))
control.exec(new MoveCommand(ezio))

console.log(control.commands)

当我们有了命令队列,我们又能够很容易得进行多次的撤销和重做,实现一个命令的历史记录。只需要移动当前命令队列的指针即可。

class CommandHistory {
    commands: Command[] = []
    
    index = 0
    
    get currentCommand() {
        return this.commands[index]
    }
    
    constructor(commands: Command[]) {
        this.commands = commands
    }
    
    redo() {
        this.index++
        this.currentCommand.exec()
    }
    
    undo() {
        this.currentCommand.undo()
        this.index--
    }
}

同时,如果我们将命令序列化成一个对象,它便可以用于保存与传递。这样我们将它发送到远程计算机,就能实现远程控制 ezio 的功能。比如完成一系列唱跳 Rap 的行为。

['Move', 'Sing', 'Jump', 'Rap', 'Move']

宏命令

Command 进行一些简单的处理就能够将已有的命令组合起来执行,将其变成一个宏命令。

class BatchedCommand implements Command {
    commands = []
    
    constructor(commands) {
        this.commands = commands
    }
    
    exec() {
        this.commands.forEach(command => command.exec())
    }
}

const batchedMoveCommand = new BatchedCommand([
    new MoveCommand(ezio),
    new SingCommand(ezio),
    new JumpCommand(ezio),
    new RapCommand(ezio),
])

batchedMoveCommand.exec()

总结

通过以上几个例子,我们可以看出命令模式有一下几个特点:

  • 完全解耦,彻底消除了接受者与调用者之间的耦合。️
  • 易拓展,只需要增加新的命令便可拓展出新功能。️
  • 可序列化,易于实现保存与传递。️
  • 太多的 Command 类容易导致系统臃肿。

作者:花生杀手