Skip to content

Latest commit

 

History

History
2194 lines (1698 loc) · 75 KB

单例模式、策略模式、代理模式、发布订阅模式、命令模式、组合模式.md

File metadata and controls

2194 lines (1698 loc) · 75 KB

第四章 单例模式

单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的 window 对象等。

4.1 实现单例模式

要实现一个单例模式,只需要用一个变量来标识当前是否已为某个类创建过对象,如果是,则在下一次获取该类的实例时,直接返回之前创建的对象。

var Singleton = function (name) {
  this.name = name;
  this.instance = null;
};

Singleton.prototype.getName = function () {
  alert(this.name);
};

Singleton.getInstance = function (name) {
  if (!this.instance) {
    this.instance = new Singleton(name);
  }
  return this.instance;
};

var a = Singleton.getInstance('sven1');
var b = Singleton.getInstance('sven2');

alert(a === b); // true

也可以使用闭包来完成这个功能

var Singleton = function (name) {
  this.name = name;
};

Singleton.prototype.getName = function () {
  alert(this.name);
};

Singleton.getInstance = (function () {
  var instance = null;
  return function (name) {
    if (!instance) {
      instance = new Singleton(name);
    }
    return instance;
  };
})();

这个方法非常简单,但是却增加了单例类的“不透明性”。跟以往通过 new xxx 来获取对象的方式不同,开发者必须知道这是一个单例类,并且通过Singleton.getInstance来获取 Singleton 类的对象。

4.2 透明的单例模式

下面代码是对上面的单例模式的一种改进,以创建唯一的 div 节点为例。

var CreateDiv = (function() {

  var instance;

  CreateDiv = function(html) {
    if (instance) {
      return instance;
    }
    this.html = html;
    this.init();
    return instance = this;
  };

  CreateDiv.prototype.init = function() {
    var div = document.createElement('div');
    div.innerHTML = this.html;
    document.body.appendChild(div);
  };

  return CreateDiv;

})();

var a = new CreateDiv('sven1');
var b = new CreateDiv('sven2');

alert(a === b); // true

这个单例模式比较透明,为了将 instance 封装起来,使用了闭包和立即执行函数,并且返回了真正的构造函数。

这种方式增加了一些程序的复杂度,阅读起来也不是很舒服。

观察这段代码

CreateDiv = function (html) {
  if (instance) {
    return instance;
  }
  this.html = html;
  this.init();
  return (instance = this);
};

在这个构造函数中,一共负责了两件事情。第一是保证只有一个实例,第二是调用该函数的 init 函数,这违反了单一职责原则,让这个函数看起来很奇怪。

4.3 用代理实现单例模式

我们来对上面的 CreateDiv 构造函数进行改造,将返回单一实例的代码提炼出来,使它变成一个纯正的构造函数

var CreateDiv = function (html) {
  this.html = html;
  this.init();
};

CreateDiv.prototype.init = function () {
  var div = document.createElement('div');
  div.innerHTML = this.html;
  document.body.appendChild(div);
};

然后引入一个代理类

const proxySingletonCreateDiv = (function () {
  let instance;
  return function (html) {
    if (!instance) {
      return (instance = new CreateDiv(html));
    }
    return instance;
  };
})();

现在我们把负责管理单例的逻辑移到了代理类 proxySingletonCreateDiv 中,它使职责划分更清晰,跟 createDiv 组合也可以产生单例模式的效果。

接下来测试一下

var a = new proxySingletonCreateDiv('sven1');
var b = new proxySingletonCreateDiv('sven2');

alert(a === b); // true

4.4 JavaScript 中的单例模式

前面的几种单例模式的实现,在传统的面向对象语言当中, 是非常自然的。以 Java 为例,如果需要某个对象,就必须先定义一个类,对象总是从类中创建而来。单例对象自然也是从类创建而来。

但在 JavaScript 中,创建对象非常简单,我们并不需要创建一个类。

这就意味着传统的单例模式并不适用于 JavaScript。

单例模式的核心是确保只有一个唯一的实例并提供给全局访问。

全局变量不是单例模式,但在 JavaScript 中,我们可以将其当作单例来使用。

例如:

var a ={}

全局对象提供给全局访问时理所当然的,这样就满足了单例模式的两个条件。

但全局变量也有问题。它会造成命名空间受污染。

以下有几种方式可以降低全局变量带来的命名污染。

  1. 使用 namespace

    最简单的方式是采取对象字面量的方式:

    var namespace = { a: function () {}, b: function () {} };

    把需要的变量都定义为 namespace 的属性,这样可以减少变量和全局作用域打交道的机会。

    还可以动态创建全局命名空间

    const myApp = {
      namespace(name) {
        var current = this;
        var parts = name.split('.');
        for (let p of parts) {
          if (!current[p]) {
            current[p] = {};
          }
          current = current[p];
        }
      },
    };
    myApp.namespace('dom.style.classname');
    
    //上面的代码相当于
    var myApp = {
      dom: {
        style: {
          classname: {},
        },
      },
    };
  2. 使用闭包封装私有变量

    这种方法是将变量封装在闭包的内部,只暴露一些接口跟外部通信

            var user = (function(){
                var __name = 'sven',
                  __age = 29;
                return {
                  getUserInfo: function(){
                      return __name + '-' + __age;
                  }
                }
            })();

    我们用下划线来约定私有变量__name__age,它们被封装在闭包产生的作用域中,外部是访问不到这两个变量的,这就避免了对全局的命令污染

4.5 惰性单例

惰性单例指的是在需要的时候才创建对象实例。惰性单例是单例模式的终点,这种技术在实际开发中非常有用。

下面假设我们的网站上有一个登录按钮,当用户点击这个按钮后,会出现一个登录弹窗。很显然这个弹窗在页面中总是唯一的,不可能同时存在两个登录窗口的情况。

下面我们来写第一种解决方案:页面加载完成时创建好登录框,登录框是隐藏的,当用户点击登录按钮时,它才显示出来

var loginLayer = (function() {
  var div = document.createElement('div');
  div.innerHTML = ’我是登录浮窗’;
  div.style.display = 'none';
  document.body.appendChild(div);
  return div;
})();
document.getElementById('loginBtn').onclick = function() {
  loginLayer.style.display = 'block';
};

这种方式的缺点在于该节点一开始就创建好了,如果用户没有点击登录按钮,那么创建该节点的操作就白白浪费了。

下面这种方式倒是可以在点击按钮时创建,但是每次都会创建多个 div,也就违背了单例模式。

var createLoginLayer = function () {
  var div = document.createElement('div');
  div.innerHTML = '我是登录浮窗';
  div.style.display = 'none';
  document.body.appendChild(div);
  return div;
};
document.getElementById('loginBtn').onclick = function () {
  const LoginLayer = createLoginLayer();
  LoginLayer.style.display = 'block';
};

我们只需要在上面代码的基础上用一个 div 进行判断是否创建过浮窗就可以实现单例模式了。

var createLoginLayer = (function () {
  let div;
  return function () {
    if (!div) {
      div = document.createElement('div');
      div.innerHTML = '我是登录浮窗';
      div.style.display = 'none';
      document.body.appendChild(div);
    }
    return div;
  };
})();
document.getElementById('loginBtn').onclick = function () {
  const LoginLayer = createLoginLayer();
  LoginLayer.style.display = 'block';
};

4.6 通用的单例模式

上面的单例模式虽然已经完成了功能,但是缺陷也很明显:

  • 代码违背了单一原则,所有逻辑都放在 createLoginLayer 中
  • 立即执行函数使得代码阅读起来不是很舒服
  • 无法给其他需要单例模式的场景复用

我们先把不变的逻辑抽离出来,返回单例的逻辑始终是不变的,可以封装成一个通用的单例函数:用一个变量来标识是否创建过对象,如果是,则在下次直接返回这个已经创建好的对象,然后把需要执行什么函数通过参数传递给这个单例函数:

var singleton = function (handler) {
  let result;
  return function () {
    return result || (result = handler.apply(this, arguments));
  };
};

由于 result 始终在闭包里,所以它始终不会被销毁

然后修改创建登录浮窗的方法

