JS设计模式之命令模式

# 什么是“命令模式”?

命令模式(别名:动作模式、事务模式)定义:将一个请求封装为一个对象,从而使我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。

简单来说,它的核心思想是:不直接调用类的内部方法,而是通过给“指令函数”传递参数,由“指令函数”来调用类的内部方法。

在这过程中,分别有 3 个不同的主体:调用者、传递者和执行者。
请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。

# 应用场景

当想降低调用者与执行者(类的内部方法)之间的耦合度时,可以使用此种设计模式。比如:设计一个命令队列,将命令调用记入日志。

主要解决:在软件系统中,行为请求者与行为实现者通常是一种紧耦合的关系,但某些场合,比如需要对行为进行记录、撤销或重做、事务等处理时,这种无法抵御变化的紧耦合的设计就不太合适。

命令模式的案例-菜单

假设我们正在编写一个用户界面程序,该用户界面上至少有数十个Button按钮。因为项目比较复杂,所以我们决定让某个程序员负责绘制这些按钮,而另外一些程序员则负责编写点击按钮后的具体行为,这些行为都将被封装在对象里。在大型项目开发中,这是很正常的分工。对于绘制按钮的程序员来说,他完全不知道某个按钮未来将用来做什么,可能用来刷新菜单界面,也可能用来增加一些子菜单,他只知道点击这个按钮会发生某些事情。那么当完成这个按钮的绘制之后,应该如何给它绑定onclick事件呢?
回想一下命令模式的应用场景:

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

我们很快可以找到在这里运用命令模式的理由:点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。

设计模式的主题总是把不变的事物和变化的事物分离开来,命令模式也不例外。按下按钮之后会发生一些事情是不变的,而具体会发生什么事情是可变的。通过command对象的帮助,将来我们可以轻易地改变这种关联,因此也可以在将来再次改变按钮的行为。

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

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

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>命令模式</title>
</head>
<body>
<!--设置三个菜单按钮-->
<button id="button1">点击按钮1</button>
<button id="button2">点击按钮2</button>
<button id="button3">点击按钮3</button>

<script>
    var button1 = document.getElementById('button1');
    var button2 = document.getElementById('button2');
    var button3 = document.getElementById('button3');
    
     var setCommand = function (button, func) {
         button.onclick = function () {
             func()
         }
     };
     var menuBar = {
         refresh: function () {
             console.log('刷新菜单页面')
         },
         add: function () {
             console.log('增加菜单页面')
         }
     };
     var RefreshMenuBarCommand = function (receiver) {
         return function () {
             receiver.refresh()
         }

     };
     var AddMenuBarCommand = function (receiver) {
         return function () {
             receiver.add()
         }
     };
    var refreshMenuBarCommand = RefreshMenuBarCommand(menuBar);
    var addMenuBarCommand = AddMenuBarCommand(menuBar);
    setCommand(button1, refreshMenuBarCommand);
    setCommand(button2, addMenuBarCommand)
</script>
</body>
</html>

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

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>命令模式</title>
</head>
<body>
<!--设置三个菜单按钮-->
<button id="button1">点击按钮1</button>
<button id="button2">点击按钮2</button>
<button id="button3">点击按钮3</button>

<script>
    var button1 = document.getElementById('button1');
    var button2 = document.getElementById('button2');
    var button3 = document.getElementById('button3');
    
     var setCommand = function (button, command) {
         button.onclick = function () {
             // 通过command.execute调用
             command.execute()
         }
     };
     var menuBar = {
         refresh: function () {
             console.log('刷新菜单页面')
         },
         add: function () {
             console.log('增加菜单页面')
         }
     };
     var RefreshMenuBarCommand = function (receiver) {
         return {
             // 返回一个execute函数
             execute: function () {
                 receiver.refresh()
             }
         }
     };
     var AddMenuBarCommand = function (receiver) {
         return {
             execute: function () {
                 receiver.refresh()
             }
         }
     };
    var refreshMenuBarCommand = RefreshMenuBarCommand(menuBar);
    var addMenuBarCommand = AddMenuBarCommand(menuBar);
    setCommand(button1, refreshMenuBarCommand);
    setCommand(button2, addMenuBarCommand)
</script>
</body>
</html>

撤销命令

命令模式的作用不仅是封装运算块,而且可以很方便地给命令对象增加撤销操作。记录上一次的操作,通过添加undo等方法回到上一步的状态

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

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

这时候最好的办法是先清除画布,然后把刚才执行过的命令全部重新执行一遍,这一点同样可以利用一个历史列表堆栈办到。记录命令日志,然后重复执行它们,这是逆转不可逆命令的一个好办法。

假如想要查看自己所释放过的技能,原理跟Canvas画图的例子一样,我们把用户在键盘的输入都封装成命令,执行过的命令将被存放到堆栈中。查看技能释放录像的时候只需要从头开始依次执行这些命令便可,代码如下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>重做</title>
</head>

<body>
    <button id="replay">播放录像</button>

    <script>
        var actions = {
            W: function() {
                console.log('向前动作')
            },
            A: function() {
                console.log('向左动作')
            },
            S: function() {
                console.log('向后动作')
            },
            D: function() {
                console.log('向右动作')
            }
        };
        var makeCommand = function(receiver, state) { // 创建命令
            return function() {
                let func = receiver[state];
                if (func instanceof Function)
                    func();
            }
        };
        var commands = {
            '119': 'W', // 前面的数字对应的ascii码的小写,后面的W指的是上面的技能函数
            '97': 'A',
            '115': 'S',
            '100': 'D',
        };
        var commandStack = []; // 保存命令的堆栈
        document.onkeypress = function(e) { // 用户按下键盘触发的事件
            var keyCode = e.keyCode,
                command = makeCommand(actions, commands[keyCode]);
            if (command) {
                command(); // 执行命令
                commandStack.push(command); //将刚刚执行的命令放入到堆栈
            }
        };
        document.getElementById('replay').onclick = function() { // 点击播放录像
            console.log('-------开始播放动作录像--------')
            var command;
            while (command = commandStack.shift()) { // 从堆栈中取出命令依次执行
                command();
            }
        }
    </script>
</body>

</html>

当我们在键盘上敲下W、A、S、D这几个键来完成一些动作之后,再按下Replay按钮,此时便会重复播放之前的动作。
项目中层遇到了这个问题,还有一个是撤销,即同样维护一个撤销的undoList,在每点击一个动作,将更新undoList,当点击撤销命令的时候,将undoList中的command取出执行即可。而且可以设置最长撤销或者重做的步数,超过步数不予处理,这样就可以通过命令模式维护了一个队列,经过项目的实战运用起来还是不错的。

命令队列

所以我们可以把div的这些运动过程都封装成命令对象,再把它们压进一个队列堆栈,当动画执行完,也就是当前command对象的职责完成之后,会主动通知队列,此时取出正在队列中等待的第一个命令对象,并且执行它。

一个动画结束后该如何通知队列。通常可以使用回调函数来通知队列,除了回调函数之外,还可以选择发布-订阅模式。即在一个动画结束后发布一个消息,订阅者接=到这个消息之后,便开始执行队列里的下一个动画。读者可以尝试按照这个思路来自行实现一个队列动画。
可以参考本系列文章之设计模式之组合模式。中的宏命令设置。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343