《JavaScript设计模式与开发实践》读书笔记

2021/11/18 ReadingNote

# 设计模式

# 单例模式(Singleton Pattern)

  1. 定义

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

  2. 使用场景

    单例模式是一种常用的模式,有一些对象我们往往只需要一个,比如线程池、全局缓存、浏览器中的window对象等。在JavaScript开发中,单例模式的用途同样非常广泛。试想一下,当我们单击登录按钮的时候,页面中会出现一个登录浮窗,而这个登录浮窗是唯一的,无论单击多少次登录按钮,这个浮窗都只会被创建一次,那么这个登录浮窗就适合用单例模式来创建。

  3. 实现

    export default class Singleton {
      constructor() { }
    
      static get instance() {
        if (!Singleton._instance) {
          Singleton._instance = new Singleton();
        }
        return Singleton._instance;
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10

    闭包实现

    export default (function () {
      let _instance = null;
      return class Singleton {
        constructor() { }
    
        static get instance() {
          if (!_instance) {
            _instance = new Singleton();
          }
          return _instance;
        }
      };
    }());
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13

# 策略模式(Strategy Pattern)

  1. 定义

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

    一个基于策略模式的程序至少由两部分组成。第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类Context,Context接受客户的请求,随后把请求委托给某一个策略类。

  2. 使用场景

    策略模式可以消除原程序中大片的条件分支语句。Context的作用就是把需要做的事情委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。替换Context中当前保存的策略对象,便能执行不同的算法来得到我们想要的结果。

  3. 实现

    const calculateBonus = function (performanceLevel, salary) {
      if (performanceLevel === 'S') {
        return salary * 4;
      }
      if (performanceLevel === 'A') {
        return salary * 3;
      }
      if (performanceLevel === 'B') {
        return salary * 2;
      }
    };
    
    calculateBonus('B', 20000); // 输出:40000
    calculateBonus('S', 6000); // 输出:24000
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    策略模式重构(在JavaScript中函数也是对象,因此可以直接用函数定义每个策略)

    const strategies = {
      S(salary) {
        return salary * 4;
      },
      A(salary) {
        return salary * 3;
      },
      B(salary) {
        return salary * 2;
      },
    };
    
    const calculateBonus = function (level, salary) {
      return strategies[level](salary);
    };
    
    console.log(calculateBonus('S', 20000)); // 输出:80000
    console.log(calculateBonus('A', 10000)); // 输出:30000
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
  4. 优缺点

    优点

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

    缺点

    • 首先,使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在Context中要好。
    • 其次,要使用策略模式,必须了解所有的strategy,必须了解各个strategy之间的不同点,这样才能选择一个合适的strategy。比如,我们要选择一种合适的旅游出行路线,必须先了解选择飞机、火车、自行车等方案的细节。此时strategy要向客户暴露它的所有实现,这是违反最少知识原则的。

# 代理模式(Proxy Pattern)

  1. 定义

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

  2. 使用场景

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

  3. 实现

    例如一个用于求乘积的函数

    const mult = function () {
      console.log('开始计算乘积');
      let a = 1;
      for (let i = 0, l = arguments.length; i < l; i++) {
        a *= arguments[i];
      }
      return a;
    };
    mult(2, 3); // 输出:6
    mult(2, 3, 4); // 输出:24
    
    // 现在加入缓存代理函数
    
    const proxyMult = (function () {
      const cache = {};
      return function () {
        const args = Array.prototype.join.call(arguments, ',');
        if (args in cache) {
          return cache[args];
        }
        return cache[args] = mult.apply(this, arguments);
      };
    }());
    
    proxyMult(1, 2, 3, 4); // 输出:24
    proxyMult(1, 2, 3, 4); // 输出:24
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26

    当我们第二次调用proxyMult( 1, 2, 3, 4 )的时候,本体mult函数并没有被计算,proxyMult直接返回了之前缓存好的计算结果。通过增加缓存代理的方式,mult函数可以继续专注于自身的职责——计算乘积,缓存的功能是由代理对象实现的。

# 迭代器模式(Iterator Pattern)

  1. 定义

    迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。目前的绝大部分语言都内置了迭代器,如JavaScript的Array.prototype.forEach

# 发布订阅模式(Publish-Subscribe Pattern)

  1. 定义

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

  2. 使用场景

    发布—订阅模式的实现,是基于一个全局的Event对象,我们利用它可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。

  3. 实现

    参考:发布订阅模式 (opens new window)

  4. 优缺点

    发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。它的应用非常广泛,既可以用在异步编程中,也可以帮助我们完成更松耦合的代码编写。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是MVC还是MVVM,都少不了发布—订阅模式的参与,而且JavaScript本身也是一门基于事件驱动的语言。

    当然,发布—订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且当你订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外,发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个bug不是件轻松的事情。

# 命令模式(Command Pattern)

  1. 定义

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

  2. 使用场景

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

  3. 实现

    目的就是把请求发送者和请求接收者解耦开的。例如给按钮绑定事件:

    const setCommand = function (button, command) {
      button.onclick = function () {
        // command上有一个名为execute的可执行函数
        command.execute();
      };
    };
    
    1
    2
    3
    4
    5
    6

# 组合模式(Composite Pattern)

  1. 定义

    组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的。组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性

  2. 使用场景

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

    • 表示对象的部分整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放封闭原则。
    • 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆if、else语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。
  3. 实现

    例如把大象放进冰箱,需要打开冰箱门、放入大象、关上冰箱门,可以把这些步骤单独写成一个个命令,放在一个方法里面组合调用。

  4. 总结

    组合模式可以让我们使用树形方式创建对象的结构。我们可以把相同的操作应用在组合对象和单个对象上。在大多数情况下,我们都可以忽略掉组合对象和单个对象之间的差别,从而用一致的方式来处理它们。然而,组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。

# 模板方法模式(Template Method Pattern)

  1. 定义

    一种基于继承的设计模式——模板方法(Template Method)模式。模板方法模式是一种只需使用继承就可以实现的非常简单的模式。模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

  2. 使用场景

    假如我们有一些平行的子类,各个子类之间有一些相同的行为,也有一些不同的行为。如果相同和不同的行为都混合在各个子类的实现中,说明这些相同的行为会在各个子类中重复出现。但实际上,相同的行为可以被搬移到另外一个单一的地方,模板方法模式就是为解决这个问题而生的。在模板方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留待子类来实现。这也很好地体现了泛化的思想。

    模板方法模式是好莱坞原则的一个典型使用场景,它与好莱坞原则的联系非常明显,当我们用模板方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。

    “不要给我们打电话,我们会给你打电话(don‘t call us, we‘ll call you)”这是著名的好莱坞原则。好莱坞原则强调高层对低层的主动作用,即低层只需要管好自己的工作(具体实现),而高层自有它自己的工作(这就是管理低层的逻辑,或者说从client到具体实现的一系列中间逻辑)。

  3. 实现

    例如泡一杯咖啡(泡茶同样只需要改写其中某些步骤)

    const Beverage = function () { };
    Beverage.prototype.boilWater = function () {
      console.log('把水煮沸');
    };
    Beverage.prototype.brew = function () { }; // 空方法,应该由子类重写
    Beverage.prototype.pourInCup = function () { }; // 空方法,应该由子类重写
    Beverage.prototype.addCondiments = function () { }; // 空方法,应该由子类重写
    
    Beverage.prototype.init = function () {
      this.boilWater();
      this.brew();
      this.pourInCup();
      this.addCondiments();
    };
    
    const Coffee = function () { };
    Coffee.prototype = new Beverage();
    
    // 接下来要重写抽象父类中的一些方法,只有“把水煮沸”这个行为
    // 可以直接使用父类Beverage中的boilWater方法,其他方法都需要在
    // Coffee子类中重写,代码如下:
    Coffee.prototype.brew = function () {
      console.log('用沸水冲泡咖啡');
    };
    Coffee.prototype.pourInCup = function () {
      console.log('把咖啡倒进杯子');
    };
    Coffee.prototype.addCondiments = function () {
      console.log('加糖和牛奶');
    };
    const coffee = new Coffee();
    coffee.init();
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32

# 享元模式(Flyweight Pattern)

  1. 定义

    享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

  2. 使用场景

    享元模式带来的好处很大程度上取决于如何使用以及何时使用,一般来说,以下情况发生时便可以使用享元模式。

    • 一个程序中使用了大量的相似对象。
    • 由于使用了大量对象,造成很大的内存开销。
    • 对象的大多数状态都可以变为外部状态。
    • 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。

# 职责链模式(Chain of Responsibility Pattern)

  1. 定义

    职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

  2. 使用场景

    作用域链、原型链、DOM节点中的事件冒泡

  3. 优缺点

    优点

    • 职责链模式的最大优点就是解耦了请求发送者和N个接收者之间的复杂关系,由于不知道链中的哪个节点可以处理你发出的请求,所以你只需把请求传递给第一个节点即可
    • 其次,使用了职责链模式之后,链中的节点对象可以灵活地拆分重组。增加或者删除一个节点,或者改变节点在链中的位置都是轻而易举的事情。
    • 那就是可以手动指定起始节点,请求并不是非得从链中的第一个节点开始传递。

    缺点

    • 不能保证某个请求一定会被链中的节点处理。
    • 职责链模式使得程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点并没有起到实质性的作用,它们的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗。

# 中介者模式(Mediator Pattern)

  1. 定义

    中介者模式的作用就是解除对象与对象之间的紧耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是互相引用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。

  2. 使用场景

    机场塔台的指挥、菠菜的下注与结算

  3. 优缺点

    中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最少知识原则,是指一个对象应该尽可能少地了解另外的对象(类似不和陌生人说话)。如果对象之间的耦合性太高,一个对象发生改变之后,难免会影响到其他的对象。而在中介者模式里,对象之间几乎不知道彼此的存在,它们只能通过中介者对象来互相影响对方

    因此,中介者模式使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。不过,中介者模式也存在一些缺点。其中,最大的缺点是系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。在程序中,中介者对象要占去一部分内存。中介者对象自身往往是一个难以维护的对象。中介者模式可以非常方便地对模块或者对象进行解耦,但对象之间并非一定需要解耦。一般来说,如果对象之间的复杂耦合确实导致调用和维护出现了困难,而且这些耦合度随项目的变化呈指数增长曲线,那我们就可以考虑用中介者模式来重构代码。

# 装饰者模式(Decorator Pattern)

  1. 定义

    给对象动态地增加职责的方式称为装饰者(decorator)模式。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。跟继承相比,装饰者是一种更轻便灵活的做法

  2. 实现

    原函数在新函数里面执行

    window.onload = function () {
      alert(1);
    };
    const _onload = window.onload || function () { };
    window.onload = function () {
      _onload();
      alert(2);
    };
    
    1
    2
    3
    4
    5
    6
    7
    8

    维护Function.prototype.beforeFunction.prototype.after

    Function.prototype.before = function (beforefn) {
      const __self = this; // 保存原函数的引用
      return function () {
        // 返回包含了原函数和新函数的"代理"函数
        // 执行新函数,且保证this不被劫持,新函数接受的参数
        // 也会被原封不动地传入原函数,新函数在原函数之前执行
        beforefn.apply(this, arguments);
        // 执行原函数并返回原函数的执行结果,
        // 并且保证this不被劫持
        return __self.apply(this, arguments);
      };
    };
    Function.prototype.after = function (afterfn) {
      const __self = this;
      return function () {
        const ret = __self.apply(this, arguments);
        afterfn.apply(this, arguments);
        return ret;
      };
    };
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20

    TypeScript的装饰器@decorator

    function addAge(constructor: Function) {
      console.log(constructor); // ƒ Person() { this.name = 'huihui'; }
      constructor.prototype.age = 18;
    }
    
    @addAge
    class Person{
      name: string;
      age!: number;
      constructor() {
        this.name = 'huihui';
      }
    }
    let person = new Person();
    console.log(person.age); // 18
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

# 状态模式(State Pattern)

  1. 定义

    允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

  2. 优缺点

    优点

    • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
    • 避免Context无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了Context中原本过多的条件分支。
    • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
    • Context中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

    状态模式的缺点是会在系统中定义许多状态类,编写20个状态类是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。另外,由于逻辑分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方就看出整个状态机的逻辑。

# 适配器模式

  1. 定义

    适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。适配器的别名是包装器(wrapper),这是一个相对简单的模式。

# 设计原则

# 单一职责原则

  1. 定义

    单一职责原则(SRP:Single responsibility principle)又称单一功能原则,面向对象五个基本原则(SOLID)之一。它规定一个类应该只有一个发生变化的原因。SRP原则体现为:一个对象(方法)只做一件事情。

    要明确的是,并不是所有的职责都应该一一分离。一方面,如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。另一方面,职责的变化轴线仅当它们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟。

  2. 优缺点

    SRP原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。但SRP原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

# 最少知识原则

  1. 定义

    迪米特法则(Law of Demeter)又叫作最少知识原则(The Least Knowledge Principle),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。英文简写为: LOD。

# 开放-封闭原则

  1. 定义

    开放封闭原则(OCP,Open Closed Principle)是所有面向对象原则的核心。软件设计本身所追求的目标就是封装变化、降低耦合,而开放封闭原则正是对这一目标的最直接体现。其他的设计原则,很多时候是为实现这一目标服务的。

    软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

# 代码重构

  1. 提炼函数
  2. 合并重复的条件片段
  3. 把条件分支语句提炼成函数
  4. 合理使用循环
  5. 提前让函数退出代替嵌套条件分支
  6. 传递对象参数代替过长的参数列表
  7. 尽量减少参数数量
  8. 少用三目运算符
  9. 合理使用链式调用
  10. 分解大型类
  11. return退出多重循环
最近更新: 2023年03月21日 14:47:21