var createLoginLayer = function () {
  div = document.createElement('div');
  div.innerHTML = '我是登录浮窗';
  div.style.display = 'none';
  document.body.appendChild(div);
  return div;
};

使用:

const createSingletonLoginLayer = singleton(createLoginLayer);
document.getElementById('loginBtn').onclick = function () {
  const LoginLayer = createSingletonLoginLayer();
  LoginLayer.style.display = 'block';
};

我们将两个创建实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响,当它们连接在一起时,就完成了创建唯一实例对象的功能。

单例模式的应用不止创建一个唯一的对象,也可以用在只处理一遍的业务场景上。

jquey 有一个 one 方法,它可以为元素添加处理函数。处理函数在每个元素上每种事件类型都只处理一次。

$('#foo').one('click', function () {
  alert('This will be displayed only once.');
});

使用 getSingleton 也可以达到一样的效果

var singleton = function (handler) {
  var result;
  return function () {
    return result || (result = handler.apply(this, arguments));
  };
};
var bindEvent = singleton(function () {
  alert(123);
  return true;
});
document.getElementById('loginBtn').onclick = bindEvent;

4.7 小结

由于语言之间的差异性,传统的单例模式跟 JavaScript 中创建单例的方法并不相同。

在 JavaScript 的单例模式中,更多的是运用闭包和高阶函数来实现单例模式。

单例模式非常简单且实用,特别是惰性单例技术,在合适的时候才创建对象,并且只创建唯一的一个对象。

当我们在写单例模式时,最好将创建对象和管理单例的职责进行分离,等到它们组合在一起,就形成一个可复用,可读性更高的单例模式。

第五章 策略模式

策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以互相替换。

5.1 使用策略模式计算奖金

以年终奖计算为例:

假设绩效 S 的年终奖有 4 倍工资,绩效 A 的年终奖有 3 倍工资,绩效 B 的则为 2 倍工资。

我们可以写这样一段代码

var calculateBonus = function (performanceLevel, salary) {
  switch (performanceLevel) {
    case 'S':
      return salary * 4;
    case 'A':
      return salary * 3;
    case 'B':
      return salary * 2;
  }
};
calculateBonus('B', 20000); // 输出:40000
calculateBonus('S', 6000); // 输出:24000

calculateBonus 函数接受两个参数,分别是绩效等级和工资水平。

这段代码非常简单,但是存在缺点:

  • 存在太多条件判断分支
  • 缺乏弹性,如果增加一种新的绩效等级,那么我们就需要来改代码,这违反了开放-封闭原则
  • 复用性差

下面是使用组合函数来重构代码。组合函数就是将业务逻辑拆分成很多小函数,将其进行组合。这里是将计算的业务逻辑与判断等级的业务逻辑分开

var performanceS = function (salary) {
  return salary * 4;
};
var performanceA = function (salary) {
  return salary * 3;
};
var performanceB = function (salary) {
  return salary * 2;
};
var calculateBonus = function (performanceLevel, salary) {
  if (performanceLevel === 'S') {
    return performanceS(salary);
  }
  if (performanceLevel === 'A') {
    return performanceA(salary);
  }
  if (performanceLevel === 'B') {
    return performanceB(salary);
  }
};
calculateBonus('A', 10000); // 输出:30000

虽然目前来看逻辑是分开了,但是依然很臃肿,系统变化时也缺乏弹性。

使用策略模式来修改代码。

策略模式指的是定义一系列的算法,把它们一个个封装起来。将不变的部分和变化的部分隔开时每个设计模式的主题,策略模式的目的就是将算法的使用和算法的实现隔离开来。

这个例子中,算法的使用方式是不变的,都是根据某个算法来得出金额。算法的实现是多种多样和可变化的,每种绩效对应不同的规则。

一个基于策略模式的程序由两部分组成。第一部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类 Context,Context 接受客户的算法,随后把请求委托给某一个策略类。要做到这一点,说明 Context 中需要保存对某个策略对象的引用。

我们先定义一组策略类,将每种绩效的计算规则都封装在对应的策略类中

var performanceS = function () {};
performanceS.prototype.calculate = function (salary) {
  return salary * 4;
};
var performanceA = function () {};
performanceA.prototype.calculate = function (salary) {
  return salary * 3;
};
var performanceB = function () {};
performanceB.prototype.calculate = function (salary) {
  return salary * 2;
};

然后创建一个 Context 环境类,它需要保存策略对象的引用。

// Bontus就是环境类,它用来保存策略对象的引用
var Bontus = function () {
  this.salary = null; //保存金额 这里是额外属性
  this.strategy = null; //这个属性用来保存策略对象的引用
};
Bontus.prototype.setSalary = function (salary) {
  this.salary = salary;
};
Bontus.prototype.setStrategy = function (strategy) {
  //设置策略对象
  this.strategy = strategy;
};
Bontus.prototype.getBonus = function () {
  return this.strategy.calculate(this.strategy);
};

使用时,先设置金额,再设置策略对象,最后获取结果

var bon = new Bontus();
bon.setSalary(2000); // 设置金额
bon.setStrategy(new performanceS()); // 设置策略对象
bon.getBonus(); // 8000
bon.setSalary(10000);
bon.setStrategy(new performanceB());
bon.getBonus(); // 20000

上面的代码中,我们先创建一个 bon 对象,并且给他设置一些原始的数据,这里是设置了工资。接下来给他设置一个策略对象,让他内部保存着这个策略对象。当需要计算时,bon 对象本身没有计算的能力,而是将计算委托给保存好的策略对象。

策略模式的思想:定义一系列的算法,并将它们挨个封装起来,并且使它们之间可以互相替换。

详细一点就是:定义一系列的算法,把它们各自封装成策略类,算法被封装在策略类内部的方法里。在客户对 Context 发起请求时,Context 总是把请求委托给这些策略对象中间的某一个进行计算。

5.2 JavaScript 版本的策略模式

上面的代码是模拟传统面向对象语言的实现,我们先创建了一组策略类,然后使用 Context 类来保存策略对象(strategy)的引用,策略对象是通过策略类创建的。最后把请求委托给策略对象来计算结果。

JavaScript 中,策略对象并不需要从各个策略类里面创建,我们直接将其定义成一个对象

const strategy = {
  S: function (salary) {
    return salary * 4;
  },
  A: function (salary) {
    return salary * 3;
  },
  B: function (salary) {
    return salary * 2;
  },
};

Context 类也并不需要通过 new Bontus 来创建,直接用函数就可以了

var calculateBontus = function (performanceLevel, salary) {
  return strategy[performanceLevel](salary);
};
calculateBontus('S', 2000); // 8000

这种方式比传统类型语言更好理解,也更加简洁。

5.3 多态在策略模式中的体现

通过使用策略模式重构代码,我们消除了原来大片的条件分支语句。所有跟奖金有关的计算我们都封装到各个策略对象中,Context 没有直接计算奖金的能力,而是把职责交给某个策略对象。每个策略对象负责的算法都被封装在对象内部。

当我们对这些策略对象发出计算奖金的请求时,它们会返回各自不同的计算结果,这是对象多态性的体现。

替换 Context 中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。

5.5 更广义的算法

策略模式指的是定义一系列的算法,并且把它们封装起来。

从定义上看,策略模式就是用来封装算法的。但如果仅仅把策略模式用来封装算法,未免有点大材小用。实际开发中,我们通常会把算法的含义扩展开来,使策略模式也可以封装一系列的业务规则。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。

下面是一个用策略模式完成表单校验用户是否输入合法数据的例子。

5.6 表单验证

以下是表单验证的校验逻辑:

  • 用户名不能为空
  • 密码长度不能少于 6 位
  • 手机号码必须符合格式

5.6.1 表单校验的第一个版本

<form action="" id="registerForm" method="post">
  请输入用户名:
  <input type="text" name="userName" />
  请输入密码:
  <input type="text" name="password" />
  请输入手机号码:
  <input type="text" name="phoneNumber" />
  <button>提交</button>
