设计模式[六]命令模式

738 阅读8分钟

这是我参与11月更文挑战的第6天,活动详情查看:2021最后一次更文挑战

命令模式的定义

假如我是一个快餐店的点餐服务员,当某位客人点餐或者打订餐电话的时候,我会把需求都写在清单上,然后交给厨房,客人不用关心是哪些厨师帮他炒菜。还可以满足客人需要的定时服务,比如客人要求1小时候炒他的菜,只要订单还在,厨师就不会忘记。客人也可以很方便打电话来撤销订单。另外如果有太多的客人点餐,厨房可以按照订单的顺序排队炒菜

这些记录着订餐信息的清单,便是命令模式中的命令对象

命令模式的用途

命名模式中的命令(command)指的是一个执行某些特定事情的指令

常见的应用场景:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此的耦合关系

例子1.菜单程序(传统语言版本)

假设我们正在编写一个用户界面程序,该用户界面上至少有数十个Button按钮。因为项目复杂,所以我们决定让某个程序员复杂绘制这些按钮,另外一些程序员复杂编写按钮的点击事件,这些行为都被封装在对象里

设计模式的主题总是把不变的事物和变化的事物分离开来,命令模式也不例外

按下按钮会发生事情是不变的,而具体发生什么是可变的。

    <button id="button1">点击按钮1</button>
    <button id="button2">点击按钮2</button>
    <button id="button3">点击按钮3</button>

接下来定义setCommand函数,负责往按钮上安装命令。点击按钮会执行某个command命令,该命令约定为command的execute()方法。虽然还不知道这些命令究竟代表了什么操作,但负责绘制按钮的程序员不关心这些事情,他只需要预留好安装命令的接口。

    const setCommand = function(button, command) {
        button.onclick = function() {
            command.execute()
        }
    }

最后,负责编写点击按钮具体行为的程序员交上了它们的成果,他们完成了刷新菜单,增加菜单和删除菜单的功能,如下

const MenuBar = {
    refresh() {
        console.log('刷新菜单目录')
    }
}
const SubMenu = {
    add() {
        console.log('刷新菜单目录')
    },
    del() {
        console.log('删除子菜单')
    }
}

然后我们把这些行为封装在命令类中。

const RefreshMenuBarCommand = function(receiver) {
    this.receiver = receiver
}
RefreshMenuBarCommand.prototype.execute = function() {
    this.receiver.refresh()
}
const AddSubMenuCommand = function(receiver) {
    this.receiver = receiver
}
AddSubMenuCommand.prototype.execute = function() {
    this.receiver.add()
}
const DelSubMenuCommand = function(receiver) {
    this.receiver = receiver
}
DelSubMenuCommand.prototype.execute = function() {
    this.receiver.del()
}

最后就是把命令接收者传入到command对象中,并且安到button上面

const refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar)
const addSubMenuCommand = new AddSubMenuCommand(SubMenu)
const delSubMenuCommand = new DelSubMenuCommand(SubMenu)

setCommand(button1, refreshMenuBarCommand)
setCommand(button2, addSubMenuCommand)
setCommand(button3, delSubMenuCommand)

JavaScript中的命令模式

也许我们会感到很奇怪,所谓的命令模式,看起来就是给对象的某个方法取了execute的名字。引入command对象和receiver这两个角色无非是把简单的事情复杂化了,即使不用什么模式,用下面几行代码就可以实现相同的功能

const bindClick = function(button, func) {
    button.onclick = func
}
const MenuBar = {
    refresh() {
        console.log('刷新菜单目录')
    }
}
const SubMenu = {
    add() {
        console.log('刷新菜单目录')
    },
    del() {
        console.log('删除子菜单')
    }
}
bindClick(button1, MenuBar.refresh)
bindClick(button2, SubMenu.add)
bindClick(button1, SubMenu.del)

命令模式的由来,其实是回调函数的一个面向对象的替代品

JavaScript作为函数作为一等对象的语言,跟策略模式一样,命令模式早已容易到Js之中。运算块不一定要封装在command.execute方法中,也可以封装下普通函数中。函数作为一等公民,本身就可以被四处传递。即使我们需要请求接收者,那也未必使用面向对象的方式,闭包可以完成同样的功能

在面向对象的设计中,命令模式的接收者被当成command对象的属性保存起来,同时约定执行命令的操作调用的command.execute方法。在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。用闭包如下的命令模式如下

const setCommand = function(button, func) {
    button.onclick = function() {
        func()
    }
}
const MenuBar = {
    refresh() {
        console.log('刷新菜单目录')
    }
}
const RefreshMenuBarCommand = function(receiver) {
    return function() {
        receiver.refresh()
    }
}
const refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar)
setCommand(button1, refreshMenuBarCommand)