</form>
var registerForm = document.getElementById('registerForm');
registerForm.onsubmit = function () {
  if (registerForm.userName.value === '') {
    alert('用户名不能为空');
    return false;
  }
  if (registerForm.password.value.length < 6) {
    alert('密码长度不能少于6位');
    return false;
  }
  if (!/(^1[3|5|8][0-9]{9}$)/.test(registerForm.phoneNumber.value)) {
    alert('手机号码格式不正确');
    return false;
  }
};
  • registerForm.onsubmit 函数包含了很多 if-else 的语句,这些语句需要覆盖所有校验规则
  • 这个函数缺乏弹性,如果想增加一种新的校验规则,或者想要将密码长度的校验从 6 位修改为 8 位。我们都需要进入到函数内部去修改内部,这违反了开发-封闭原则
  • 这个函数复用性差,无法给其他表单复用

5.6.2 用策略模式重构表单校验

  • 第一步:将所有策略规则都封装进入策略对象

    var strategies = {
      isNonEmpty: function (value, errorMsg) {
        // 不为空
        if (value === '') {
          return errorMsg;
        }
      },
      minLength: function (value, length, errorMsg) {
        // 限制最小长度
        if (value.length < length) {
          return errorMsg;
        }
      },
      isMobile: function (value, errorMsg) {
        // 手机号码格式
        if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
          return errorMsg;
        }
      },
    };
  • 第二步:新建一个 Context 类,这里名叫 Validator 类。它负责接受用户的请求并委托给 strategy 对象。

    要写 Context 类实现代码,最好先设定好用户如何向它发起请求,也就是这个类如何使用,这有助于我们编写 Validator 类,假定它是这样使用的:

    var validataFunc = function () {
      var validator = new Validator(); // 创建一个validator对象
      /***************添加一些校验规则****************/
      validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空');
      validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6位');
      validator.add(registerForm.phoneNumber, 'isMobile', '手机号码格式不正确');
      var errorMsg = validator.start(); // 获得校验结果
      return errorMsg; // 返回校验结果
    };
    var registerForm = document.getElementById('registerForm');
    registerForm.onsubmit = function () {
      var errorMsg = validataFunc(); // 如果errorMsg有确切的返回值,说明未通过校验
      if (errorMsg) {
        alert(errorMsg);
        return false; // 阻止表单提交
      }
    };

    我们通过 Validator 类来创建一个 validator 对象,用 validator.add 来添加校验规则

    validator.add 接受三个参数:

    validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6位');

    1. 第一个参数为需要校验的内容
    2. 第二个参数表示校验规则,minLength:6是一个以冒号隔开的字符串。冒号前面的 minLength 代表客户挑选的 strategy 对象,冒号后面的数字 6 表示在校验过程中所必需的一些参数。'minLength:6’的意思就是校验 registerForm.password 这个文本输入框的 value 最小长度为 6。如果这个字符串中不包含冒号,说明校验过程中不需要额外的参数信息,比如’isNonEmpty'。
    3. 第三个参数是当校验失败后返回的错误信息

    当添加完校验规则后,我们通过 validator.start 方法启动校验,如果不成功则返回不成功的信息。

    下面是 Validator 类的实现

    class Validator {
      #cache = [];
      add(dom, rule, errorMessage) {
        // 把校验的步骤用空函数包装起来,并且放入cache
        this.#cache.push(function () {
          const [strategyProperty, ...args] = rule.split(':'); //分割出需要传递给验证函数的参数
          return strategies[strategyProperty].apply(dom, [
            dom.value,
            ...args,
            errorMessage,
          ]); //将验证逻辑委托给策略对象中的验证函数
        });
      }
      start() {
        for (let validatorFunc of this.#cache) {
          let message = validatorFunc(); //调用保存在cache属性中的校验规则函数
          if (message) {
            return message; // 如果有message,则表示验证错误,直接返回
          }
        }
      }
    }

    在使用策略模式重构代码之后,我们可以通过配置的方式完成一个表单的验证,这些校验规则可以复用在程序的任何地方。

    在修改某个校验规则时,只需要编写或者改写少量的代码。比如我希望将用户名的输入框校验规则改成用户名不少于 4 个字符,修改起来是毫不费力的。

    validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空'); // 改成: validator.add(registerForm.userName, 'minLength:4', '用户名最少4个字');

5.6.3 给某个文本输入框添加多个校验规则

目前上面的代码中一个输入框一次只能验证一种规则,如果我们希望一个输入框能够验证多个规则呢?比如像这样

validator.add(registerForm.userName, [
  ['isNonEmpty', '用户名不能为空'],
  ['minLength:10', '用户名长度不能小于10位'],
]);

只需要稍微改写一下 add 并添加一个新的 addRules 方法就可以了

   add(dom, rule, errorMessage) {
    if (rule instanceof Array) {
      return this.addRules(dom, rule);
    }...
  }
  addRules(dom, rules) {
    for (let [rule, errorMessage] of rules) {
      this.add(dom, rule, errorMessage);
    }
  }

这段代码并非 Javascript 设计模式与开发实践中的原代码,由于原代码的实现略麻烦,所以这里做一些修改。

5.7 策略模式的优缺点

优点:

  • 策略模式利用组合、委托和多态等思想,可以有效避免多重选择语句
  • 策略模式提供开放-封闭原则的完美支持,将算法独立在 strategy 中,使它们易于切换,易于扩展
  • 策略模式的算法也可以复用在系统的其他地方
  • 策略模式利用组合和委托来让 Context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案

缺点:

  • 需要增加策略类或者策略对象
  • 使用策略模式,必须了解各个 strategy 之间的不同点,才能选择一个合适的 strategy

5.8 一等函数对象和策略模式

在以类为中心的传统面向对象语言中,不同的算法和行为被封装在各个策略类中,Context 将请求委托给这些策略对象,这些策略对象会根据请求返回不同的执行结果,体现了对象的多态性。

在函数作为一等对象的语言中,策略模式是隐形的。strategy 就是值为函数的变量。

在 JavaScript 中,除了使用类来封装算法和行为,使用函数也是一种选择。

这些“算法”可以被封装到函数中并且四处传递,也就是我们常说的“高阶函数”。实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当我们对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。在 JavaScript 中,“函数对象的多态性”来得更加简单。

5.9 小结

JavaScript 版本的策略模式往往被函数所取代,这时策略模式就成为一种隐形的模式。

  • 传统策略模式

    需要一个 Context 环境类和一组 Strategy 类,其中 Strategy 策略类封装了具体的算法,并负责具体的计算过程。在 Context 类中可以保存某一个 Strategy 类的引用,通过这个引用来使用算法。

  • JavaScript 策略模式

    JavaScript 下 Strategy 类也可以是一些函数。Context 环境类也可以是一个函数,通过这个函数来将计算过程委托给 Strategy。

策略模式的目的就是将算法的使用和算法的实现隔离开来。这里的算法是一种广义的算法,可以替代其他业务逻辑,比如表单验证等。

第六章 代理模式

代理模式是为对象提供一个代用品或占用符,以便控制对它的访问。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理后,再把请求转交给本体对象。

image-20211103104718266

不用代理模式

image-20211103104857197

使用代理模式

6.2 保护代理和虚拟代理

保护代理:代理 B 可以帮助 A 过滤掉一些请求。保护代理用于控制不同权限的对象对目标对象的访问。但在 JavaScript 中不容易实现保护代理,因为我们无法判断谁访问了对象。

虚拟代理:JavaScript 中虚拟代理是常用的代理模式。虚拟代理可以把一些开销很大的操作,延迟到真正需要它的时候采取创建。

6.3 虚拟代理实现图片预加载

图片预加载是一种常用的技术:如果直接给某个 img 标签节点设置 src 属性,由于图片过大或者网络不佳,图片的位置往往有段时间是空白。常见的做法是先用一张 loading 图片站位,然后用异步的方式加载图片,等图片加载好了再填充到 img 节点内。这种场景很适合用虚拟代理。

第一步是创建一个本体对象,这个对象可以往页面中创建 img 标签,并且提供一个对外的 setSrc 接口,外界调用这个接口,就可以给 img 标签设置 src 属性

var myImage = (function () {
  var imgNode = document.createElement('img');
  document.body.appendChild(imgNode);
  return {
    setSrc: function (src) {
      imgNode.src = src;
    },
  };
})();
myImage.setSrc('http://xxxx.jpg');

第二步是创建代理对象,通过这个代理对象,在图片被真正加载好之前,页面会出现一张 loading 的占位图,来提示用户正在加载中。

var proxyImage = (function () {
  const img = new Image();
  img.onload = function () {
    // 3. 代理的src加载完成,会触发onload事件
    myImage.setSrc(this.src); // 4. 此时再重新给被代理的节点设置src属性
  };
  return {
    setSrc(src) {
      myImage.setSrc('loading.png'); //1.先让node节点预先加载loading图
      img.src = src; //2.设置代理的src属性
    },
  };
})();
proxyImage.setSrc('http://xxxx'); // proxyImage代理了myImage的访问,并且加入额外的预加载操作

6.4 代理的意义

上面的代码实际上不需要代理也可以完成,那么代理的意义在哪呢?

单一职责原则

单一职责原则指的是一个类(包括对象和函数),应该只有一个引起它变化的原因。如果一个对象承担了多项职责,这意味着它会变得巨大,引起它变化的原因有很多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到破坏。

职责定义为引起变化的原因。上述代码中,myImage 对象除了负责给展示的 img 节点设置 src 外,还需要预加载图片。我们在处理其中一个职责时,有可能因为其强耦合性而影响另外一个职责的实现。

在面向对象的设计中,大部分情况下,如果违反其他任何原则,同时将违反开放-封闭原则。如果我们知识从网上获取一些体积很小的图片,或者 5 年后网速快到不需要预加载,我们可能希望把预加载的代码从 myImage 对象中删掉。这样就不得不改动 myImage 对象了。

实际上,我们只是需要给 img 节点设置 src,预加载图片只是一个锦上添花的功能。如果可以把这个操作放在另一个对象里,自然是非常好的方法。这样代理的作用就体现出来了,负责预加载图片,预加载的操作完成之后,把请求重新交给本体 MyImage。

纵观整个程序,我们并没有修改或者增加 MyImage 的接口,但是通过代理对象,实际上给系统添加了新的行为。这是符合开放-封闭原则的。给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对象。何况就算有一天我们不再需要预加载,只需要改成请求本体而不是请求代理对象即可。

6.5 代理和本体接口的一致性

如果有一天我们不再需要预加载,那么就不再需要代理对象,可以选择直接请求本体。其中关键是代理对象和本体都对外提供了 setSrc 方法,在客户看来,代理对象和本体是一致的,代理接手请求的过程对于用户来说是透明的,用户并不清楚代理和本体的区别,这样做有两个好处。

  1. 用户可以放心地请求代理,他只关心是否能得到想要的结果。

  2. 在任何使用本体的地方都可以替换成使用代理。

6.6 虚拟代理合并 HTTP 请求

假设现在有一排 checkbox 节点,每点击一个就会往服务器同步文件。

这里是 html

  <input type="checkbox" id="1"></input>1
  <input type="checkbox" id="2"></input>2
  <input type="checkbox" id="3"></input>3
  <input type="checkbox" id="4"></input>4
  <input type="checkbox" id="5"></input>5
  <input type="checkbox" id="6"></input>6
  <input type="checkbox" id="7"></input>7
  <input type="checkbox" id="8"></input>8
  <input type="checkbox" id="9"></input>9

下面给他们绑定事件,每次选中后都会往服务器发送同步哪个文件的请求。

const checkBoxNodes = document.querySelectorAll('input');
var syncFile = function (id) {
  console.log('开始同步文件,id为' + id);
};
for (let checkBoxNode of checkBoxNodes) {
  checkBoxNode.onclick = function () {
    if (this.checked === true) {
      syncFile(this.id);
    }
  };
}

每次我们选中 checkbox,就会依次像服务器发送请求。如果用户在短时间内频繁点击(如一秒钟点四个 checkbox),那么网络请求的开销就会非常大。

解决方案是我们可以使用一个代理函数每次都收集要发送给服务器的请求,最后一次性发送给服务器。

const checkBoxNodes = document.querySelectorAll('input');
var syncFile = function (id) {
  console.log('开始同步文件,id为' + id);
};

var proxySyncFile = (function () {
  var cache = [];
  var timer;
  return function (id) {
    cache.push(id);
    clearTimeout(timer); //防抖
    timer = setTimeout(function () {
      syncFile(cache.join(',')); // 发送请求给服务器
      cache.length = 0; //记得清空保存起来的cache
      clearTimeout(timer);
    }, 2000);
  };
})();

for (let checkBoxNode of checkBoxNodes) {
  checkBoxNode.onclick = function () {
    if (this.checked === true) {
      proxySyncFile(this.id);
    }
  };
}

6.8 缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

6.8.1 缓存代理的例子——计算乘积

下面是一个用来计算乘积的懒加载函数

var mult = function(...rest) {
  let a = 1 mult = function(...rest) {
    for (let i of rest) {
      a *= i
    }
    return a
  }
  return mult(...rest)
}
console.log(mult(1, 2, 3))

如果给它加上缓存,那么就可以减少计算

var mult = function (...rest) {
  let a = 1;
  let cache = {};
  mult = function (...rest) {
    const property = rest.join(',');
    // 判断有没有传递过同样的参数
    if (!(property in cache)) {
      for (let i of rest) {
        console.log('这里是复杂的计算');
        a *= i;
      }
      cache[property] = a; // 计算后把计算参数和计算结果保存在缓存里
    } // 如果有就直接返回缓存的结果,不需要重复计算了
    return cache[property];
  };
  return mult(...rest);
};
console.log(mult(1, 2, 3)); // "这里是复杂的计算" * 3
//6
console.log(mult(1, 2, 3)); // 6

上面的懒加载函数 mult 需要完成两个职责:计算乘积,缓存

按照单一职责原则,我们应当用虚拟缓存代理模式来分离它的职责。

var mult = function(...rest) {
  let a = 1
  mult = function(...rest) {
    for (let i of rest) {
      console.log("这里是复杂的计算") a *= i
    }
    return a
  }
  return mult(...rest)
}
var proxyMult = (function() {
  let cache = {}
  return function(...rest) {
    let property = rest.join(',')
    if (property in cache) {
      return cache[property]
    }
    return cache[property] = mult(...rest)
  }
})()
console.log(proxyMult(1, 2, 3))
console.log(proxyMult(1, 2, 3))

通过增加缓存代理的方式,mult 函数可以继续专注于自身的职责,缓存的功能则是由代理对象实现的。

6.9 用高阶函数动态创建代理

这一章作者并没写什么内容,只是贴了大段代码,实际上这章就是在代理模式的基础上使用通用单例模式的思想,你会觉得这里的代码跟通用代理模式的代码很像

var mult = function(...rest) {
  let a = 1
  mult = function(...rest) {
    for (let i of rest) {
      console.log("这里是复杂的计算") a *= i
    }
    return a
  }
  return mult(...rest)
}

/* 创建缓存代理的工厂 */
var createProxyFactory = function(fn) {
  let cache = {}
  return function(...rest) {
    let property = rest.join(',')
    // 这里跟通用单例模式的代码非常类似,单例模式返回cache的引用,这里是返回cache里的属性
    if (property in cache) {
      return cache[property]
    }
    return cache[property] = fn(...rest)
  }
}
const proxyMult = createProxyFactory(mult)
console.log(proxyMult(1, 2, 3))
console.log(proxyMult(1, 2, 3))

createProxyFactory 是高阶函数,现在我们把用来计算的函数当作参数传递给它,就可以给各种计算方法创建不同的缓存代理,这样一来整个程序会更加灵活。

6.11 小结

代理模式包含很多,在 JavaScript 中使用最多的是虚拟代理和缓存代理。

我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。

第八章 发布订阅模式

发布-订阅模式又叫观察者模式,它定义对象之间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 中,我们一般用事件模型来替代传统的发布-订阅模式

8.1 现实中的发布-订阅模式

小明想买房,但是售楼处的房子早已售磬。于是小明将号码留在售楼处,让售楼 MM 有了房子之后给他打电话——订阅。

售楼 MM 的手里有想买房客户的花名册,新楼盘推出后,售楼 MM 会翻开花名册,依次给客户发信息通知他们——发布。