当然,如果想更明确的表达当前正在使用命令模式,或者除了执行命令之外,将来有可能还要提供撤销命令等操作。那我们最好改成调用execute方法

const RefreshMenuBarCommand = function(receiver) {
    return {
        execute() {
            receiver.refresh()
        }
    }
}
const setCommand = function(button, command) {
    button.onclick = function() {
        command.execute()
    }
}
const refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar)
setCommand(button1, refreshMenuBarCommand)

撤销命令

本节的目标是利用策略模式篇中5.4的Animate类来编写一个动画,这个动画的表现是让页面上的小球移动到水平的某个位置。现在页面有一个input和一个button按钮,文本框的数字代表小球移动后的水平位置,点击按钮后小球开始移动。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="ball" style="position: absolute;background-color: #000;width: 50px;height: 50px;border-radius: 50%;top: 10vh;"></div>
    输入小球移动后的位置<input type="number" id="pos">
    <button id="moveBtn">开始移动</button>
    <div>
        <button id="cancelBtn">撤销操作</button>
    </div>

    <!-- 策略模式的Animate类代码 -->
    <script>
         /**
         * t: 动画已消耗的时间
         * b: 小球原始位置
         * c: 小球的目标位置
         * d: 动画持续的总时间
        */
        const tween = {
            linear(t, b, c, d) {
                return c * t / d + b
            },
            easeIn(t, b, c, d) {
                return c * (t /= d) * t + b
            },
            strongEaseIn(t, b, c, d) {
                return c * (t /= d) * t * t * t * t + b
            },
            strongEaseOut(t, b, c, d) {
                return c * ((t = t / d - 1) * t * t * t * t + 1) + b
            },
            sineaseIn(t, b, c, d) {
                return c * (t /= d) * t * t + b
            },
            sineaseOut(t, b, c, d) {
                return c * ((t = t / d - 1) * t * t + 1) + b
            }
        }

        const Animate = function(dom) {
            this.dom = dom // 进行运动的dom节点
            this.startTime = 0 // 动画开始时间
            this.startPos = 0 // 动画开始时,dom节点的位置,即dom的初始位置
            this.endPos = 0 // 动画结束时,dom节点的位置,即dom的目标位置
            this.propertyName = null // dom节点需要被改变额css属性名
            this.easing = null // 缓动算法
            this.duration = null // 动画持续时间
        }

        // 负责启动这个动画
        Animate.prototype.start = function(propertyName, endPos, duration, easing) {
            this.startTime = +new Date // 动画启动时间
            this.startPos = this.dom.getBoundingClientRect()[propertyName] // dom节点的初始位置
            this.propertyName = propertyName // dom节点需要给更改的css属性名
            this.endPos = endPos // dom节点的目标位置
            this.duration = duration // 动画持续时间
            this.easing = tween[easing] // 缓动算法

            let timer = setInterval(() => {
                if (this.step() === false) { // 如果动画已结束,清除定时器
                    clearInterval(timer)
                }
            }, 16)
        }

        

        Animate.prototype.step = function() {
            const now = +new Date
            if (now >= this.startTime + this.duration) {
                // 如果当前时间大于动画开始时间加上持续时间,说明动画结束,此时要修正小球的位置,因为在这一帧开始之后,小球的位置已经接近了目标位置,但很可能不完全等同于目标位置
                this.update(this.endPos)
                // 然后false给start函数清除定时器
                return false
            }
            // 求出小球的当前位置
            const pos = this.easing(now - this.startTime, this.startPos, this.endPos - this.startPos, this.duration)
            this.update(pos) // 更新小球的css属性值
        }

        Animate.prototype.update = function(pos) {
            this.dom.style[this.propertyName] = pos + 'px'
        }
    </script>

    <script>
        // 不用命令模式实现
        // moveBtn.onclick = function() {
        //     const animate = new Animate(ball)
        //     animate.start('left', pos.value, 1000, 'strongEaseOut')
        // }

        // 命令模式实现
        const MoveCommand = function(receiver, pos) {
            this.receiver = receiver
            this.pos = pos
            this.oldPos = null
        }
        let moveCommand
        MoveCommand.prototype.execute = function() {
            this.receiver.start('left', this.pos, 1000, 'strongEaseOut')
            // 记录移动前的位置
            this.oldPos = this.receiver.dom.getBoundingClientRect()[this.receiver.propertyName]
        }
        MoveCommand.prototype.undo = function() {
            // 回到开始前的位置
            this.receiver.start('left', this.oldPos, 1000, 'strongEaseOut')
        }
        moveBtn.onclick = function() {
            const animate = new Animate(ball)
            moveCommand = new MoveCommand(animate, pos.value)
            moveCommand.execute()
        }
        cancelBtn.onclick = function() {
            moveCommand.undo()
        }
    </script>