8.2 发布-订阅模式的作用

订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。比如,我们可以订阅 ajax 请求的 error、succ 事件。

订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另外一个对象的某个接口。发布-订阅模式可以让两个对象松耦合地联系在一起,虽然不太清楚彼此的细节,但这不影响他们之间互相通信。

8.3 DOM 事件

我们在 DOM 节点上面绑定过事件函数,这就是一种发布-订阅模式。

document.body.addEventListener(
  'click',
  function () {
    alert(2);
  },
  false
);

document.body.addEventListener(
  'click',
  function () {
    alert(3);
  },
  false
);

document.body.addEventListener(
  'click',
  function () {
    alert(4);
  },
  false
);

document.body.click(); // 模拟用户点击

在上面的代码中,我们需要监控用户点击 document.body 的动作,但是我们没有办法预知用户在什么时候点击。所以我们订阅 document.body 的 click 事件,当 body 节点被点击时,body 节点就会向订阅者发布这个消息。

8.4 自定义事件

实现发布订阅模式的步骤

  • 指定谁充当发布者(售楼处)
  • 给发布者添加一个缓存列表,用来存放回调函数以便通知订阅者(花名册)
  • 当时机成熟,遍历缓存列表,触发里面存放的订阅者回调函数(通过花名册发短信通知买房)

下面实现一个简单的发布订阅模式

const salesOffieces = {}; // 订阅对象
salesOffieces.cache = []; //缓存列表用来放回调函数
salesOffieces.listen = function (fn) {
  //订阅消息
  this.cache.push(fn); //存入缓存列表
};
salesOffieces.trigger = function (...rest) {
  // 需要的时候触发
  for (let fn of this.cache) {
    fn.call(this, ...rest);
  }
};

// 测试
salesOffieces.listen(function (e) {
  console.log(e);
});
// 触发了
salesOffieces.trigger();

上面的发布订阅比较简单,可惜功能不够,比如不能指定订阅者来发布消息。

我们有必要增加一个标识 key,根据 key 可以给指定的订阅对象发布消息。

const salesOffieces = {}; // 订阅对象

salesOffieces.cache = {}; //缓存列表用来放回调函数
//订阅时指定key
salesOffieces.listen = function (key, fn) {
  //订阅消息
  if (key in this.cache === false) {
    this.cache[key] = [];
  }
  this.cache[key].push(fn); //存入对应key的缓存列表
};

// 需要的时候触发
salesOffieces.trigger = function (key, ...rest) {
  let fns = this.cache[key]; //取出对应key的缓存列表
  if (!fns || fns.length === 0) {
    return false;
  }
  for (let fn of fns) {
    fn.call(this, key, ...rest);
  }
};

salesOffieces.listen('小明', function (key, args) {
  console.log('price' + key + args);
});

salesOffieces.trigger('小明', 20000);
// "price小明20000"

现在订阅者可以只订阅自己感兴趣的事件了。

8.5 发布-订阅模式的通用实现

下面是通过类来创建发布者的事件中心,使用类可以创建不同的发布者,让发布者可以拥有发布-订阅功能

class eventHub {
  #cache = {};
  //订阅事件
  listen(key, fn) {
    if (key in this.#cache === false) {
      this.#cache[key] = [];
    }
    this.#cache[key].push(fn);
  }
  // 发布事件
  trigger(key, ...rest) {
    if (!this.#cache[key] || this.#cache[key].length === 0) {
      return false;
    }
    for (let fn of this.#cache[key]) {
      fn.call(this, ...rest);
    }
  }
}

测试一下

const a = new eventhub();
a.listen('a', function (...rest) {
  console.log(rest);
});

a.trigger('a', 1, 2, 3); // [1,2,3]

上述代码是我的改写。《JavaScript 设计模式与开发实践》中并不是采用这种方式,而是直接用一个 event 对象,通过遍历 event 对象,给发布者添加 event 对象身上的 listen、trigger 等属性。

     var installEvent = function( obj ){
         for ( var i in event ){
           obj[ i ] = event[ i ];
         }
     };

8.6 取消订阅事件

下面来实现取消订阅事件

class eventHub {
  #cache = {};
  //订阅事件
  listen(key, fn) {
    if (key in this.#cache === false) {
      this.#cache[key] = [];
    }
    this.#cache[key].push(fn);
  }
  // 发布事件
  trigger(key, ...rest) {
    if (!this.#cache[key] || this.#cache[key].length === 0) {
      return false;
    }
    for (let fn of this.#cache[key]) {
      fn.call(this, ...rest);
    }
  }
  //删除订阅事件
  remove(key, fn) {
    if (!key) {
      return false;
    }
    //如果没传递指定的函数,则删除全部订阅
    if (!fn) {
      this.#cache[key] = [];
    }
    const len = this.#cache[key].length;
    // 遍历cache,删除指定的函数
    for (let i = 0; i < len; i++) {
      let _fn = this.#cache[key][i];
      if (fn === _fn) {
        this.#cache[key].splice(i, 1);
        break;
      }
    }
  }
}

测试一下

const a = new eventHub();

const fn1 = function () {
  console.log('fn1');
};
const fn2 = function () {
  console.log('fn2');
};
a.listen('a', fn1);
a.listen('a', fn2);
a.listen('b', fn1);
a.listen('b', fn2);

a.trigger('a'); // fn1 fn2
a.trigger('b'); // fn1 fn2
a.remove('a'); //把a的所有订阅都取消
a.trigger('a'); //取消了 无打印
a.remove('b', fn2); //给b取消掉fn2函数的订阅
a.trigger('b'); // fn1

8.7 在 React 应用内使用发布-订阅模式传递一个 id

由于书中的例子不是很好理解,我在 React 中试写了一个这个模式,并成功传递了数据,下面是我的代码,都很简单。

我们经常遇到这样一个场景:进入列表页面,可以得到一个 ID,然后通过这个 ID 进行详情页面。

列表页面跟详情页面是兄弟页面,这里我们有几种方式传递 ID

  • 我们自然可以用状态提升的方式将 ID 传递给两者的父组件,然后 props 或者 Context 传递(太麻烦)
  • Redux 全局管理(太重了)
  • 通过 url 的 queryString 传递(常用方法)
  • 通过浏览器 storage
  • 通过发布-订阅模式传递(不常用但很高级)

下面是发布订阅的代码,采用 TS 编写,方法基本一样,但也略有不同

// EventHub.ts
type Cache = {
  [key: string]: Array<(args?: unknown) => any>;
};
class _EventHub {
  private cache: Cache = {};
  //订阅事件
  listen(key, fn) {
    if (key in this.cache === false) {
      this.cache[key] = [];
    }
    this.cache[key].push(fn);
  }
  // 发布事件
  trigger(key, ...rest) {
    if (!this.cache[key] || this.cache[key].length === 0) {
      return false;
    }
    const result: any[] = [];
    for (let fn of this.cache[key]) {
      result.push(fn.call(this, ...rest));
    }
    //触发后马上清空以免有缓存
    this.remove(key);
    return result;
  }
  //删除订阅事件
  remove(key, fn?) {
    if (!key) {
      return false;
    }
    //如果没传递指定的函数,则删除全部订阅
    if (!fn) {
      this.cache[key] = [];
    }
    const len = this.cache[key].length;
    // 遍历cache,删除指定的函数
    for (let i = 0; i < len; i++) {
      let _fn = this.cache[key][i];
      if (fn === _fn) {
        this.cache[key].splice(i, 1);
        break;
      }
    }
  }
}
//一个代理类,使用单例模式,可以返回同一个EventHub的实例
const EventHub = (function () {
  var cache;
  return function () {
    if (cache) {
      return cache;
    }
    return (cache = new _EventHub());
  };
})();

export default EventHub;
// App.tsx
import './styles.css';
import Page1 from './page1';
import Page2 from './page2';
import { Routes, Route } from 'react-router-dom';
export default function App() {
  return (
    <div className='App'>
      <Routes>
        <Route path='/page1' element={<Page1 />} />
        <Route path='/page2' element={<Page2 />} />
      </Routes>
    </div>
  );
}
// Page1.tsx
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import Eventhub from './eventHub';
export default function Page1() {
  const id = 2;
  useEffect(() => {
    Eventhub().listen('k', function () {
      return id;
    });
  }, []);
  return (
    <div className='page1'>
      <h1>页面1</h1>
      <Link to='/page2'>to页面2</Link>
    </div>
  );
}
// Page2.tsx
import './styles.css';
import Eventhub from './eventHub';
import { Link } from 'react-router-dom';
import { useEffect } from 'react';

export default function Page2() {
  useEffect(() => {
    const id = Eventhub().trigger('k');
    console.log('id=' + id);
  }, []);
  return (
    <div className='page2'>
      <h2>页面2</h2>
      <Link to='/page1'>to页面1</Link>
    </div>
  );
}
  • 当触发后,需要及时清空 cache 以免有缓存
  • 这里使用单例模式,让每次使用 Eventhub 时都返回同一个对象,做到全局使用同一个发布者对象的效果

React-eventhub

8.8 全局的发布-订阅对象

在实际开发中,我们不可能使用一次发布-订阅模式就新创建一个发布者对象,比较好的做法是只采用一个全局的 Event 对象来实现。

订阅者不需要知道发布者是谁,而是直接订阅即可。发布者也不需要知道订阅者是谁,只需要按照 key 发布就行。

所以上一节代码中,我使用了一个单例模式,每次执行EventHub()时,都会返回_EventHub的实例。EventHub是一个代理类,它有只返回一个_EventHub实例的职责。

_EventHub是真正的全局的 Event 对象,它充当中介的作用,所有订阅都会通过它,所有发布也都必须经过它。我们不再关心用哪个发布者对象来订阅-发布了,_EventHub可以把订阅者和发布者都联系起来。

8.9 模块间通信

我们要留意另一个问题,模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后。我们最终会搞不清楚消息来自哪个模块,或者消息会流向哪些模块,这又会给我们的维护带来一些麻烦,也许某个模块的作用就是暴露一些接口给其他模块调用。

8.10 必须先订阅再发布吗

我们所了解到的发布—订阅模式,都是订阅者必须先订阅一个消息,随后才能接收到发布者发布的消息。如果把顺序反过来,发布者先发布一条消息,而在此之前并没有对象来订阅它,这条消息无疑将消失在宇宙中。

在某些情况下,我们需要先将这条消息保存下来,等到有对象来订阅它的时候,再重新把消息发布给订阅者。就如同 QQ 中的离线消息一样,离线消息被保存在服务器中,接收人下次登录上线之后,可以重新收到这条消息。

为了满足这个需求,我们要建立一个存放离线事件的堆栈,当事件发布的时候,如果此时还没有订阅者来订阅这个事件,我们暂时把发布事件的动作包裹在一个函数里,这些包装函数将被存入堆栈中,等到终于有对象来订阅此事件的时候,我们将遍历堆栈并且依次执行这些包装函数,也就是重新发布里面的事件。当然离线事件的生命周期只有一次,就像 QQ 的未读消息只会被重新阅读一次,所以刚才的操作我们只能进行一次。

8.13 小结

发布-订阅模式,也就是我们常说的观察者模式。

优点:

  • 在时间上解耦
  • 为对象之间解耦

缺点:

  • 创建订阅内容需要消耗一定时间和内存,且如果最后都未发生,消息会一直存在。
  • 发布-订阅模式可以弱化对象之间的关系,但如果过度使用,对象和对象之间的联系也被深埋在背后,导致程序难以跟踪维护和理解。

第九章 命令模式

命令对象:快餐店的所有外卖信息都会形成一个清单。有了这个清单,厨房可以按照订单顺序炒菜,客户也可以很方便地打电话撤销订单。这些记录订餐信息的清单,就是命令模式中的命令对象。

9.1 命令模式的用途

命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。

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

9.2 命令模式的一个例子——菜单程序

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

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

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

命令模式的第一步,是定义一个 setCommand 函数,负责安装命令。

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方法
  };
};

第二步,是将要做的行为都收集起来,这些用来收集行为的对象被称为命令接收者

//所有要做的行为 receiver
var MenuBar = {
  refresh: function() {
    console.log(’刷新菜单目录’);
  }
};

var SubMenu = {
  add: function() {
    console.log(’增加子菜单’);
  },
  del: function() {
    console.log(’删除子菜单’);
  }
};

第三步,把这些定位都封装到命令类中

// 刷新类
class RefreshMenuBarCommand {
  constructor(receiver) {
    this.receiver = receiver;
  }
  execute() {
    this.receiver.refresh();
  }
}
// 增加菜单类
class AddSubMenuCommand {
  constructor(receiver) {
    this.receiver = receiver;
  }
  execute() {
    this.receiver.add();
  }
}
// 删除菜单类
class DelSubMenuCommand {
  constructor(receiver) {
    this.receiver = receiver;
  }
  execute() {
    this.receiver.del();
  }
}

最后是将命令接收者传递给 setCommand 方法来将命令安装到 button 上

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

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

9.3 JavaScript 中的命令模式

上面的命令模式,是模拟传统面向对象语言的命令模式实现。命令模式将过程式的请求调用封装在 command 对象的 execute 方法里,通过封装方法调用,我们可以把运算块包装成形。command 对象可以被四处传递,所以在调用命令的时候,客户(Client)不需要关心事情是如何进行的。

看起来整个过程就是给一个对象起一个 execute 方法。引入 command 对象和 receiver 这两个角色对于 JavaScript 这种函数为一等对象的语言来说,实在是将简单的事情复杂化了,我们给他做一个改写:

var setCommand = function (button, commandFunc) {
  button.onclick = commandFunc;
};
//所有要做的行为
var MenuBar = {
  refresh: function () {
    console.log('刷新菜单目录');
  },
};

var SubMenu = {
  add: function () {
    console.log('增加子菜单');
  },
  del: function () {
    console.log('删除子菜单');
  },
};

setCommand(button1, MenuBar.refresh);
setCommand(button1, SubMenu.add);
setCommand(button1, SubMenu.del);

跟策略模式一样,命令模式早已融入 JavaScript 语言当中。我们不需要再封装一个 execute 方法,而是封装在普通函数中,就可以传递起来。

如果想要显式表示是命令模式,或者未来可能会添加撤销命令的那么可以继续使用 execute 方法。

var setCommand = function (button, command) {
  button.onclick = function () {
    command.execute();
  };
};
//所有要做的行为
var MenuBar = {
  refresh: function () {
    console.log('刷新菜单目录');
  },
};

var SubMenu = {
  add: function () {
    console.log('增加子菜单');
  },
  del: function () {
    console.log('删除子菜单');
  },
};

const refreshCommand = {
  execute() {
    MenuBar.refresh();
  },
};
const addCommand = {
  execute() {
    SubMenu.add();
  },
};
const delCommand = {
  execute() {
    SubMenu.del();
  },
};

setCommand(button1, refreshCommand);
setCommand(button1, addCommand);
setCommand(button1, delCommand);

本质上就是将类换成对象,由于函数可以自由传递,所以 receiver 可以不用传递。

9.4 撤销命令

撤销操作的实现一般是给命令对象增加一个名为 unexecude 或者 undo 的方法,在该方法里执行 execute 的反向操作。

比如下面定义设置命令的方法

function setCommand(target, command) {
  target.onclick = command.execute.bind(command);
}

function delCommand(target, command) {
  target.onclick = command.undo.bind(command);
}

定义一个命令类:接收一个 receiver

class Command {
  constructor(receiver) {
    this.receiver = receiver;
  }
  execute() {
    this.receiver.add();
  }
  undo() {
    this.receiver.del();
  }
}

收集 receiver 的行为

var SubMenu = {
  add: function () {
    console.log('增加子菜单');
  },
  del: function () {
    console.log('删除子菜单');
  },
};

使用命令

setCommand(button1, new Command(SubMenu));
delCommand(button2, new Command(SubMenu));

9.7 宏命令

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

创建宏命令的第一步,就是制定好执行的指令:

class closeDoorCommand {
  execute() {
    console.log('开门');
  }
}

class OpenCompute {
  execute() {
    console.log('打开电脑');
  }
}