</body>
</html>

现在通过命令模式轻松的实现了撤销功能。撤销是命令模式一个非常有用的功能,试想一下开发一个围棋程序的时候,我们把每一步棋子的变化都封装成命令,则可以轻而易举地实现悔棋功能。同样,可以轻松实现文本编辑器的ctrl + Z功能

撤销和重做

很多时候,我们需要撤销一系列的命令。比如在围棋中,已经下了10布棋,我们需要一次性悔到第5步。在这之前,我们可以把所有执行过的下棋命令都储存在一个历史列表中,然后倒序循环来依次执行这些命令的undo操作,知道循环到第5个命令为止

然后,在某些请看下无法顺利的利用undo操作回到之前的状态。比如在canvas画图中,画布上有一些点,我们在这些点之间画了N条曲线把这些点相互连接起来,当然这是用命令模式来实现的。但是我们很难为这里的对象定义一个擦除某条曲线的undo操作,因为在canvas中,擦除同一条线相对不容易实现。

这时候最好的办法是先清除画布,然后把刚才执行过的命令重新执行一遍,这一点同样可以利用历史列表堆栈来实现。

例如编写一款《街头霸王》游戏,命令模式可以实现播放录像功能。原理跟canvas画图的例子一样,我们把用户在键盘的输入都封装成命令,播放录像的时候只需要从头开始执行这些命令即可,代码如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="replay">播放录像</button>
    <script>
        const Ryu = {
            attack() {
                console.log('攻击')
            },
            defense() {
                console.log('防御')
            },
            jump() {
                console.log('跳')
            },
            crouch(){
                console.log('蹲下')
            }
        }
        const makeCommand = function(receiver, state) {
            return function() {
                receiver[state]()
            }
        }
        const commands = {
            "119": "jump", // W
            "115": "crouch", // S
            "97": "defense", // A
            "100": "attack" // D
        }
        
        const commandStack = [] // 保存命令的堆栈
        document.onkeypress = function(ev) {
            const keyCode = ev.keyCode,
                  command = makeCommand(Ryu, commands[keyCode])
            if (command) {
                command()
                commandStack.push(command)
            }
        }

        replay.onclick = function() {
            // 点击播放录像
            while(command = commandStack.shift()) {
                command()
            }
        }
    </script>
</body>
</html>

可以看到,当我们在键盘上敲下W、A、S、D这几个键完成一些动画之后,在按下Replay按钮,便会重复之前的动作

命令队列

队列在动画的运用场景也非常多,比如之前的小球运动程序会有个问题;快速点击按钮的时候,此时小球的前一个动画可能尚未结束,于是前一个动画会骤然停止,小球转而开始第二个动画的运动过程。但这不是用户的期望,用户希望这两个动画会排队进行

我们可以把div的这些运动过程都封装成命令对象,再把他们押金一个人队列堆栈,当动画执行完,会主动通知队列,取出队列中等待的第一个命令对象,并且执行它

可以用发布-订阅模式。再一个动画结束后发布一个消息,订阅者收到这个消息后,便可以执行下一个动画。

宏任务

宏任务是一组命令的集合,通过执行宏任务的命令,可以一次执行一批命令。想象一下,家里有一个万能遥控器,每天回家的时候,只要按一个特别的按钮,它就会帮我们关上房间门,顺便打开打开电脑并登录微信

const closeDoorCommand = {
    execute() {
        console.log('关门')
    }
}
const openPcCommand = {
    execute() {
        console.log('打开电脑')
    }
}
const openWxCommand = {
    execute() {
        console.log('登录微信')
    }
}

接下来定义宏任务MacroCommand,它的结构很简单,add方法表示把子命令添加进宏任务对象,当调用execute方法时,会迭代子命令对象,并执行它们的execute方法

class MacroCommand {
    commandList = []

    add(command) {
        this.commandList.push(command)
    }

    execute() {
        for (let i = 0, command; command = this.commandList[i++];) {
            command.execute()
        }
    }
}

const macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand)
macroCommand.add(openPcCommand)
macroCommand.add(openWxCommand)
macroCommand.execute()

宏命令是命令模式与组合模式联合产物,关于组合模式的知识,下一章详细介绍

结语

本章我们学习了设计模式。跟许多其他语言不同,Js可以用高阶函数非常方便的实现命令模式。命令模式再JavaScript语言是一种隐形的模式