class LoginQQ {
  execute() {
    console.log('登录QQ');
  }
}

第二步,是创建一个宏命令,宏命令有 add 方法,接收命令为参数,调用后会将命令添加到宏命令列表中。

class MacroCommand {
  macroList = [];
  add(commander) {
    this.macroList.push(commander);
  }
  execute() {
    for (let commander of this.macroList) {
      commander.execute();
    }
  }
}

第三步:添加设置命令的方法

function setCommand(target, command) {
  command.execute.call(command);
}

最后一步,添加命令并使用

const macroCommand = new MacroCommand();
macroCommand.add(new closeDoorCommand());
macroCommand.add(new OpenCompute());
macroCommand.add(new LoginQQ());

setCommand(button1, macroCommand);

当设置命令后,macroCommand会执行内部的execute方法,execute方法将遍历宏命令列表,并执行收集好的commander.execute方法。

当然我们也可以为宏命令添加撤销功能,跟macroCommand.execute类似,当调用macroCommand.undo时, 宏命令里包含所有的字命令对象要依次执行各自的 undo 操作。

9.8 智能命令和傻瓜命令

        var closeDoorCommand = {
            execute: function(){
              console.log( ’关门’ );
            }
        };

closeDoorCommand 中没有包含任何 receiver 的信息,它本身就包揽了执行请求的行为,这跟我们之前看到的命令对象都包含了一个 receiver 是矛盾的。

一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。

但是我们也可以定义一些更“聪明”的命令对象,“聪明”的命令对象可以直接实现请求,这样一来就不再需要接收者的存在,这种“聪明”的命令对象也叫作智能命令。

没有接收者的智能命令,退化到和策略模式非常相近,从代码结构上已经无法分辨它们,能分辨的只有它们意图的不同。

  • 策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。
  • 智能命令模式指向的问题域更广,command 对象解决的目标更具发散性。命令模式还可以完成撤销、排队等功能。

9.9 小结

Javascript 可以用高阶函数非常方便地实现命令模式。命令模式在 JavaScript 中是一种隐形的模式。

第十章 组合模式

组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的孙对象构成的。

10.1 回顾宏命令

在第九章宏命令中,宏命令对象包含了一组具体的子命令对象,不管是宏命令对象,还是子命令对象,都有一个 execute 方法执行命令。

class closeDoorCommand {
  execute() {
    console.log('开门');
  }
}

class OpenPcCommand {
  execute() {
    console.log('打开电脑');
  }
}

class openQQCommand {
  execute() {
    console.log('登录QQ');
  }
}
class MacroCommand {
  macroList = [];
  add(commander) {
    this.macroList.push(commander);
  }
  execute() {
    for (let commander of this.macroList) {
      commander.execute();
    }
  }
}
const macroCommand = new MacroCommand();
macroCommand.add(new closeDoorCommand());
macroCommand.add(new OpenPcCommand());
macroCommand.add(new openQQCommand());

macroCommand.execute(); // 执行宏命令

我们很容易发现,在宏命令中包含了一组子命令,他们组成一个树形结构,这里是一颗结构非常简单的树。

img

其中,macroCommand 被称为组合对象,closeDoorCommand、OpenPcCommand、OpenQQCommand 都是叶对象。在 macroCommand 的 execute 方法中,并不执行真正的操作,而是遍历它的叶对象,把真正的 execute 委托给它的叶对象。

macroCommand 表现得像一个命令,但它实际上只是这些命令的“代理”。并非真正的代理,因为 macroCommand 只负责将请求传递给叶对象,它的目的不在于控制对叶对象的访问。

10.2 组合模式的用途

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。下面分别说明:

  • 表现树形结构

    组合模式的一个优点是提供了一种遍历树形结构的方案,通过调用组合对象的 execute 方法,程序会递归调用组合对象下面的叶对象的 execute 方法。这样我们就可以只操作一次,便能够依次做多个命令。组合模式可以非常方便地描述对象部分-整体的层次结构

  • 利用对象的多态性统一对待组合对象和单个对象。利用对象的多态性表现,可以使客户端忽略组合对象和单个对象的不同。在组合模式中,客户将统一地使用组合结构中的所有对象,而不需要关心它们究竟是组合对象还是单个对象。

这种模式在实际开发中,会带来非常大的便利性。当我们往宏命令中添加命令时,我们甚至不需要关心这个命令是另一个宏命令还是普通子命令。我们只需要确定它是一个命令,并且这个命令拥有 execute 方法,那么这个命令就可以被添加进去。

当宏命令和普通子命令接收到执行 execute 方法的请求时,宏命令和普通子命令都会做它认为正确的事情。这些差异是隐藏起来,这种透明性可以让我们非常自由地扩展命令。

10.3 请求在树中传递的过程

在组合模式中,请求在树中传递的过程总是遵循一种逻辑。

以宏命令为例,请求从树最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求作出相应的处理;如果当前处理请求的对象是组合对象(宏命令),组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点。

如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。叶对象下面不会再有其他子节点,一个叶对象就是树的这条枝叶的尽头,组合对象下面可能还会有子节点。

img

请求从上到下沿着树进行传递,直到树的尽头。作为客户,只需要关心树最顶层的组合对象,客户只需要请求这个组合对象,请求就会沿着树往下传递,依次到达所有的叶对象。

10.4 更强大的宏命令

假设我们现在需要一个超级万能遥控器,可以控制家里所有的电器,这个遥控器拥有一下功能:

  • 打开空调
  • 打开电视和音响
  • 关门、开电脑、登录 QQ

先设置一下超级万能遥控器的按钮

<button id='button'>按我</button>

下面是命令集

class MacroCommand {
  constructor() {
    this.commandList = [];
  }
  add(command) {
    this.commandList.push(command);
  }
  execute() {
    for (let command of this.commandList) {
      command.execute();
    }
  }
}

class OpenAcCommand {
  execute() {
    console.log('开空调');
  }
}

class OpenTvCommand {
  execute() {
    console.log('开电视');
  }
}

class openSoundCommand {
  execute() {
    console.log('打开音响');
  }
}
//超级遥控器
const macroCommand1 = new MacroCommand();
macroCommand1.add(new OpenAcCommand());
macroCommand1.add(new OpenTvCommand());
macroCommand1.add(new openSoundCommand());

class closeDoorCommand {
  execute() {
    console.log('关门');
  }
}

class openPcCommand {
  execute() {
    console.log('开电脑');
  }
}

class openQQCommand {
  execute() {
    console.log('开QQ');
  }
}
// 遥控器2号
var macroCommand2 = new MacroCommand();
macroCommand2.add(new closeDoorCommand());
macroCommand2.add(new openPcCommand());
macroCommand2.add(new openQQCommand());
// 遥控器1号将2号也组合起来
macroCommand1.add(macroCommand2);

function setCommand(command) {
  button.onclick = command.execute.bind(command); //这里记得绑定一下this指向
}

setCommand(macroCommand1);

点击按钮后,就会看到执行以下结果:

"开空调"
"开电视"
"打开音响"
"关门"
"开电脑"
"开QQ"

从这个例子可以看出,基本对象可以被组合成更复杂的组合对象,组合对象又可以被组合,这样不断递归下去,这棵树的结构可以支持任意多的复杂度。在树最终被构造完成之后,让整颗树最终运转起来的步骤非常简单,只需要调用最上层对象的 execute 方法。每当对最上层的对象进行一次请求时,实际上是在对整个树进行深度优先的搜索,而创建组合对象的程序员并不关心这些内在的细节,往这棵树里面添加一些新的节点对象是非常容易的事情。

10.5 抽象类在组合模式的作用

组合模式最大的优点在于可以一致地对待组合对象和基本对象。用户不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有 execute 方法,这个命令就可以被添加到树中。

在 JavaScript 这种语言中,对象的多态性是与生俱来的,我们通常不需要去模拟一个抽象类,JavaScript 中实现组合模式的难点在于要保证组合对象和叶对象都拥有同样的方法。

在 JavaScript 中实现组合模式,看起来只缺乏一些严谨性,我们的代码算不上安全,但能够更快速自由地开发,这既是 JavaScript 的优点,又是 JavaScript 的缺点。

10.6 透明性带来的安全问题

组合模式的透明性使得发起请求的客户不用顾忌树中组合对象和叶对象的区别,但他们本质上是有区别的。

组合对象可以拥有子节点,叶对象下面就没有子节点,所以我们也许会有一些误操作,比如试图往叶对象中添加子节点。解决方案通常是给叶对象也增加一个 add 方法,并且调用这个方法时,跑出一个异常来做提醒。

class openPcCommand {
  execute() {
    console.log('开电脑');
  }
  add() {
    throw new Error('叶对象不能添加子节点');
  }
}

10.7 组合模式的例子——扫描文件夹

文件夹和文件之间的关系,非常适合用组合模式来描述。文件夹里既可以包含文件,又可以包含其他文件夹,最终组成一棵树。

组合模式在文件夹的应用有以下两层好处:

  • 复制文件类型时,不再需要考虑文件的类型。
  • 当扫描文件夹时,不需要关心里面有多少文件和子文件夹,组合模式可以让我们只操作最外层的文件夹

下面是扫描文件夹的例子:

function Folder(name) {
  this.name = name;
  this.files = [];
}

Folder.prototype.add = function (file) {
  this.files.push(file);
};

Folder.prototype.scan = function () {
  console.log('开始扫描文件夹:' + this.name);
  for (let file of this.files) {
    file.scan.call(file);
  }
};

function File(name) {
  this.name = name;
}

File.prototype.scan = function () {
  console.log('开始扫描文件:' + this.name);
};

File.prototype.add = function () {
  throw new Error('文件下不能添加文件');
};

const file1 = new File('文件1');
const file2 = new File('文件2');
const folder1 = new Folder('文件夹1');

folder1.add(file1);
folder1.add(file2);

const file3 = new File('文件3');
const file4 = new File('文件4');
const folder2 = new Folder('文件夹2');
folder2.add(file3);
folder2.add(file4);
folder1.add(folder2);

folder1.scan();

最终的打印结果为:

"开始扫描文件夹:文件夹1"
"开始扫描文件:文件1"
"开始扫描文件:文件2"
"开始扫描文件夹:文件夹2"
"开始扫描文件:文件3"
"开始扫描文件:文件4"

现在假设我需要将文件夹 2 内的文件 3 复制到文件夹 1 来,可以直接添加

folder1.add(file3);

在添加一批文件的操作过程中,客户不用分辨它们到底是文件还是文件夹。新增加的文件和文件夹能够很容易地添加到原来的树结构中,在树里已有的对象一起工作。

运行了组合模式后,扫描整个文件夹的操作也是轻而易举的,我们只需要操作树的顶端对象

folder1.scan();

10.8 一些值得注意的地方

  1. 组合模式不是父子关系

    组合模式的树形结构很容易让人误会组合对象和叶对象是父子关系,这是不正确的。

    组合模式是一种聚合关系,而不是父子关系。组合对象包含一组叶对象,但 Leaf 并不是 Composite 的子类。组合对象把请求委托给它包含的叶对象,它们能够合作的关键是拥有相同接口。

  2. 对叶对象操作的一致性

    组合模式除了要求组合对象和叶对象拥有相同的接口外,还有一个必要条件,就是一组叶对象的操作必须拥有一致性。

  3. 双向映射关系

    发放过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本身是一个组合模式的好例子,但要考虑的一种情况是,也许某些员工属于多个组织架构。比如某位架构师既隶属于开发组,又隶属于架构组,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该架构师很可能会收到两份过节费。

    这种复合情况下我们必须给父节点和子节点建立双向映射关系,一个简单的方法是给小组和员工对象都增加集合来保存对方的引用。但是这种相互间的引用相当复杂,而且对象之间产生了过多的耦合性,修改或者删除一个对象都变得困难,此时我们可以引入中介者模式来管理这些对象。

  4. 用职责链模式提高组合模式性能

    在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许表现得不够理想

    有时候我们确实可以借助一些技巧,在实际操作中避免遍历整棵树,有一种现成的方案是借助职责链模式。职责链模式一般需要我们手动去设置链条,但在组合模式中,父对象和子对象之间实际上形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反过来从子对象往父对象传递,直到遇到可以处理该请求的对象为止,这也是职责链模式的经典运用场景之一。

10.9 引用父对象

组合对象保存了它下面的子节点的引用,这是组合模式的特点,此时树结构是自上而下的。但有时候我们需要在子节点上保存父节点的引用,比如组合模式中使用职责链时,有可能需要让请求从子节点往父节点上冒泡传递。还有当我们删除某个文件时,实际上是从这个文件的上层文件夹中删除该文件的。

现在我们修改一下扫描文件夹的代码,使得扫描整个文件夹之前,我们可以先移除某一个具体的文件。

首先我们需要调用 add 方法时,设置一个父对象

/* ******Folder对象 ****** */
function Folder(name) {
  this.name = name;
  this.parent = null; // 增加parent属性
  this.files = [];
}

Folder.prototype.add = function (file) {
  file.parent = this; // 设置父对象
  this.files.push(file);
};

Folder.prototype.scan = function () {
  console.log('开始扫描文件夹:' + this.name);
  for (let file of this.files) {
    file.scan.call(file);
  }
};

接着完成删除逻辑,当子对象调用 remove 方法时,让父对象遍历 files 属性,删除这个子对象。

Folder.prototype.remove = function () {
  if (!this.parent) {
    return; //如果没有父节点,说明要么是根文件夹,要么只创建还没被添加
  }
  const index = this.parent.files.findIndex(f => f === this);
  this.parent.files.splice(index, 1);
};

对于 File 对象来说也是同样的逻辑

/* ******File对象 ****** */
function File(name) {
  this.parent = null;
  this.name = name;
}

File.prototype.scan = function () {
  console.log('开始扫描文件:' + this.name);
};

File.prototype.add = function () {
  throw new Error('文件下不能添加文件');
};

File.prototype.remove = function () {
  if (!this.parent) {
    return; //如果没有父节点,,说明只创建还没被添加
  }
  const index = this.parent.files.findIndex(f => f === this);
  this.parent.files.splice(index, 1);
};

下面测试一下:

const file1 = new File('文件1');
const file2 = new File('文件2');
const file3 = new File('文件3');
const file4 = new File('文件4');
const folder1 = new Folder('文件夹1');
const folder2 = new Folder('文件夹2');

folder1.add(file1);
folder1.add(file2);
folder1.add(folder2);
folder2.add(file3);
folder2.add(file4);
folder1.scan();

此时的目录结构时这样的

"开始扫描文件夹:文件夹1"
"开始扫描文件:文件1"
"开始扫描文件:文件2"
"开始扫描文件夹:文件夹2"
"开始扫描文件:文件3"
"开始扫描文件:文件4"

文件夹 1 中包含文件 1、文件 2、文件夹 2。

文件夹 2 中包含文件 3、文件 4。

此时删除文件夹 2 和文件 2

file2.remove();
folder2.remove();

再扫描看看

folder1.scan();
('开始扫描文件夹:文件夹1');
('开始扫描文件:文件1');

文件删除成功了。

10.10 何时使用组合模式

组合模式如果运用得当,可以大大简化客户的代码。一般来说,组合模式适用于以下两种情况:

  • 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构,特别是我们在开发期间不知道这棵树有多少层次的时候。在树的构造最终完成之前,我们都可以通过请求树的顶层对象来对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放-封闭原则
  • 客户希望统一对待树中的所有对象。组合模式可以使用户忽略叶对象和组合对象之间的区别,在面对这棵树时,用户不用关系当前正在处理的是组合对象还是叶对象,也不需要写大量的判断语句来处理。组合对象和叶对象会各自做自己认为正确的事情。

10.11 小结

组合模式可以让我们使用树形结构的方式创建对象的结构。我们可以把相同的操作应用在组合对象和单个对象上。大多数情况下,我们都可以忽略组合对象和叶对象之间的差别。用一致的方式来对待它们。

组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都和其他对象差不多。它们的区别只有在运行时候才会显现出来,这会让代码难以理解。此外,如果通过组合模式创建太多对象,那么这些对象可能会让系统负担不起。