原文链接: https://interview.poetries.top/docs/base/design-pattern.html

设计模式

一、基础篇

this、new、bind、call、apply

1. this 指向的类型

刚开始学习 JavaScript 的时候,this 总是最能让人迷惑,下面我们一起看一下在 JavaScript 中应该如何确定 this 的指向。this 是在函数被调用时确定的,它的指向完全取决于函数调用的地方,而不是它被声明的地方(除箭头函数外)。当一个函数被调用时,会创建一个执行上下文,它包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息,this 就是这个记录的一个属性,它会在函数执行的过程中被用到。

this 在函数的指向有以下几种场景:

  • 作为构造函数被 new 调用;
  • 作为对象的方法使用;
  • 作为函数直接调用;
  • callapplybind 调用;
  • 箭头函数中的 this

1.1 new 绑定

函数如果作为构造函数使用 new 调用时, this 绑定的是新创建的构造函数的实例。

    function Foo() {
        console.log(this)
    }
    
    var bar = new Foo()       // 输出: Foo 实例,this 就是 bar

实际上使用 new 调用构造函数时,会依次执行下面的操作:

  • 创建一个新对象;
  • 构造函数的 prototype 被赋值给这个新对象的 __proto__
  • 将新对象赋给当前的 this
  • 执行构造函数;
  • 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象,如果返回的不是对象将被忽略;

1.2 显式绑定

通过 callapplybind 我们可以修改函数绑定的 this,使其成为我们指定的对象。通过这些方法的第一个参数我们可以显式地绑定 this

    function foo(name, price) {
        this.name = name
        this.price = price
    }
    
    function Food(category, name, price) {
        foo.call(this, name, price)       // call 方式调用
        // foo.apply(this, [name, price])    // apply 方式调用
        this.category = category
    }
    
    new Food('食品', '汉堡', '5块钱')
    
    // 浏览器中输出: {name: "汉堡", price: "5块钱", category: "食品"}
    call 和 apply 的区别是 call 方法接受的是参数列表,而 apply 方法接受的是一个参数数组。
    
    func.call(thisArg, arg1, arg2, ...)        // call 用法
    func.apply(thisArg, [arg1, arg2, ...])     // apply 用法

bind 方法是设置 this 为给定的值,并返回一个新的函数,且在调用新函数时,将给定参数列表作为原函数的参数序列的前若干项。

    func.bind(thisArg[, arg1[, arg2[, ...]]])    // bind 用法

举个例子:

    var food = {
        name: '汉堡',
        price: '5块钱',
        getPrice: function(place) {
            console.log(place + this.price)
        }
    }
    
    food.getPrice('KFC ')   // 浏览器中输出: "KFC 5块钱"
    
    var getPrice1 = food.getPrice.bind({ name: '鸡腿', price: '7块钱' }, '肯打鸡 ')
    getPrice1()       // 浏览器中输出: "肯打鸡 7块钱"
    关于 bind 的原理,我们可以使用 apply 方法自己实现一个 bind 看一下:
    
    // ES5 方式
    Function.prototype.bind = Function.prototype.bind || function() {
        var self = this
        var rest1 = Array.prototype.slice.call(arguments)
        var context = rest1.shift()
        return function() {
            var rest2 = Array.prototype.slice.call(arguments)
            return self.apply(context, rest1.concat(rest2))
        }
    }
    
    // ES6 方式
    Function.prototype.bind = Function.prototype.bind || function(...rest1) {
        const self = this
        const context = rest1.shift()
        return function(...rest2) {
            return self.apply(context, [...rest1, ...rest2])
        }
    }

ES6 方式用了一些 ES6 的知识比如 rest 参数、数组解构

注意: 如果你把 nullundefined 作为 this 的绑定对象传入 callapplybind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

    var a = 'hello'
    
    function foo() {
        console.log(this.a)
    }
    
    foo.call(null)         // 浏览器中输出: "hello"

1.3 隐式绑定

函数是否在某个上下文对象中调用,如果是的话 this 绑定的是那个上下文对象。

    var a = 'hello'
    
    var obj = {
        a: 'world',
        foo: function() {
            console.log(this.a)
        }
    }
    
    obj.foo()       // 浏览器中输出: "world"

上面代码中,foo 方法是作为对象的属性调用的,那么此时 foo 方法执行时,this 指向 obj 对象。也就是说,此时 this 指向调用这个方法的对象,如果嵌套了多个对象,那么指向最后一个调用这个方法的对象:

    var a = 'hello'
    
    var obj = {
        a: 'world',
        b:{
            a:'China',
            foo: function() {
                console.log(this.a)
            }
        }
    }
    
    obj.b.foo()      // 浏览器中输出: "China"

最后一个对象是 obj 上的 b,那么此时 foo 方法执行时,其中的 this 指向的就是 b 对象。

1.4 默认绑定

函数独立调用,直接使用不带任何修饰的函数引用进行调用,也是上面几种绑定途径之外的方式。非严格模式下 this 绑定到全局对象(浏览器下是 winodwnode 环境是 global),严格模式下 this 绑定到 undefined (因为严格模式不允许 this 指向全局对象)。

    var a = 'hello'
    
    function foo() {
        var a = 'world'
        console.log(this.a)
        console.log(this)
    }
    
    foo()             // 相当于执行 window.foo()
    
    // 浏览器中输出: "hello"
    // 浏览器中输出: Window 对象

上面代码中,变量 a 被声明在全局作用域,成为全局对象 window 的一个同名属性。函数 foo 被执行时,this 此时指向的是全局对象,因此打印出来的 a 是全局对象的属性。

注意有一种情况:

    var a = 'hello'
    
    var obj = {
        a: 'world',
        foo: function() {
            console.log(this.a)
        }
    }
    
    var bar = obj.foo
    
    bar()              // 浏览器中输出: "hello"

此时 bar 函数,也就是 obj 上的 foo 方法为什么又指向了全局对象呢,是因为 bar 方法此时是作为函数独立调用的,所以此时的场景属于默认绑定,而不是隐式绑定。这种情况和把方法作为回调函数的场景类似:

    var a = 'hello'
    
    var obj = {
        a: 'world',
        foo: function() {
            console.log(this.a)
        }
    }
    
    function func(fn) {
        fn()
    }
    
    func(obj.foo)              // 浏览器中输出: "hello"
  • 参数传递实际上也是一种隐式的赋值,只不过这里 obj.foo 方法是被隐式赋值给了函数 func 的形参 fn,而之前的情景是自己赋值,两种情景实际上类似。这种场景我们遇到的比较多的是 setTimeoutsetInterval,如果回调函数不是箭头函数,那么其中的 this 指向的就是全局对象.
  • 其实我们可以把默认绑定当作是隐式绑定的特殊情况,比如上面的 bar(),我们可以当作是使用 window.bar() 的方式调用的,此时 bar 中的 this 根据隐式绑定的情景指向的就是 window

2. this 绑定的优先级

this 存在多个使用场景,那么多个场景同时出现的时候,this 到底应该如何指向呢。这里存在一个优先级的概念,this 根据优先级来确定指向。优先级:new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定

所以 this 的判断顺序:

  • new 绑定: 函数是否在 new 中调用?如果是的话 this 绑定的是新创建的对象;
  • 显式绑定: 函数是否是通过 bindcallapply 调用?如果是的话,this 绑定的是指定的对象;
  • 隐式绑定: 函数是否在某个上下文对象中调用?如果是的话,this 绑定的是那个上下文对象;
  • 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象;

3. 箭头函数中的 this

  • 箭头函数 是根据其声明的地方来决定 this
  • 箭头函数的 this 绑定是无法通过 callapplybind 被修改的,且因为箭头函数没有构造函数 constructor,所以也不可以使用 new 调用,即不能作为构造函数,否则会报错。
    var a = 'hello'
    
    var obj = {
        a: 'world',
        foo: () => {
            console.log(this.a)
        }
    }
    
    obj.foo()             // 浏览器中输出: "hello"

4. 一个 this 的小练习

用一个小练习来实战一下:

    var a = 20
    
    var obj = {
        a: 40,
        foo:() => {
            console.log(this.a)
        
            function func() {
                this.a = 60
                console.log(this.a)
            }
        
            func.prototype.a = 50
            return func
        }
    }
    
    var bar = obj.foo()        // 浏览器中输出: 20
    bar()                      // 浏览器中输出: 60
    new bar()                  // 浏览器中输出: 60

稍微解释一下:

  • var a = 20 这句在全局变量 window 上创建了个属性 a 并赋值为 20
  • 首先执行的是 obj.foo(),这是一个箭头函数,箭头函数不创建新的函数作用域直接沿用语句外部的作用域,因此 obj.foo() 执行时箭头函数中 this 是全局 window,首先打印出 window 上的属性 a 的值 20,箭头函数返回了一个原型上有个值为 50 的属性 a 的函数对象 funcbar
  • 继续执行的是 bar(),这里执行的是刚刚箭头函数返回的闭包 func,其内部的 this 指向 window,因此 this.a 修改了 window.a 的值为 60 并打印出来;
  • 然后执行的是 new bar(),根据之前的表述,new 操作符会在 func 函数中创建一个继承了 func 原型的实例对象并用 this 指向它,随后 this.a = 60 又在实例对象上创建了一个属性 a,在之后的打印中已经在实例上找到了属性 a,因此就不继续往对象原型上查找了,所以打印出第三个 60
  • 如果把上面例子的箭头函数换成普通函数呢,结果会是什么样?
    var a = 20
    
    var obj = {
        a: 40,
        foo: function() {
            console.log(this.a)
            
            function func() {
                this.a = 60
                console.log(this.a)
            }
            
            func.prototype.a = 50
            return func
        }
    }
    
    var bar = obj.foo()        // 浏览器中输出: 40
    bar()                      // 浏览器中输出: 60
    new bar()                  // 浏览器中输出: 60

闭包与高阶函数

1. 闭包

1.1 什么是闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

我们首先来看一个闭包的例子:

    function foo() {
        var a = 2
        
        function bar() {
            console.log(a)
        }
        
        return bar
    }
    
    var baz = foo()
    
    baz()            // 输出: 2
  • foo 函数传递出了一个函数 bar,传递出来的 bar 被赋值给 baz 并调用,虽然这时 baz 是在 foo 作用域外执行的,但 baz 在调用的时候可以访问到前面的 bar 函数所在的 foo 的内部作用域。
  • 由于 bar 声明在 foo 函数内部,bar 拥有涵盖 foo 内部作用域的闭包,使得 foo 的内部作用域一直存活不被回收。一般来说,函数在执行完后其整个内部作用域都会被销毁,因为 JavaScriptGC(Garbage Collection)垃圾回收机制会自动回收不再使用的内存空间。但是闭包会阻止某些 GC,比如本例中 foo() 执行完,因为返回的 bar 函数依然持有其所在作用域的引用,所以其内部作用域不会被回收。
  • 注意: 如果不是必须使用闭包,那么尽量避免创建它,因为闭包在处理速度和内存消耗方面对性能具有负面影响。

1.2 利用闭包实现结果缓存(备忘模式)

备忘模式就是应用闭包的特点的一个典型应用。比如有个函数:

    function add(a) {
        return a + 1;
    }
  • 多次运行 add() 时,每次得到的结果都是重新计算得到的,如果是开销很大的计算操作的话就比较消耗性能了,这里可以对已经计算过的输入做一个缓存。
  • 所以这里可以利用闭包的特点来实现一个简单的缓存,在函数内部用一个对象存储输入的参数,如果下次再输入相同的参数,那就比较一下对象的属性,如果有缓存,就直接把值从这个对象里面取出来。
    /* 备忘函数 */
    function memorize(fn) {
        var cache = {}
        return function() {
            var args = Array.prototype.slice.call(arguments)
            var key = JSON.stringify(args)
            return cache[key] || (cache[key] = fn.apply(fn, args))
        }
    }
    
    /* 复杂计算函数 */
    function add(a) {
        return a + 1
    }
    
    var adder = memorize(add)
    
    adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
    adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
    adder(2)            // 输出: 3    当前: cache: { '[1]': 2, '[2]': 3 }

使用 ES6 的方式会更优雅一些:

    /* 备忘函数 */
    function memorize(fn) {
        const cache = {}
        return function(...args) {
            const key = JSON.stringify(args)
            return cache[key] || (cache[key] = fn.apply(fn, args))
        }
    }
    
    /* 复杂计算函数 */
    function add(a) {
        return a + 1
    }
    
    const adder = memorize(add)
    
    adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
    adder(1)            // 输出: 2    当前: cache: { '[1]': 2 }
    adder(2)            // 输出: 3    当前: cache: { '[1]': 2, '[2]': 3 }

稍微解释一下:

  • 备忘函数中用 JSON.stringify 把传给 adder 函数的参数序列化成字符串,把它当做 cache 的索引,将 add 函数运行的结果当做索引的值传递给 cache,这样 adder 运行的时候如果传递的参数之前传递过,那么就返回缓存好的计算结果,不用再计算了,如果传递的参数没计算过,则计算并缓存 fn.apply(fn, args),再返回计算的结果。
  • 当然这里的实现如果要实际应用的话,还需要继续改进一下,比如:
  • 缓存不可以永远扩张下去,这样太耗费内存资源,我们可以只缓存最新传入的 n 个;
  • 在浏览器中使用的时候,我们可以借助浏览器的持久化手段,来进行缓存的持久化,比如 cookielocalStorage 等;
  • 这里的复杂计算函数可以是过去的某个状态,比如对某个目标的操作,这样把过去的状态缓存起来,方便地进行状态回退。
  • 复杂计算函数也可以是一个返回时间比较慢的异步操作,这样如果把结果缓存起来,下次就可以直接从本地获取,而不是重新进行异步请求。

注意: cache 不可以是 Map,因为 Map 的键是使用 === 比较的,因此当传入引用类型值作为键时,虽然它们看上去是相等的,但实际并不是,比如 [1]!==[1],所以还是被存为不同的键。

    //  X 错误示范
    function memorize(fn) {        
      const cache = new Map()
      return function(...args) {
        return cache.get(args) || cache.set(args, fn.apply(fn, args)).get(args)
      }
    }
    
    function add(a) {
      return a + 1
    }
    
    const adder = memorize(add)
    
    adder(1)    // 2    cache: { [ 1 ] => 2 }
    adder(1)    // 2    cache: { [ 1 ] => 2, [ 1 ] => 2 }
    adder(2)    // 3    cache: { [ 1 ] => 2, [ 1 ] => 2, [ 2 ] => 3 }

2. 高阶函数

高阶函数就是输入参数里有函数,或者输出是函数的函数。

2.1 函数作为参数

如果你用过 setTimeoutsetIntervalajax 请求,那么你已经用过高阶函数了,这是我们最常看到的场景:回调函数,因为它将函数作为参数传递给另一个函数。

比如 ajax 请求中,我们通常使用回调函数来定义请求成功或者失败时的操作逻辑:

    $.ajax("/request/url", function(result){
        console.log("请求成功!")
    })

ArrayObjectString 等等基本对象的原型上有很多操作方法,可以接受回调函数来方便地进行对象操作。这里举一个很常用的 Array.prototype.filter() 方法,这个方法返回一个新创建的数组,包含所有回调函数执行后返回 true 或真值的数组元素。

    var words = ['spray', 'limit', 'elite', 'exuberant', 'destruction', 'present'];
    
    var result = words.filter(function(word) {
        return word.length > 6
    })       // 输出: ["exuberant", "destruction", "present"]

回调函数还有一个应用就是钩子,如果你用过 Vue 或者 React 等框架,那么你应该对钩子很熟悉了,它的形式是这样的:

    function foo(callback) {
        // ... 一些操作
        callback()
    }

2.2 函数作为返回值

另一个经常看到的高阶函数的场景是在一个函数内部输出另一个函数,比如:

    function foo() {
        return function bar() {}
    }

主要是利用闭包来保持着作用域:

    function add() {
        var num = 0
        return function(a) {
            return num = num + a
        }
    }
    var adder = add()
    
    adder(1)     // 输出: 1
    adder(2)     // 输出: 3

1. 柯里化

  • 柯里化(Currying),又称部分求值(Partial Evaluation),是把接受多个参数的原函数变换成接受一个单一参数(原函数的第一个参数)的函数,并且返回一个新函数,新函数能够接受余下的参数,最后返回同原函数一样的结果。
  • 核心思想是把多参数传入的函数拆成单(或部分)参数函数,内部再返回调用下一个单(或部分)参数函数,依次处理剩余的参数。

柯里化有 3 个常见作用:

  • 参数复用
  • 提前返回
  • 延迟计算/运行
  • 先来看看柯里化的通用实现:
    // ES5 方式
    function currying(fn) {
        var rest1 = Array.prototype.slice.call(arguments)
        rest1.shift()
        return function() {
            var rest2 = Array.prototype.slice.call(arguments)
            return fn.apply(null, rest1.concat(rest2))
        }
    }
    
    // ES6 方式
    function currying(fn, ...rest1) {
      return function(...rest2) {
        return fn.apply(null, rest1.concat(rest2))
      }
    }

用它将一个 sayHello 函数柯里化试试:

    // 接上面
    function sayHello(name, age, fruit) {
      console.log(console.log(`我叫 ${name},我 ${age} 岁了, 我喜欢吃 ${fruit}`))
    }
    
    var curryingShowMsg1 = currying(sayHello, '小明')
    curryingShowMsg1(22, '苹果')           // 输出: 我叫 小明,我 22 岁了, 我喜欢吃 苹果
    
    var curryingShowMsg2 = currying(sayHello, '小衰', 20)
    curryingShowMsg2('西瓜')               // 输出: 我叫 小衰,我 20 岁了, 我喜欢吃 西瓜

更高阶的用法参见:JavaScript 函数式编程技巧 - 柯里化

2. 反柯里化

  • 柯里化是固定部分参数,返回一个接受剩余参数的函数,也称为部分计算函数,目的是为了缩小适用范围,创建一个针对性更强的函数。核心思想是把多参数传入的函数拆成单参数(或部分)函数,内部再返回调用下一个单参数(或部分)函数,依次处理剩余的参数。
  • 而反柯里化,从字面讲,意义和用法跟函数柯里化相比正好相反,扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象。

先来看看反柯里化的通用实现吧~

    // ES5 方式
    Function.prototype.unCurrying = function() {
      var self = this
      return function() {
        var rest = Array.prototype.slice.call(arguments)
        return Function.prototype.call.apply(self, rest)
      }
    }
    
    // ES6 方式
    Function.prototype.unCurrying = function() {
      const self = this
      return function(...rest) {
        return Function.prototype.call.apply(self, rest)
      }
    }
    如果你觉得把函数放在 Function 的原型上不太好,也可以这样:
    
    // ES5 方式
    function unCurrying(fn) {
      return function (tar) {
        var rest = Array.prototype.slice.call(arguments)
        rest.shift()
        return fn.apply(tar, rest)
      }
    }
    
    // ES6 方式
    function unCurrying(fn) {
      return function(tar, ...argu) {
        return fn.apply(tar, argu)
      }
    }

下面简单试用一下反柯里化通用实现,我们将 Array 上的 push 方法借出来给 arguments这样的类数组增加一个元素:

    // 接上面
    var push = unCurrying(Array.prototype.push)
    
    function execPush() {
      push(arguments, 4)
      console.log(arguments)
    }
    
    execPush(1, 2, 3)    // 输出: [1, 2, 3, 4]

简单说,函数柯里化就是对高阶函数的降阶处理,缩小适用范围,创建一个针对性更强的函数。

    function(arg1, arg2)              // => function(arg1)(arg2)
    function(arg1, arg2, arg3)        // => function(arg1)(arg2)(arg3)
    function(arg1, arg2, arg3, arg4)  // => function(arg1)(arg2)(arg3)(arg4)
    function(arg1, arg2, ..., argn)   // => function(arg1)(arg2)…(argn)

而反柯里化就是反过来,增加适用范围,让方法使用场景更大。使用反柯里化, 可以把原生方法借出来,让任何对象拥有原生对象的方法。

    obj.func(arg1, arg2)        // => func(obj, arg1, arg2)

可以这样理解柯里化和反柯里化的区别:

  • 柯里化是在运算前提前传参,可以传递多个参数;
  • 反柯里化是延迟传参,在运算时把原来已经固定的参数或者 this 上下文等当作参数延迟到未来传递。
  • 更高阶的用法参见:JavaScript 函数式编程技巧 - 反柯里化

3. 偏函数

偏函数是创建一个调用另外一个部分(参数或变量已预制的函数)的函数,函数可以根据传入的参数来生成一个真正执行的函数。其本身不包括我们真正需要的逻辑代码,只是根据传入的参数返回其他的函数,返回的函数中才有真正的处理逻辑比如:

    var isType = function(type) {
      return function(obj) {
        return Object.prototype.toString.call(obj) === `[object ${type}]`
      }
    }
    
    var isString = isType('String')
    var isFunction = isType('Function')

这样就用偏函数快速创建了一组判断对象类型的方法~

偏函数和柯里化的区别:

  • 柯里化是把一个接受 n 个参数的函数,由原本的一次性传递所有参数并执行变成了可以分多次接受参数再执行,例如:add = (x, y, z) => x + y + z→curryAdd = x => y => z => x + y + z;
  • 偏函数固定了函数的某个部分,通过传入的参数或者方法返回一个新的函数来接受剩余的参数,数量可能是一个也可能是多个;
  • 当一个柯里化函数只接受两次参数时,比如 curry()(),这时的柯里化函数和偏函数概念类似,可以认为偏函数是柯里化函数的退化版

ES6

1. let、const

一个显而易见特性是 let 声明的变量还可以更改,而 const 一般用来声明常量,声明之后就不能更改了:

    let foo = 1;
    const bar = 2;
    foo = 3;
    bar = 3;   // 报错 TypeError

1.1 作用域差别

刚学 JavaScript 的时候,我们总是看到类似于「JavaScript 中没有块级作用域,只有函数作用域」的说法。举个例子:

    var arr = [];
    for (var i = 0; i < 4; i++) {
      arr[i] = function () {
        console.log(i)
      }
    }
    arr[2]()	 // 期望值:2,输出: 4

因为 i变量是 var 命令声明的,var 声明的变量的作用域是函数作用域,因此此时 i 变量是在全局范围内都有效,也就是说全局只有一个变量 i,每次循环只是修改同一个变量 i 的值。虽然函数的定义是在循环中进行,但是每个函数的 i 都指向这个全局唯一的变量 i。在函数执行时,for 循环已经结束,i 最终的值是 4,所以无论执行数组里的哪个函数,结果都是 i 最终的值 4。

ES6 引入的 letconst 声明的变量是仅在块级作用域中有效:

    var arr = [];
    for (let i = 0; i < 4; i++) {
      arr[i] = function () {
        console.log(i)
      }
    }
    arr[2]()	 // 期望值:2,输出: 2

这个代码中,变量 ilet 声明的,也就是说 i 只在本轮循环有效,所以每次循环 i 都是一个新的变量,最后输出的是 2。

那如果我们不使用 ES6letconst 怎样去实现?可以使用函数的参数来缓存变量的值,让闭包在执行时索引到的变量为函数作用域中缓存的函数参数变量值:

    var arr = []
    for (var i = 0; i < 4; i++) {
        (function(j) {
            arr[i] = function(j) {
                console.log(j)
            }
        })(i)
    }
    arr[2]()	 // 输出: 2

这个做法归根结底还是使用函数作用域来变相实现块级作用域,事实上 Babel 编译器也是使用这个做法,我们来看看 Babel 编译的结果:

    // 编译前,ES6 语法
    var arr = [];
    for (let i = 0; i < 4; i++) {
      arr[i] = function () {
        console.log(i)
      }
    }
    arr[2]()   // 输出: 2
    
    // 编译后,Babel 编译后的 ES5 语法
    "use strict";
    var arr = [];
    var _loop = function _loop(i) {
      arr[i] = function () {
        console.log(i);
      };
    };
    
    for (var i = 0; i < 4; i++) {
      _loop(i);
    }
    
    arr[2]();   // 输出: 2

可以看到 Babel 编译后的代码,也是使用了这个做法。

1.2 不存在变量提升

var 命令声明的变量会发生变量提升的现象,也就是说变量在声明之前使用,其值为 undefinedfunction 声明的函数也是有这样的特性。而 letconst 命令声明的变量没有变量提升,如果在声明之前使用,会直接报错。

    // var 命令存在变量提升
    console.log(tmp)  // undefined
    var tmp = 1
    console.log(tmp)  // 1
    
    // let、const 命令不存在变量提升
    console.log(boo)  // 报错 ReferenceError
    let boo = 2

1.3 暂时性死区

在一个块级作用域中对一个变量使用 letconst 声明前,该变量都是不可使用的,这被称为暂时性死区(Temporal Dead Zone, TDZ):

    tmp = 'asd';
    if (true) {
      // 虽然在这之前定义了一个全局变量 tmp,但是块内重新定义了一个 tmp
      console.log(tmp); // 报错 ReferenceError
      let tmp;
    }

1.4 不允许重复声明

letconst 命令是不允许重复声明同一个变量的:

    if (true) {
      let tmp;
      let tmp;  // 报错 SyntaxError
    }
    
    function func(arg) {	// 因为已经有一个 arg 变量名的形参了
      let arg;
    }
    func()  // 报错 SyntaxError

2. 箭头函数

2.1 基本用法

ES6 中可以使用箭头函数来定义函数。下面例子中,同名函数的定义是等价的:

    // 基础用法
    const test1 = function (参数1, 参数2,, 参数N) { 函数声明 }
    const test1 = (参数1, 参数2,, 参数N) => { 函数声明 }
    
    // 当只有一个参数时,圆括号是可选的
    const test2 = (单一参数) => { 函数声明 }
    const test2 = 单一参数 => { 函数声明 }
    
    // 没有参数时,圆括号不能省略
    const test3 = () => { 函数声明 }
    
    // 当函数体只是 return 一个单一表达式时,可以省略花括号和 return 关键词
    const test4 = () { return 表达式(单一) }
    const test4 = () => 表达式(单一)
    
    // 函数体返回对象字面表达式时,如果省略花括号和 return 关键词,返回值需要加括号
    const test5 = () => { return {foo: 'bar'} }
    const test5 = () => ({foo: 'bar'})  // 输出 {foo: 'bar'}
    const test6 = () => {foo: 'bar'}    // 输出 undefined,大括号被识别为代码块

总结:

  • 参数如果只有一个,可以不加圆括号 ()
  • 没有参数时,不能省略圆括号 ()
  • 如果函数体只返回单一表达式,那么函数体可以不使用大括号 {}return,直接写表达式即可;
  • 在 3 的基础上,如果返回值是一个对象字面量,那么返回值需要加圆括号 (),避免被识别为代码块。

2.2 箭头函数中的 this

箭头函数出来之前,函数在执行时才能确定 this 的指向,所以会经常出现闭包中的 this 指向不是期望值的情况。在以前的做法中,如果要给闭包指定 this,可以用 bind\call\apply,或者把 this 值分配给封闭的变量(一般是 that)。箭头函数出来之后,给我们提供了不一样的选择。

箭头函数不会创建自己的 this,只会从自己定义位置的作用域的上一层直接继承 this

    function Person(){
      this.age = 10;
    
      setInterval(() => {
        this.age++;    // this 正确地指向 p 实例
      }, 1000);
    }
    
    var p = new Person();  // 1s后打印出 10

另外因为箭头函数没有自己的 this 指针,因此对箭头函数使用 callapplybind 时,只能传递函数,不能绑定 this,它们的第一个参数将被忽略:

    this.param = 1
    
    const func1 = () => console.log(this.param)
    const func2 = function() {
        console.log(this.param)
    }
    func1.apply({ param: 2 })   // 输出: 1
    func2.apply({ param: 2 })   // 输出: 2

总结一下:

  • 箭头函数中的 this 就是定义时所在的对象,而不是使用时所在的对象;
  • 无法作为构造函数,不可以使用 new命令,否则会抛错;
  • 箭头函数中不存在 arguments 对象,但我们可以通过 Rest 参数来替代;
  • 箭头函数无法使用 yield 命令,所以不能作为 Generator 函数;
  • 不可以通过 bindcallapply 绑定 this,但是可以通过 callapply 传递参数。

3. class 语法

class 语法出来之前,我们一般通过上一章介绍的一些方法,来间接实现面向对象三个要素:封装、继承、多态。ES6 给我们提供了更面向对象(更 OOObject Oriented)的写法,我们可以通过 class 关键字来定义一个类。

基本用法:

    // ES5 方式定义一个类
    function Foo() { this.kind = 'foo' }
    
    Foo.staticMethod = function() { console.log('静态方法') }
    
    Foo.prototype.doThis = function() { console.log(`实例方法 kind:${ this.kind }`) }
    
    // ES6 方式定义一个类
    class Foo {
        /* 构造函数 */
        constructor() { this.kind = 'foo' }
        
        /* 静态方法 */
        static staticMethod() { console.log('静态方法') }
        
        /* 实例方法 */
        doThis() {
            console.log(`实例方法 kind:${ this.kind }`)
        }
    }

ES6 方式实现继承:

    // 接上
    class Bar extends Foo {
        constructor() {
            super()
            this.type = 'bar'
        }
        
        doThat() {
            console.log(`实例方法 type:${ this.type } kind:${ this.kind }`)
        }
    }
    
    const bar = new Bar()
    bar.doThat()   // 实例方法 type:bar kind:foo

总结一下:

  • static 关键字声明的是静态方法,不会被实例继承,只可以直接通过类来调用;
  • class 没有变量提升,因此必须在定义之后才使用;
  • constructor为构造函数,子类构造函数中的super 代表父类的构造函数,必须执行一次,否则新建实例时会抛错;
  • new.target 一般用在构造函数中,返回 new 命令作用于的那个构造函数;
  • classextends 来实现继承,子类继承父类所有实例方法和属性。

4. 解构赋值

ES6 允许按照一定方式,从数组和对象中提取值。本质上这种写法属于模式匹配,只要等号两边的模式相同,左边的变量就会被赋予相对应的值。

数组解构基本用法:

    let [a, b, c] = [1, 2, 3]          // a:1 b:2 c:3
    let [a, [[b], c]] = [1, [[2], 3]]  // a:1 b:2 c:3
    let [a, , b] = [1, 2, 3]           // a:1 b:3
    let [a,...b] = [1, 2, 3]           // a:1 b:[2, 3]
    let [a, b,...c] = [1]              // a:1 b:undefined c:[]
    let [a, b = 4] = [null, undefined] // a:null b:4
    let [a, b = 4] = [1]						   // a:1 b:4
    let [a, b = 4] = [1, null]				 // a:1 b:null
  • 解构不成功,变量的值为 undefined
  • 解构可以指定默认值,如果被解构变量的对应位置没有值,即为空,或者值为 undefined,默认值才会生效。

对象解构基本用法:

    let { a, b } = { a: 1, b: 2 }      // a:1 b:2
    let { c } = { a: 1, b: 2 }         // c:undefined
    let { c = 4 } = { a: 1, b: 2 }     // c:4
    let { a: c } = { a: 1, b: 2 }      // c:1
    let { a: c = 4, d: e = 5 } = { a: 1, b: 2 }   // c:1 e:5
    let { length } = [1, 2]            // length:2
  • 解构不成功,变量的值为 undefined
  • 解构可以指定默认值,如果被解构变量严格为 undefined 或为空,默认值才会生效;
  • 如果变量名和属性名不一致,可以赋给其它名字的变量 {a:c},实际上对象解构赋值 {a} 是简写 {a:a},对象的解构赋值是先找到同名属性,再赋给对应的变量,真正被赋值的是后者。

5. 扩展运算符

扩展运算符和 Rest 参数的形式一样 ...,作用相当于 Rest 参数的逆运算。它将一个数组转化为逗号分割的参数序列。事实上实现了迭代器(Iterator)接口的对象都可以使用扩展运算符,包括 ArrayStringSetMapNodeListarguments 等。

数组可以使用扩展运算符:

    console.log(...[1, 2, 3])               // 1 2 3
    console.log(1, ...[2, 3, 4], 5)         // 1 2 3 4 5
    [...document.querySelectorAll('div')]   // [<div>, <div>, <div>]
    [...[1], ...[2, 3]]                     // [1, 2, 3]
    
    const arr = [1]
    arr.push(...[2, 3])                     // arr:[1, 2, 3]

对象也可以使用扩展运算符,通常被用来合并对象:

    {...{a: 1}, ...{a: 2, b: 3}}            // {a: 2, b: 3}

6. 默认参数

ES6 允许给函数的参数设置默认值,如果不传递、或者传递为 undefined 则会采用默认值:

    function log(x, y = 'World') {
        console.log(x, y)
    }
    
    log('Hello')             // Hello World
    log('Hello', undefined)  // Hello World
    log('Hello', 'China')    // Hello China
    log(undefined, 'China')  // undefined China
    log(, 'China')           // 报错 SyntaxError
    log('Hello', '')         // Hello
    log('Hello', null)       // Hello null

注意:

  • 参数不传递或者传递 undefined 会让参数等于默认值,但是如果参数不是最后一个,不传递参数会报错;
  • 特别注意,传递 null 不会让函数参数等于默认值。
  • 默认参数可以和解构赋值结合使用:
    function log({x, y = 'World'} = {}) {
        console.log(x, y)
    }
    
    log({x: 'hello'})            // hello World
    log({x: 'hello',y: 'China'}) // hello China
    log({y: 'China'})            // undefined "China"
    log({})                      // undefined "World"
    log()                        // undefined "World"

分析一下后两种情况:

  • 传递参数为 {} 时,因为被解构变量既不为空,也不是 undefined,所以不会使用解构赋值的默认参数 {}。虽然最终形参的赋值过程还是 {x, y = 'World'} = {},但是这里等号右边的空对象是调用时传递的,而不是形参对象的默认值;
  • 不传参时,即被解构变量为空,那么会使用形参的默认参数 {},形参的赋值过程相当于 {x, y = 'World'} = {},注意这里等号右边的空对象,是形参对象的默认值。
  • 上面是给被解构变量的整体设置了一个默认值 {}。下面细化一下,给默认值 {} 中的每一项也设置默认值:
    function log({x, y} = {x: 'yes', y: 'World'}) {
        console.log(x, y)
    }
    
    log({x: 'hello'})            // hello undefined
    log({x: 'hello',y: 'China'}) // hello China
    log({y: 'China'})            // undefined "China"
    log({})                      // undefined undefined
    log()                        // yes World

也分析一下后两种情况:

  • 传递参数为{} 时,被解构变量不为空,也不为 undefined,因此不使用默认参数 {x, y: 'World'},形参的赋值过程相当于 {x, y} = {},所以 x 与 y 都是 undefined
  • 不传参时,等式右边采用默认参数,形参赋值过程相当于 {x, y} = {x: 'yes', y: 'World'}

7. Rest 参数

我们知道 arguments 是类数组,没有数组相关方法。为了使用数组上的一些方法,我们需要先 用Array.prototype.slice.call(arguments) 或者 [...arguments] 来将 arguments 类数组转化为数组。

ES6 允许我们通过 Rest 参数来获取函数的多余参数:

    // 获取函数所有的参数,rest 为数组
    function func1(...rest){ /* ... */}
    
    // 获取函数第一个参数外其他的参数,rest 为数组
    function func1(val, ...rest){ /* ... */}

注意,Rest 参数只能放在最后一个,否则会报错:

    // 报错 SyntaxError: Rest 参数必须是最后一个参数
    function func1(...rest, a){ /* ... */}

形参名并不必须是 rest,也可以是其它名称,使用者可以根据自己的习惯来命名

继承与原型链

JavaScript 是一种灵活的语言,兼容并包含面向对象风格、函数式风格等编程风格。我们知道面向对象风格有三大特性和六大原则,三大特性是封装、继承、多态,六大原则是单一职责原则(SRP)、开放封闭原则(OCP)、里氏替换原则(LSP)、依赖倒置原则(DIP)、接口分离原则(ISP)、最少知识原则(LKP)。

JavaScript 并不是强面向对象语言,因此它的灵活性决定了并不是所有面向对象的特征都适合 JavaScript 开发,本教程将会着重介绍三大特性中的继承,和六大原则里的单一职责原则、开放封闭原则、最少知识原则

1. 原型对象链

JavaScript 内建的继承方法被称为原型对象链,又称为原型对象继承。对于一个对象,因为它继承了它的原型对象的属性,所以它可以访问到这些属性。同理,原型对象也是一个对象,它也有自己的原型对象,因此也可以继承它的原型对象的属性。

这就是原型继承链:对象继承其原型对象,而原型对象继承它的原型对象,以此类推。

2. 对象继承

使用对象字面量形式创建对象时,会隐式指定 Object.prototype 为新对象的 [[Prototype]]。使用 Object.create()方式创建对象时,可以显式指定新对象的[[Prototype]]。该方法接受两个参数:第一个参数为新对象的[[Prototype]],第二个参数描述了新对象的属性,格式如在Object.defineProperties()` 中使用的一样。

    // 对象字面量形式,原型被隐式地设置为 Object.prototype
    var rectangle = { sizeType: '四边形' }
    
    // Object.create() 创建,显示指定为 Object.prototype, 等价于 ↑
    var rectangle = Object.create(Object.prototype, {
        sizeType: {
            configurable: true,
            enumerable: true,
            value: '四边形',
            writable: true
        }
    })

我们可以用这个方法来实现对象继承:

    var rectangle = {
        sizeType: '四边形',
        getSize: function() {
            console.log(this.sizeType)
        }
    }
    
    var square = Object.create(rectangle, {
        sizeType: { value: '正方形' }
    })
    
    rectangle.getSize()   // "四边形"
    square.getSize()      // "正方形"
    
    console.log(rectangle.hasOwnProperty('getSize')) // true
    console.log(rectangle.isPrototypeOf(square))     // true
    console.log(square.hasOwnProperty('getSize'))    // false
    console.log('getSize' in square)                 // true
    
    console.log(square.__proto__ === rectangle)                       // true
    console.log(square.__proto__.__proto__ === Object.prototype)      // true

  • 对象 square 继承自对象 rectangle,也就继承了 rectanglesizeType 属性和 getSize() 方法,又通过重写 sizeType 属性定义了一个自有属性,隐藏并替代了原型对象中的同名属性。所以 rectangle.getSize() 输出 「四边形」 而 square.getSize() 输出 「正方形」。
  • 在访问一个对象的时候,JavaScript 引擎会执行一个搜索过程,如果在对象实例上发现该属性,该属性值就会被使用,如果没有发现则搜索其原型对象 [[Prototype]],如果仍然没有发现,则继续搜索该原型对象的原型对象 [[Prototype]],直到继承链顶端,顶端通常是一个 Object.prototype,其 [[prototype]]null。这就是原型链的查找过程。
  • 可以通过 Object.create() 创建 [[Prototype]]null 的对象:var obj = Object.create(null)。对象 obj 是一个没有原型链的对象,这意味着 toString()valueOf 等存在于 Object 原型上的方法同样不存在于该对象上,通常我们将这样创建出来的对象为纯净对象。

3. 原型链继承

  • JavaScript 中的对象继承是构造函数继承的基础,几乎所有的函数都有 prototype 属性(通过Function.prototype.bind 方法构造出来的函数是个例外),它可以被替换和修改。
  • 函数声明创建函数时,函数的 prototype 属性被自动设置为一个继承自 Object.prototype 的对象,该对象有个自有属性 constructor,其值就是函数本身。
    // 构造函数
    function YourConstructor() {}
    
    // JavaScript 引擎在背后做的:
    YourConstructor.prototype = Object.create(Object.prototype, {
        constructor: {
            configurable: true,
            enumerable: true,
            value: YourConstructor,
            writable: true
        }
    })
    
    console.log(YourConstructor.prototype.__proto__ === Object.prototype)         // true

JavaScript 引擎帮你把构造函数的 prototype 属性设置为一个继承自 Object.prototype 的对象,这意味着我们创建出来的构造函数都继承自 Object.prototype。由于 prototype 可以被赋值和改写,所以通过改写它来改变原型链:

    /* 四边形 */
    function Rectangle(length, width) {
        this.length = length   // 长
        this.width = width     // 宽
    }
    
    /* 获取面积 */
    Rectangle.prototype.getArea = function() {
        return this.length * this.width
    }
    
    /* 获取尺寸信息 */
    Rectangle.prototype.getSize = function() {
        console.log(`Rectangle: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    /* 正方形 */
    function Square(size) {
        this.length = size
        this.width = size
    }
    
    Square.prototype = new Rectangle()
    Square.prototype.constructor = Square   // 原本为 Rectangle,重置回 Square 构造函数
    
    Square.prototype.getSize = function() {
        console.log(`Square: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    var rect = new Rectangle(5, 10)
    var squa = new Square(6)
    
    rect.getSize()       // Rectangle: 5x10,面积: 50
    squa.getSize()       // Square: 6x6,面积: 36

  • 为什么使用 Square.prototype = new Rectangle() 而不用 Square.prototype = Rectangle.prototype 呢。这是因为后者使得两个构造函数的 prototype 指向了同一个对象,当修改其中一个函数的 prototype 时,另一个函数也会受影响。
  • 所以 Square 构造函数的 prototype 属性被改写为了 Rectagle 的一个实例。
  • 但是仍然有问题。当一个属性只存在于构造函数的 prototype 上,而构造函数本身没有时,该属性会在构造函数的所有实例间共享,其中一个实例修改了该属性,其他所有实例都会受影响:
    /* 四边形 */
    function Rectangle(sizes) {
        this.sizes = sizes
    }
    
    /* 正方形 */
    function Square() {}
    
    Square.prototype = new Rectangle([1, 2])
    
    var squa1 = new Square()  // sizes: [1, 2]
    
    squa1.sizes.push(3)       // 在 squa1 中修改了 sizes
    
    console.log(squa1.sizes)  // sizes: [1, 2, 3]
    
    var squa2 = new Square()
    
    console.log(squa2.sizes)  // sizes: [1, 2, 3] 应该是 [1, 2],得到的是修改后的 sizes

4. 构造函数窃取

构造函数窃取又称构造函数借用、经典继承。这种技术的基本思想相当简单,即在子类型构造函数的内部调用父类构造函数。

    function getArea() {
        return this.length * this.width
    }
    
    /* 四边形 */
    function Rectangle(length, width) {
        this.length = length
        this.width = width
    }
    
    /* 获取面积 */
    Rectangle.prototype.getArea = getArea
    
    /* 获取尺寸信息 */
    Rectangle.prototype.getSize = function() {
        console.log(`Rectangle: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    /* 正方形 */
    function Square(size) {
        Rectangle.call(this, size, size)
        
        this.getArea = getArea
        
        this.getSize = function() {
            console.log(`Square: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
        }
    }
    
    var rect = new Rectangle(5, 10)
    var squa = new Square(6)
    
    rect.getSize()       // Rectangle: 5x10,面积: 50
    squa.getSize()       // Square: 6x6,面积: 36
  • 这样的实现避免了引用类型的属性被所有实例共享的问题,在父类实例创建时还可以自定义地传参,缺点是方法都是在构造函数中定义,每次创建实例都会重新赋值一遍方法,即使方法的引用是一致的。
  • 这种方式通过构造函数窃取来设置属性,模仿了那些基于类的语言的类继承,所以这通常被称为伪类继承或经典继承。

5. 组合继承

组合继承又称伪经典继承,指的是将原型链和借用构造函数的技术组合发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。

    /* 四边形 */
    function Rectangle(length, width) {
        this.length = length
        this.width = width
        this.color = 'red'
    }
    
    /* 获取面积 */
    Rectangle.prototype.getArea = function() {
        return this.length * this.width
    }
    
    /* 获取尺寸信息 */
    Rectangle.prototype.getSize = function() {
        console.log(`Rectangle: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    /* 正方形 */
    function Square(size) {
        Rectangle.call(this, size, size)  // 第一次调用 Rectangle 函数
        this.color = 'blue'
    }
    
    Square.prototype = new Rectangle()    // 第二次调用 Rectangle 函数
    Square.prototype.constructor = Square
    
    Square.prototype.getSize = function() {
        console.log(`Square: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    var rect = new Rectangle(5, 10)
    var squa = new Square(6)
    
    rect.getSize()       // Rectangle: 5x10,面积: 50
    squa.getSize()       // Square: 6x6,面积: 36

组合继承是 JavaScript 中最常用的继承模式,但是父类构造函数被调用了两次。

6. 寄生组合式继承

    /* 实现继承逻辑 */
    function inheritPrototype(sub, sup) {
        var prototype = Object.create(sup.prototype)
        prototype.constructor = sub
        sub.prototype = prototype
    }
    
    /* 四边形 */
    function Rectangle(length, width) {
        this.length = length
        this.width = width
        this.color = 'red'
    }
    
    /* 获取面积 */
    Rectangle.prototype.getArea = function() {
        return this.length * this.width
    }
    
    /* 获取尺寸信息 */
    Rectangle.prototype.getSize = function() {
        console.log(`Rectangle: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    /* 正方形 */
    function Square(size) {
        Rectangle.call(this, size, size)  // 第一次调用 Rectangle 函数
        this.color = 'blue'
    }
    
    // 实现继承
    inheritPrototype(Square, Rectangle)
    
    Square.prototype.getSize = function() {
        console.log(`Square: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
    }
    
    var rect = new Rectangle(5, 10)
    var squa = new Square(6)
    
    rect.getSize()       // Rectangle: 5x10,面积: 50
    squa.getSize()       // Square: 6x6,面积: 36
  • 这种方式的高效率体现它只调用了一次父类构造函数,并且因此避免了在 Rectangle.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变。因此,还能够正常使用 instanceofisPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。
  • 不过这种实现有些麻烦,推介使用组合继承和下面的 ES6 方式实现继承。

7. ES6 的 extends 方式实现继承

ES6 中引入了 class 关键字,class 之间可以通过 extends 关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰、方便和语义化的多。

    /* 四边形 */
    class Rectangle {
        constructor(length, width) {
            this.length = length
            this.width = width
            this.color = 'red'
        }
        
        /* 获取面积 */
        getArea() {
            return this.length * this.width
        }
        
        /* 获取尺寸信息 */
        getSize() {
            console.log(`Rectangle: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
        }
    }
    
    /* 正方形 */
    class Square extends Rectangle {
        constructor(size) {
            super(size, size)
            this.color = 'blue'
        }
        
        getSize() {
            console.log(`Square: ${ this.length }x${ this.width },面积: ${ this.getArea() }`)
        }
    }
    
    
    var rect = new Rectangle(5, 10)
    var squa = new Square(6)
    
    rect.getSize()       // Rectangle: 5x10,面积: 50
    squa.getSize()       // Square: 6x6,面积: 36

然而并不是所有浏览器都支持 class/extends 关键词,不过我们可以引入 Babel 来进行转译。class 语法实际上也是之前语法的语法糖,用户可以把上面的代码放到 Babel 的在线编译中看看,编译出来是什么样子

设计原则

在前文我们介绍了面向对象三大特性之继承,本文将主要介绍面向对象六大原则中的单一职责原则(SRP)、开放封闭原则(OCP)、最少知识原则(LKP)。

设计原则是指导思想,从思想上给我们指明程序设计的正确方向,是我们在开发设计过程中应该尽力遵守的准则。而设计模式是实现手段,因此设计模式也应该遵守这些原则,或者说,设计模式就是这些设计原则的一些具体体现。要达到的目标就是高内聚低耦合,高内聚是说模块内部要高度聚合,是模块内部的关系,低耦合是说模块与模块之间的耦合度要尽量低,是模块与模块间的关系。

注意 ,遵守设计原则是好,但是过犹不及,在实际项目中我们不要刻板遵守,需要根据实际情况灵活运用

1. 单一职责原则 SRP

  • 单一职责原则 (Single Responsibility Principle, SRP)是指对一个类(方法、对象,下文统称对象)来说,应该仅有一个引起它变化的原因。也就是说,一个对象只做一件事。
  • 单一职责原则可以让我们对对象的维护变得简单,如果一个对象具有多个职责的话,那么如果一个职责的逻辑需要修改,那么势必会影响到其他职责的代码。如果一个对象具有多种职责,职责之间相互耦合,对一个职责的修改会影响到其他职责的实现,这就是属于模块内低内聚高耦合的情况。负责的职责越多,耦合越强,对模块的修改就越来越危险。

优点:

  • 降低单个类(方法、对象)的复杂度,提高可读性和可维护性,功能之间的界限更清晰; 类(方法、对象)之间根据功能被分为更小的粒度,有助于代码的复用;
  • 缺点: 增加系统中类(方法、对象)的个数,实际上也增加了这些对象之间相互联系的难度,同时也引入了额外的复杂度。

2. 开放封闭原则 OCP

开放封闭原则 (Open-Close Principle, OCP)是指一个模块在扩展性方面应该是开放的,而在更改性方面应该是封闭的,也就是对扩展开放,对修改封闭。

当需要增加需求的时候,则尽量通过扩展新代码的方式,而不是修改已有代码。因为修改已有代码,则会给依赖原有代码的模块带来隐患,因此修改之后需要把所有依赖原有代码的模块都测试一遍,修改一遍测试一遍,带来的成本很大,如果是上线的大型项目,那么代价和风险可能更高。

优点

  • 增加可维护性,避免因为修改给系统带来的不稳定性。

3. 最少知识原则 LKP

  • 最少知识原则 (Least Knowledge Principle, LKP)又称为迪米特原则 (Law of Demeter, LOD),一个对象应该对其他对象有最少的了解。
  • 通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,类的内部如何实现、如何复杂都与调用者或者依赖者没关系,调用者或者依赖者只需要知道他需要的方法即可,其他的我一概不关心。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
  • 通常为了减少对象之间的联系,是通过引入一个第三者来帮助进行通信,阻隔对象之间的直接通信,从而减少耦合。

优点:

  • 降低类(方法、对象)之间不必要的依赖,减少耦合。

缺点:

  • 类(方法、对象)之间不直接通信也会经过一个第三者来通信,那么就要权衡引入第三者带来的复杂度是否值得。

二、创建型模式

单例模式

  • 单例模式可能是设计模式里面最简单的模式了,虽然简单,但在我们日常生活和编程中却经常接触到,本节我们一起来学习一下。
  • 单例模式 (Singleton Pattern)又称为单体模式,保证一个类只有一个实例,并提供一个访问它的全局访问点。也就是说,第二次使用同一个类创建新对象的时候,应该得到与第一次创建的对象完全相同的对象。

1. 你曾经遇见过的单例模式

  • 当我们在电脑上玩经营类的游戏,经过一番眼花缭乱的骚操作好不容易走上正轨,夜深了我们去休息,第二天打开电脑,发现要从头玩,立马就把电脑扔窗外了,所以一般希望从前一天的进度接着打,这里就用到了存档。每次玩这游戏的时候,我们都希望拿到同一个存档接着玩,这就是属于单例模式的一个实例。
  • 编程中也有很多对象我们只需要唯一一个,比如数据库连接、线程池、配置文件缓存、浏览器中的 window/document 等,如果创建多个实例,会带来资源耗费严重,或访问行为不一致等情况。
  • 类似于数据库连接实例,我们可能频繁使用,但是创建它所需要的开销又比较大,这时只使用一个数据库连接就可以节约很多开销。一些文件的读取场景也类似,如果文件比较大,那么文件读取就是一个比较重的操作。比如这个文件是一个配置文件,那么完全可以将读取到的文件内容缓存一份,每次来读取的时候访问缓存即可,这样也可以达到节约开销的目的。

在类似场景中,这些例子有以下特点:

  • 每次访问者来访问,返回的都是同一个实例;
  • 如果一开始实例没有创建,那么这个特定类需要自行创建这个实例;

2. 实例的代码实现

  • 如果你是一个前端er,那么你肯定知道浏览器中的 windowdocument 全局变量,这两个对象都是单例,任何时候访问他们都是一样的对象,window 表示包含 DOM 文档的窗口,document 是窗口中载入的 DOM 文档,分别提供了各自相关的方法。
  • 在 ES6 新增语法的 Module 模块特性,通过 import/export 导出模块中的变量是单例的,也就是说,如果在某个地方改变了模块内部变量的值,别的地方再引用的这个值是改变之后的。除此之外,项目中的全局状态管理模式 Vuex、Redux、MobX 等维护的全局状态,vue-routerreact-router 等维护的路由实例,在单页应用的单页面中都属于单例的应用(但不属于单例模式的应用)。
  • 在 JavaScript 中使用字面量方式创建一个新对象时,实际上没有其他对象与其类似,因为新对象已经是单例了:
    { a: 1 } === { a: 1 } 		// false
  • 那么问题来了,如何对构造函数使用 new 操作符创建多个对象时,仅获取同一个单例对象呢。
  • 对于刚刚打经营游戏的例子,我们可以用 JavaScript 来实现一下:
    function ManageGame() {
        if (ManageGame._schedule) {        // 判断是否已经有单例了
            return ManageGame._schedule
        }
        ManageGame._schedule = this
    }
    
    ManageGame.getInstance = function() {
        if (ManageGame._schedule) {        // 判断是否已经有单例了
            return ManageGame._schedule
        }
        return ManageGame._schedule = new ManageGame()
    }
    
    const schedule1 = new ManageGame()
    const schedule2 = ManageGame.getInstance()
    
    console.log(schedule1 === schedule2)

稍微解释一下,这个构造函数在内部维护(或者直接挂载自己身上)一个实例,第一次执行 new 的时候判断这个实例有没有创建过,创建过就直接返回,否则走创建流程。我们可以用 ES6class 语法改造一下:

    class ManageGame {
        static _schedule = null
        
        static getInstance() {
            if (ManageGame._schedule) {        // 判断是否已经有单例了
                return ManageGame._schedule
            }
            return ManageGame._schedule = new ManageGame()
        }
        
        constructor() {
            if (ManageGame._schedule) {        // 判断是否已经有单例了
                return ManageGame._schedule
            }
            ManageGame._schedule = this
        }
    }
    
    const schedule1 = new ManageGame()
    const schedule2 = ManageGame.getInstance()
    
    console.log(schedule1 === schedule2)	// true

上面方法的缺点在于维护的实例作为静态属性直接暴露,外部可以直接修改。

3. 单例模式的通用实现

根据上面的例子提炼一下单例模式,游戏可以被认为是一个特定的类(Singleton),而存档是单例(instance),每次访问特定类的时候,都会拿到同一个实例。主要有下面几个概念:

  • Singleton :特定类,这是我们需要访问的类,访问者要拿到的是它的实例;
  • instance :单例,是特定类的实例,特定类一般会提供 getInstance 方法来获取该单例;
  • getInstance :获取单例的方法,或者直接由 new 操作符获取;

这里有几个实现点要关注一下:

  • 访问时始终返回的是同一个实例;
  • 自行实例化,无论是一开始加载的时候就创建好,还是在第一次被访问时;
  • 一般还会提供一个 getInstance 方法用来获取它的实例;

结构大概如下图:

下面使用通用的方法来实现一下。

3.1 IIFE 方式创建单例模式

  • 简单实现中,我们提到了缺点是实例会暴露,那么这里我们首先使用立即调用函数 IIFE 将不希望公开的单例实例 instance 隐藏。
  • 当然也可以使用构造函数复写将闭包进行的更彻底,具体代码参看 Github 仓库,这里就不贴了。
    const Singleton = (function() {
        let _instance = null        // 存储单例
        
        const Singleton = function() {
            if (_instance) return _instance     // 判断是否已有单例
            _instance = this
            this.init()                         // 初始化操作
            return _instance
        }
        
        Singleton.prototype.init = function() {
            this.foo = 'Singleton Pattern'
        }
        
        return Singleton
    })()
    
    const visitor1 = new Singleton()
    const visitor2 = new Singleton()
    
    console.log(visitor1 === visitor2)	// true
  • 这样一来,虽然仍使用一个变量 _instance 来保存单例,但是由于在闭包的内部,所以外部代码无法直接修改。
  • 在这个基础上,我们可以继续改进,增加 getInstance 静态方法:
    const Singleton = (function() {
        let _instance = null        // 存储单例
        
        const Singleton = function() {
            if (_instance) return _instance     // 判断是否已有单例
            _instance = this
            this.init()                         // 初始化操作
            return _instance
        }
        
        Singleton.prototype.init = function() {
            this.foo = 'Singleton Pattern'
        }
        
        Singleton.getInstance = function() {
            if (_instance) return _instance
            _instance = new Singleton()
            return _instance
        }
        
        return Singleton
    })()
    
    const visitor1 = new Singleton()
    const visitor2 = new Singleton()         // 既可以 new 获取单例
    const visitor3 = Singleton.getInstance() // 也可以 getInstance 获取单例
    
    console.log(visitor1 === visitor2)	// true
    console.log(visitor1 === visitor3)	// true
  • 代价和上例一样是闭包开销,并且因为 IIFE 操作带来了额外的复杂度,让可读性变差。
  • IIFE 内部返回的 Singleton 才是我们真正需要的单例的构造函数,外部的 Singleton 把它和一些单例模式的创建逻辑进行了一些封装。
  • IIFE 方式除了直接返回一个方法/类实例之外,还可以通过模块模式的方式来进行,就不贴代码了,代码实现在 Github 仓库中,读者可以自己瞅瞅。

3.2 块级作用域方式创建单例

IIFE 方式本质还是通过函数作用域的方式来隐藏内部作用域的变量,有了 ES6 的 let/const 之后,可以通过 { } 块级作用域的方式来隐藏内部变量:

    let getInstance
    
    {
        let _instance = null        // 存储单例
        
        const Singleton = function() {
            if (_instance) return _instance     // 判断是否已有单例
            _instance = this
            this.init()                         // 初始化操作
            return _instance
        }
        
        Singleton.prototype.init = function() {
            this.foo = 'Singleton Pattern'
        }
        
        getInstance = function() {
            if (_instance) return _instance
            _instance = new Singleton()
            return _instance
        }
    }
    
    const visitor1 = getInstance()
    const visitor2 = getInstance()
    
    console.log(visitor1 === visitor2)

输出: true 怎么样,是不是对块级作用域的理解更深了呢~

3.3 单例模式赋能

之前的例子中,单例模式的创建逻辑和原先这个类的一些功能逻辑(比如 init 等操作)混杂在一起,根据单一职责原则,这个例子我们还可以继续改进一下,将单例模式的创建逻辑和特定类的功能逻辑拆开,这样功能逻辑就可以和正常的类一样。

    /* 功能类 */
    class FuncClass {
        constructor(bar) { 
            this.bar = bar
            this.init()
        }
        
        init() {
            this.foo = 'Singleton Pattern'
        }
    }
    
    /* 单例模式的赋能类 */
    const Singleton = (function() {
        let _instance = null        // 存储单例
        
        const ProxySingleton = function(bar) {
            if (_instance) return _instance     // 判断是否已有单例
            _instance = new FuncClass(bar)
            return _instance
        }
        
        ProxySingleton.getInstance = function(bar) {
            if (_instance) return _instance
            _instance = new Singleton(bar)
            return _instance
        }
        
        return ProxySingleton
    })()
    
    const visitor1 = new Singleton('单例1')
    const visitor2 = new Singleton('单例2')
    const visitor3 = Singleton.getInstance()
    
    console.log(visitor1 === visitor2)	// true
    console.log(visitor1 === visitor3)	// true
  • 这样的单例模式赋能类也可被称为代理类,将业务类和单例模式的逻辑解耦,把单例的创建逻辑抽象封装出来,有利于业务类的扩展和维护。代理的概念我们将在后面代理模式的章节中更加详细地探讨。
  • 使用类似的概念,配合 ES6 引入的 Proxy 来拦截默认的 new 方式,我们可以写出更简化的单例模式赋能方法:
    /* Person 类 */
    class Person {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    }
    
    /* 单例模式的赋能方法 */
    function Singleton(FuncClass) {
        let _instance
        return new Proxy(FuncClass, {
            construct(target, args) {
                return _instance || (_instance = Reflect.construct(FuncClass, args)) // 使用 new FuncClass(...args) 也可以
            }
        })
    }
    
    const PersonInstance = Singleton(Person)
    
    const person1 = new PersonInstance('张小帅', 25)
    const person2 = new PersonInstance('李小美', 23)
    
    console.log(person1 === person2)	// true

4. 惰性单例、懒汉式-饿汉式

  • 有时候一个实例化过程比较耗费性能的类,但是却一直用不到,如果一开始就对这个类进行实例化就显得有些浪费,那么这时我们就可以使用惰性创建,即延迟创建该类的单例。之前的例子都属于惰性单例,实例的创建都是 new 的时候才进行。

惰性单例又被成为懒汉式,相对应的概念是饿汉式:

  • 懒汉式单例是在使用时才实例化
  • 饿汉式是当程序启动时或单例模式类一加载的时候就被创建。
  • 我们可以举一个简单的例子比较一下:
    class FuncClass {
        constructor() { this.bar = 'bar' }
    }
    
    // 饿汉式
    const HungrySingleton = (function() {
        const _instance = new FuncClass()
        
        return function() {
            return _instance
        }
    })()
    
    // 懒汉式
    const LazySingleton = (function() {
        let _instance = null
        
        return function() {
            return _instance || (_instance = new FuncClass())
        }
    })()
    
    const visitor1 = new HungrySingleton()
    const visitor2 = new HungrySingleton()
    const visitor3 = new LazySingleton()
    const visitor4 = new LazySingleton()
    
    console.log(visitor1 === visitor2)	// true
    console.log(visitor3 === visitor4)	// true

可以打上 debugger 在控制台中看一下,饿汉式在 HungrySingleton 这个 IIFE 执行的时候就进入到 FuncClass 的实例化流程了,而懒汉式的 LazySingleton 中 FuncClass 的实例化过程是在第一次 new 的时候才进行的。

惰性创建在实际开发中使用很普遍,了解一下对以后的开发工作很有帮助。

5. 源码中的单例模式

ElementUI 为例,ElementUI 中的全屏 Loading 蒙层调用有两种形式:

    // 1. 指令形式
    Vue.use(Loading.directive)
    // 2. 服务形式
    Vue.prototype.$loading = service
  • 上面的是指令形式注册,使用的方式 <div :v-loading.fullscreen="true">...</div>
  • 下面的是服务形式注册,使用的方式 this.$loading({ fullscreen: true })

用服务方式使用全屏 Loading 是单例的,即在前一个全屏 Loading 关闭前再次调用全屏 Loading,并不会创建一个新的 Loading 实例,而是返回现有全屏 Loading 的实例。

下面我们可以看看 ElementUI 2.9.2 的源码是如何实现的,为了观看方便,省略了部分代码:

    import Vue from 'vue'
    import loadingVue from './loading.vue'
    
    const LoadingConstructor = Vue.extend(loadingVue)
    
    let fullscreenLoading
    
    const Loading = (options = {}) => {
        if (options.fullscreen && fullscreenLoading) {
            return fullscreenLoading
        }
    
        let instance = new LoadingConstructor({
            el: document.createElement('div'),
            data: options
        })
    
        if (options.fullscreen) {
            fullscreenLoading = instance
        }
        return instance
    }
    
    export default Loading
  • 这里的单例是 fullscreenLoading,是存放在闭包中的,如果用户传的 optionsfullscreentrue 且已经创建了单例的情况下则回直接返回之前创建的单例,如果之前没有创建过,则创建单例并赋值给闭包中的 fullscreenLoading 后返回新创建的单例实例。
  • 这是一个典型的单例模式的应用,通过复用之前创建的全屏蒙层单例,不仅减少了实例化过程,而且避免了蒙层叠加蒙层出现的底色变深的情况。

6. 单例模式的优缺点

单例模式主要解决的问题就是节约资源,保持访问一致性。

简单分析一下它的优点:

  • 单例模式在创建后在内存中只存在一个实例,节约了内存开支和实例化时的性能开支,特别是需要重复使用一个创建开销比较大的类时,比起实例不断地销毁和重新实例化,单例能节约更多资源,比如数据库连接;
  • 单例模式可以解决对资源的多重占用,比如写文件操作时,因为只有一个实例,可以避免对一个文件进行同时操作;
  • 只使用一个实例,也可以减小垃圾回收机制 GC(Garbage Collecation) 的压力,表现在浏览器中就是系统卡顿减少,操作更流畅,CPU 资源占用更少;

单例模式也是有缺点的

  • 单例模式对扩展不友好,一般不容易扩展,因为单例模式一般自行实例化,没有接口;
  • 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化;

. 单例模式的使用场景

那我们应该在什么场景下使用单例模式呢:

  • 当一个类的实例化过程消耗的资源过多,可以使用单例模式来避免性能浪费;
  • 当项目中需要一个公共的状态,那么需要使用单例模式来保证访问一致性;

工厂模式

工厂模式 (Factory Pattern),根据不同的输入返回不同类的实例,一般用来创建同一类对象。工厂方式的主要思想是将对象的创建与对象的实现分离。

1. 你曾见过的工厂模式

今天你的老同学找你来玩,你决定下个馆子(因为不会做饭),于是你来到了小区门口的饭店,跟老板说,来一份鱼香肉丝,一份宫保鸡丁。等会儿菜就烧好端到你的面前,不用管菜烧出来的过程,你只要负责吃就行了。

上面这两个例子都是工厂模式的实例,老板相当于工厂,负责生产产品,访问者通过老板就可以拿到想要的产品。

在类似场景中,这些例子有以下特点:

  • 访问者只需要知道产品名,就可以从工厂获得对应实例;
  • 访问者不关心实例创建过程;

2. 实例的代码实现

如果你使用过 document.createElement 方法创建过 DOM 元素,那么你已经使用过工厂方法了,虽然这个方法实际上很复杂,但其使用的就是工厂方法的思想:访问者只需提供标签名(如 divimg),那么这个方法就会返回对应的 DOM 元素。

我们可以使用 JavaScript 将上面饭馆例子实现一下:

    /* 饭店方法 */
    function restaurant(menu) {
        switch (menu) {
            case '鱼香肉丝':
                return new YuXiangRouSi()
            case '宫保鸡丁':
                return new GongBaoJiDin()
            default:
                throw new Error('这个菜本店没有 -。-')
        }
    }
    
    /* 鱼香肉丝类 */
    function YuXiangRouSi() { this.type = '鱼香肉丝' }
    
    YuXiangRouSi.prototype.eat = function() {
        console.log(this.type + ' 真香~')
    }
    
    /* 宫保鸡丁类 */
    function GongBaoJiDin() { this.type = '宫保鸡丁' }
    
    GongBaoJiDin.prototype.eat = function() {
        console.log(this.type + ' 让我想起了外婆做的菜~')
    }
    
    const dish1 = restaurant('鱼香肉丝')
    dish1.eat()													// 输出: 鱼香肉丝 真香~
    const dish2 = restaurant('红烧排骨') // 输出: Error 这个菜本店没有 -。-

工厂方法中这里使用 switch-case 语法,你也可以用 if-else,都可以。

下面使用 ES6 的 class 语法改写一下:

    /* 饭店方法 */
    class Restaurant {
        static getMenu(menu) {
            switch (menu) {
                case '鱼香肉丝':
                    return new YuXiangRouSi()
                case '宫保鸡丁':
                    return new GongBaoJiDin()
                default:
                    throw new Error('这个菜本店没有 -。-')
            }
        }
    }
    
    /* 鱼香肉丝类 */
    class YuXiangRouSi {
        constructor() { this.type = '鱼香肉丝' }
        
        eat() { console.log(this.type + ' 真香~') }
    }
    
    /* 宫保鸡丁类 */
    class GongBaoJiDin {
        constructor() { this.type = '宫保鸡丁' }
        
        eat() { console.log(this.type + ' 让我想起了外婆做的菜~') }
    }
    
    const dish1 = Restaurant.getMenu('鱼香肉丝')
    dish1.eat()													 				 // 输出: 鱼香肉丝 真香~
    const dish2 = Restaurant.getMenu('红烧排骨')	// 输出: Error 这个菜本店没有 -。-
  • 这样就完成了一个工厂模式,但是这个实现有一个问题:工厂方法中包含了很多与创建产品相关的过程,如果产品种类很多的话,这个工厂方法中就会罗列很多产品的创建逻辑,每次新增或删除产品种类,不仅要增加产品类,还需要对应修改在工厂方法,违反了开闭原则,也导致这个工厂方法变得臃肿、高耦合。
  • 严格上这种实现在面向对象语言中叫做简单工厂模式。适用于产品种类比较少,创建逻辑不复杂的时候使用。
  • 工厂模式的本意是将实际创建对象的过程推迟到子类中,一般用抽象类来作为父类,创建过程由抽象类的子类来具体实现。JavaScript 中没有抽象类,所以我们可以简单地将工厂模式看做是一个实例化对象的工厂类即可。关于抽象类的有关内容,可以参看抽象工厂模式。
  • 然而作为灵活的 JavaScript,我们不必如此较真,可以把易变的参数提取出来:
    /* 饭店方法 */
    class Restaurant {
        constructor() {
            this.menuData = {}
        }
        
        /* 创建菜品 */
        getMenu(menu) {
            if (!this.menuData[menu])
                throw new Error('这个菜本店没有 -。-')
            const { type, message } = this.menuData[menu]
            return new Menu(type, message)
        }
        
        /* 增加菜品种类 */
        addMenu(menu, type, message) {
            if (this.menuData[menu]) {
                console.Info('已经有这个菜了!')
                return
            }
            this.menuData[menu] = { type, message }
        }
        
        /* 移除菜品 */
        removeMenu(menu) {
            if (!this.menuData[menu]) return
            delete this.menuData[menu]
        }
    }
    
    /* 菜品类 */
    class Menu {
        constructor(type, message) {
            this.type = type
            this.message = message
        }
        
        eat() { console.log(this.type + this.message) }
    }
    
    const restaurant = new Restaurant()
    restaurant.addMenu('YuXiangRouSi', '鱼香肉丝', ' 真香~')			// 注册菜品
    restaurant.addMenu('GongBaoJiDin', '宫保鸡丁', ' 让我想起了外婆做的菜~')
    
    const dish1 = restaurant.getMenu('YuXiangRouSi')
    dish1.eat()																				// 输出: 鱼香肉丝 真香~
    const dish2 = restaurant.getMenu('HongSaoPaiGu')	// 输出: Error 这个菜本店没有 -。-
  • 我们还给 Restaurant 类增加了 addMenu/removeMenu 私有方法,以便于扩展。
  • 当然这里如果菜品参数不太一致,可以在 addMenu 时候注册构造函数或者类,创建的时候返回 new 出的对应类实例,灵活变通即可。

3. 工厂模式的通用实现

根据上面的例子我们可以提炼一下工厂模式,饭店可以被认为是工厂类(Factory),菜品是产品(Product),如果我们希望获得菜品实例,通过工厂类就可以拿到产品实例,不用关注产品实例创建流程。主要有下面几个概念:

  • Factory :工厂,负责返回产品实例;
  • Product :产品,访问者从工厂拿到产品实例;

结构大概如下:

下面用通用的方法实现,这里直接用 class 语法:

    /* 工厂类 */
    class Factory {
        static getInstance(type) {
            switch (type) {
                case 'Product1':
                    return new Product1()
                case 'Product2':
                    return new Product2()
                default:
                    throw new Error('当前没有这个产品')
            }
        }
    }
    
    /* 产品类1 */
    class Product1 {
        constructor() { this.type = 'Product1' }
        
        operate() { console.log(this.type) }
    }
    
    /* 产品类2 */
    class Product2 {
        constructor() { this.type = 'Product2' }
        
        operate() { console.log(this.type) }
    }
    
    const prod1 = Factory.getInstance('Product1')
    prod1.operate()																	// 输出: Product1
    const prod2 = Factory.getInstance('Product3')		// 输出: Error 当前没有这个产品

注意,由于 JavaScript 的灵活,简单工厂模式返回的产品对象不一定非要是类实例,也可以是字面量形式的对象,所以读者可以根据场景灵活选择返回的产品对象形式。

4. 源码中的工厂模式

4.1 Vue/React 源码中的工厂模式

和原生的 document.createElement 类似,Vue 和 React 这种具有虚拟 DOM树(Virtual Dom Tree)机制的框架在生成虚拟 DOM 的时候,都提供了 createElement 方法用来生成 VNode,用来作为真实 DOM 节点的映射:

    // Vue
    createElement('h3', { class: 'main-title' }, [
        createElement('img', { class: 'avatar', attrs: { src: '../avatar.jpg' } }),
        createElement('p', { class: 'user-desc' }, '长得帅老的快,长得丑活得久')
    ])
    
    // React
    React.createElement('h3', { className: 'user-info' },
      React.createElement('img', { src: '../avatar.jpg', className: 'avatar' }),
      React.createElement('p', { className: 'user-desc' }, '长得帅老的快,长得丑活得久')
    )

createElement 函数结构大概如下:

    class Vnode (tag, data, children) { ... }
    
    function createElement(tag, data, children) {
      	return new Vnode(tag, data, children)
    }

可以看到 createElement 函数内会进行 VNode 的具体创建,创建的过程是很复杂的,而框架提供的 createElement 工厂方法封装了复杂的创建与验证过程,对于使用者来说就很方便了。

4.2 vue-router 源码中的工厂模式

工厂模式在源码中应用频繁,以 vue-router 中的源码为例,代码位置:vue-router/src/index.js

    // src/index.js
    export default class VueRouter {
        constructor(options) {
            this.mode = mode	// 路由模式
            
            switch (mode) {           // 简单工厂
                case 'history':       // history 方式
                    this.history = new HTML5History(this, options.base)
                    break
                case 'hash':          // hash 方式
                    this.history = new HashHistory(this, options.base, this.fallback)
                    break
                case 'abstract':      // abstract 方式
                    this.history = new AbstractHistory(this, options.base)
                    break
                default:
                    // ... 初始化失败报错
            }
        }
    }

稍微解释一下这里的源码。mode 是路由创建的模式,这里有三种 HistoryHashAbstract,前两种我们已经很熟悉了,HistoryH5 的路由方式,Hash 是路由中带

的路由方式,Abstract 代表非浏览器环境中路由方式,比如 Nodeweex 等;this.history

用来保存路由实例,vue-router 中使用了工厂模式的思想来获得响应路由控制类的实例。

  • 源码里没有把工厂方法的产品创建流程封装出来,而是直接将产品实例的创建流程暴露在 VueRouter 的构造函数中,在被 new 的时候创建对应产品实例,相当于 VueRouter 的构造函数就是一个工厂方法。
  • 如果一个系统不是 SPA (Single Page Application,单页应用),而是是 MPA(Multi Page Application,多页应用),那么就需要创建多个 VueRouter 的实例,此时 VueRouter 的构造函数也就是工厂方法将会被多次执行,以分别获得不同实例。

5. 工厂模式的优缺点

工厂模式将对象的创建和实现分离,这带来了优点:

  • 良好的封装,代码结构清晰,访问者无需知道对象的创建流程,特别是创建比较复杂的情况下;
  • 扩展性优良,通过工厂方法隔离了用户和创建流程隔离,符合开放封闭原则;
  • 解耦了高层逻辑和底层产品类,符合最少知识原则,不需要的就不要去交流;
  • 工厂模式的缺点:带来了额外的系统复杂度,增加了抽象性;

6. 工厂模式的使用场景

那么什么时候使用工厂模式呢:

  • 对象的创建比较复杂,而访问者无需知道创建的具体流程;
  • 处理大量具有相同属性的小对象;

什么时候不该用工厂模式:滥用只是增加了不必要的系统复杂度,过犹不及。

7. 其他相关模式

7.1 工厂模式与抽象工厂模式

这两个方式可以组合使用,具体联系与区别在抽象工厂模式中讨论。

7.2 工厂模式与模板方法模式

这两个模式看起来比较类似,不过主要区别是:

  • 工厂模式 主要关注产品实例的创建,对创建流程封闭起来;
  • 模板方法模式 主要专注的是为固定的算法骨架提供某些步骤的实现;
  • 这两个模式也可以组合一起来使用,比如在模板方法模式里面,使用工厂方法来创建模板方法需要的对象。

抽象工厂模式

工厂模式 (Factory Pattern),根据输入的不同返回不同类的实例,一般用来创建同一类对象。工厂方式的主要思想是将对象的创建与对象的实现分离。

  • 抽象工厂 (Abstract Factory):通过对类的工厂抽象使其业务用于对产品类簇的创建,而不是负责创建某一类产品的实例。关键在于使用抽象类制定了实例的结构,调用者直接面向实例的结构编程,从实例的具体实现中解耦。
  • 我们知道 JavaScript 并不是强面向对象语言,所以使用传统编译型语言比如 JAVA、C#、C++ 等实现的设计模式和 JavaScript 不太一样,比如 JavaScript 中没有原生的类和接口等(不过 ES6+ 渐渐提供类似的语法糖),我们可以用变通的方式来解决。最重要的是设计模式背后的核心思想,和它所要解决的问题。

1. 你曾见过的抽象工厂模式

还是使用上一节工厂模式中使用的饭店例子。

  • 你再次来到了小区的饭店,跟老板说来一份鱼香肉丝,来一份宫保鸡丁,来一份番茄鸡蛋汤,来一份排骨汤(今天可能比较想喝汤)。无论什么样的菜,还是什么样的汤,他们都具有同样的属性,比如菜都可以吃,汤都可以喝。所以我们不论拿到什么菜,都可以吃,而不论拿到什么汤,都可以喝。对于饭店也一样,这个饭店可以做菜做汤,另一个饭店也可以,那么这两个饭店就具有同样的功能结构。

  • 面的场景都是属于抽象工厂模式的例子。菜类属于抽象产品类,制定具体产品菜类所具备的属性,而饭店和之前的工厂模式一样,负责具体生产产品实例,访问者通过老板获取想拿的产品。只要我们点的是汤类,即使还没有被做出来,我们就知道是可以喝的。推广一下,饭店功能也可以被抽象(抽象饭店类),继承这个类的饭店实例都具有做菜和做汤的功能,这样也完成了抽象类对实例的结构约束。

  • 在类似场景中,这些例子有特点:只要实现了抽象类的实例,都实现了抽象类制定的结构;

2. 实例的代码实现

我们知道 JavaScript 并不强面向对象,也没有提供抽象类(至少目前没有提供),但是可以模拟抽象类。用对 new.target 来判断 new 的类,在父类方法中 throw new Error(),如果子类中没有实现这个方法就会抛错,这样来模拟抽象类:

    /* 抽象类,ES6 class 方式 */
    class AbstractClass1 {
        constructor() {
            if (new.target === AbstractClass1) {
                throw new Error('抽象类不能直接实例化!')
            }
        }
    
        /* 抽象方法 */
        operate() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 抽象类,ES5 构造函数方式 */
    var AbstractClass2 = function () {
        if (new.target === AbstractClass2) {
            throw new Error('抽象类不能直接实例化!')
        }
    }
    /* 抽象方法,使用原型方式添加 */
    AbstractClass2.prototype.operate = function(){ throw new Error('抽象方法不能调用!') }

下面用 JavaScript 将上面介绍的饭店例子实现一下。

首先使用原型方式:

    /* 饭店方法 */
    function Restaurant() {}
    
    Restaurant.orderDish = function(type) {
        switch (type) {
            case '鱼香肉丝':
                return new YuXiangRouSi()
            case '宫保鸡丁':
                return new GongBaoJiDing()
            case '紫菜蛋汤':
                return new ZiCaiDanTang()
            default:
                throw new Error('本店没有这个 -。-')
        }
    }
    
    /* 菜品抽象类 */
    function Dish() { this.kind = '菜' }
    
    /* 抽象方法 */
    Dish.prototype.eat = function() { throw new Error('抽象方法不能调用!') }
    
    /* 鱼香肉丝类 */
    function YuXiangRouSi() { this.type = '鱼香肉丝' }
    
    YuXiangRouSi.prototype = new Dish()
    
    YuXiangRouSi.prototype.eat = function() {
        console.log(this.kind + ' - ' + this.type + ' 真香~')
    }
    
    /* 宫保鸡丁类 */
    function GongBaoJiDing() { this.type = '宫保鸡丁' }
    
    GongBaoJiDing.prototype = new Dish()
    
    GongBaoJiDing.prototype.eat = function() {
        console.log(this.kind + ' - ' + this.type + ' 让我想起了外婆做的菜~')
    }
    
    const dish1 = Restaurant.orderDish('鱼香肉丝')
    dish1.eat()
    const dish2 = Restaurant.orderDish('红烧排骨')
    
    // 输出: 菜 - 鱼香肉丝 真香~
    // 输出: Error 本店没有这个 -。-
    使用 class 语法改写一下:
    
    /* 饭店方法 */
    class Restaurant {
        static orderDish(type) {
            switch (type) {
                case '鱼香肉丝':
                    return new YuXiangRouSi()
                case '宫保鸡丁':
                    return new GongBaoJiDin()
                default:
                    throw new Error('本店没有这个 -。-')
            }
        }
    }
    
    /* 菜品抽象类 */
    class Dish {
        constructor() {
            if (new.target === Dish) {
                throw new Error('抽象类不能直接实例化!')
            }
            this.kind = '菜'
        }
        
        /* 抽象方法 */
        eat() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 鱼香肉丝类 */
    class YuXiangRouSi extends Dish {
        constructor() {
            super()
            this.type = '鱼香肉丝'
        }
        
        eat() { console.log(this.kind + ' - ' + this.type + ' 真香~') }
    }
    
    /* 宫保鸡丁类 */
    class GongBaoJiDin extends Dish {
        constructor() {
            super()
            this.type = '宫保鸡丁'
        }
        
        eat() { console.log(this.kind + ' - ' + this.type + ' 让我想起了外婆做的菜~') }
    }
    
    const dish0 = new Dish()  										// 输出: Error 抽象方法不能调用!
    const dish1 = Restaurant.orderDish('鱼香肉丝')
    dish1.eat()																		// 输出: 菜 - 鱼香肉丝 真香~
    const dish2 = Restaurant.orderDish('红烧排骨') // 输出: Error 本店没有这个 -。-
  • 这里的 Dish 类就是抽象产品类,继承该类的子类需要实现它的方法 eat。
  • 上面的实现将产品的功能结构抽象出来成为抽象产品类。事实上我们还可以更进一步,将工厂类也使用抽象类约束一下,也就是抽象工厂类,比如这个饭店可以做菜和汤,另一个饭店也可以做菜和汤,存在共同的功能结构,就可以将共同结构作为抽象类抽象出来,实现如下:
    /* 饭店 抽象类,饭店都可以做菜和汤 */
    class AbstractRestaurant {
        constructor() {
            if (new.target === AbstractRestaurant)
                throw new Error('抽象类不能直接实例化!')
            this.signborad = '饭店'
        }
        
        /* 抽象方法:创建菜 */
        createDish() { throw new Error('抽象方法不能调用!') }
        
        /* 抽象方法:创建汤 */
        createSoup() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 具体饭店类 */
    class Restaurant extends AbstractRestaurant {
        constructor() { super() }
        
        createDish(type) {
            switch (type) {
                case '鱼香肉丝':
                    return new YuXiangRouSi()
                case '宫保鸡丁':
                    return new GongBaoJiDing()
                default:
                    throw new Error('本店没这个菜')
            }
        }
        
        createSoup(type) {
            switch (type) {
                case '紫菜蛋汤':
                    return new ZiCaiDanTang()
                default:
                    throw new Error('本店没这个汤')
            }
        }
    }
    
    /* 菜 抽象类,菜都有吃的功能 eat */
    class AbstractDish {
        constructor() {
            if (new.target === AbstractDish) {
                throw new Error('抽象类不能直接实例化!')
            }
            this.kind = '菜'
        }
        
        /* 抽象方法 */
        eat() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 菜 鱼香肉丝类 */
    class YuXiangRouSi extends AbstractDish {
        constructor() {
            super()
            this.type = '鱼香肉丝'
        }
        
        eat() { console.log(this.kind + ' - ' + this.type + ' 真香~') }
    }
    
    /* 菜 宫保鸡丁类 */
    class GongBaoJiDing extends AbstractDish {
        constructor() {
            super()
            this.type = '宫保鸡丁'
        }
        
        eat() { console.log(this.kind + ' - ' + this.type + ' 让我想起了外婆做的菜~') }
    }
    
    /* 汤 抽象类,汤都有喝的功能 drink */
    class AbstractSoup {
        constructor() {
            if (new.target === AbstractDish) {
                throw new Error('抽象类不能直接实例化!')
            }
            this.kind = '汤'
        }
        
        /* 抽象方法 */
        drink() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 汤 紫菜蛋汤类 */
    class ZiCaiDanTang extends AbstractSoup {
        constructor() {
            super()
            this.type = '紫菜蛋汤'
        }
        
        drink() { console.log(this.kind + ' - ' + this.type + ' 我从小喝到大~') }
    }
    
    
    const restaurant = new Restaurant()
    
    const soup1 = restaurant.createSoup('紫菜蛋汤')
    soup1.drink()																		// 输出: 汤 - 紫菜蛋汤 我从小喝到大~
    const dish1 = restaurant.createDish('鱼香肉丝')
    dish1.eat()																			// 输出: 菜 - 鱼香肉丝 真香~
    const dish2 = restaurant.createDish('红烧排骨')  // 输出: Error 本店没有这个 -。-

这样如果创建新的饭店,新的饭店继承这个抽象饭店类,那么也要实现抽象饭店类,这样就都具有抽象饭店类制定的结构。

3. 抽象工厂模式的通用实现

我们提炼一下抽象工厂模式,饭店还是工厂(Factory),菜品种类是抽象类(AbstractFactory),而实现抽象类的菜品是具体的产品(Product),通过工厂拿到实现了不同抽象类的产品,这些产品可以根据实现的抽象类被区分为类簇。主要有下面几个概念:

  • Factory :工厂,负责返回产品实例;
  • AbstractFactory :虚拟工厂,制定工厂实例的结构;
  • Product :产品,访问者从工厂中拿到的产品实例,实现抽象类;
  • AbstractProduct :产品抽象类,由具体产品实现,制定产品实例的结构;

概略图如下:

下面是通用的实现,原型方式略过:

    /* 工厂 抽象类 */
    class AbstractFactory {
        constructor() {
            if (new.target === AbstractFactory) 
                throw new Error('抽象类不能直接实例化!')
        }
        
        /* 抽象方法 */
        createProduct1() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 具体饭店类 */
    class Factory extends AbstractFactory {
        constructor() { super() }
        
        createProduct1(type) {
            switch (type) {
                case 'Product1':
                    return new Product1()
                case 'Product2':
                    return new Product2()
                default:
                    throw new Error('当前没有这个产品 -。-')
            }
        }
    }
    
    /* 抽象产品类 */
    class AbstractProduct {
        constructor() {
            if (new.target === AbstractProduct) 
                throw new Error('抽象类不能直接实例化!')
            this.kind = '抽象产品类1'
        }
        
        /* 抽象方法 */
        operate() { throw new Error('抽象方法不能调用!') }
    }
    
    /* 具体产品类1 */
    class Product1 extends AbstractProduct {
        constructor() {
            super()
            this.type = 'Product1'
        }
        
        operate() { console.log(this.kind + ' - ' + this.type) }
    }
    
    /* 具体产品类2 */
    class Product2 extends AbstractProduct {
        constructor() {
            super()
            this.type = 'Product2'
        }
        
        operate() { console.log(this.kind + ' - ' + this.type) }
    }
    
    
    const factory = new Factory()
    
    const prod1 = factory.createProduct1('Product1')
    prod1.operate()																		// 输出: 抽象产品类1 - Product1
    const prod2 = factory.createProduct1('Product3')	// 输出: Error 当前没有这个产品 -。-
  • 如果希望增加第二个类簇的产品,除了需要改一下对应工厂类之外,还需要增加一个抽象产品类,并在抽象产品类基础上扩展新的产品。
  • 我们在实际使用的时候不一定需要每个工厂都继承抽象工厂类,比如只有一个工厂的话我们可以直接使用工厂模式,在实战中灵活使用。

4. 抽象工厂模式的优缺点

抽象模式的优点:

抽象产品类将产品的结构抽象出来,访问者不需要知道产品的具体实现,只需要面向产品的结构编程即可,从产品的具体实现中解耦;

抽象模式的缺点:

  • 扩展新类簇的产品类比较困难,因为需要创建新的抽象产品类,并且还要修改工厂类,违反开闭原则;
  • 带来了系统复杂度,增加了新的类,和新的继承关系;

5. 抽象工厂模式的使用场景

如果一组实例都有相同的结构,那么就可以使用抽象工厂模式。

6. 其他相关模式 6.1 抽象工厂模式与工厂模式

工厂模式和抽象工厂模式的区别:

  • 工厂模式 主要关注单独的产品实例的创建;
  • 抽象工厂模式 主要关注产品类簇实例的创建,如果产品类簇只有一个产品,那么这时的抽象工厂模式就退化为工厂模式了;根据场景灵活使用即可。

建造者模式

建造者模式(Builder Pattern)又称生成器模式,分步构建一个复杂对象,并允许按步骤构造。同样的构建过程可以采用不同的表示,将一个复杂对象的构建层与其表示层分离。

  • 在工厂模式中,创建的结果都是一个完整的个体,我们对创建的过程并不关心,只需了解创建的结果。而在建造者模式中,我们关心的是对象的创建过程,因此我们通常将创建的复杂对象的模块化,使得被创建的对象的每一个子模块都可以得到高质量的复用,当然在灵活的 JavaScript 中我们可以有更灵活的实现。

1. 你曾见过的建造者模式

  • 假定我们需要建造一个车,车这个产品是由多个部件组成,车身、引擎、轮胎。汽车制造厂一般不会自己完成每个部件的制造,而是把部件的制造交给对应的汽车零部件制造商,自己只进行装配,最后生产出整车。整车的每个部件都是一个相对独立的个体,都具有自己的生产过程,多个部件经过一系列的组装共同组成了一个完整的车。
  • 类似的场景还有很多,比如生产一个笔记本电脑,由主板、显示器、壳子组成,每个部件都有自己独立的行为和功能,他们共同组成了一个笔记本电脑。笔记本电脑厂从部件制造商处获得制造完成的部件,再由自己完成组装,得到笔记本电脑这个完整的产品。

在这些场景中,有以下特点:

  • 整车制造厂(指挥者)无需知道零部件的生产过程,零部件的生产过程一般由零部件厂商(建造者)来完成;
  • 整车制造厂(指挥者)决定以怎样的装配方式来组装零部件,以得到最终的产品;

2. 实例的代码实现

我们可以使用 JavaScript 来将上面的装配汽车的例子实现一下。

    // 建造者,汽车部件厂家,提供具体零部件的生产
    function CarBuilder({ color = 'white', weight = 0 }) {
        this.color = color
        this.weight = weight
    }
    
    // 生产部件,轮胎
    CarBuilder.prototype.buildTyre = function(type) {
        switch (type) {
            case 'small':
                this.tyreType = '小号轮胎'
                this.tyreIntro = '正在使用小号轮胎'
                break
            case 'normal':
                this.tyreType = '中号轮胎'
                this.tyreIntro = '正在使用中号轮胎'
                break
            case 'big':
                this.tyreType = '大号轮胎'
                this.tyreIntro = '正在使用大号轮胎'
                break
        }
    }
    
    // 生产部件,发动机
    CarBuilder.prototype.buildEngine = function(type) {
        switch (type) {
            case 'small':
                this.engineType = '小马力发动机'
                this.engineIntro = '正在使用小马力发动机'
                break
            case 'normal':
                this.engineType = '中马力发动机'
                this.engineIntro = '正在使用中马力发动机'
                break
            case 'big':
                this.engineType = '大马力发动机'
                this.engineIntro = '正在使用大马力发动机'
                break
        }
    }
    
    /* 奔驰厂家,负责最终汽车产品的装配 */
    function benChiDirector(tyre, engine, param) {
        var _car = new CarBuilder(param)
        _car.buildTyre(tyre)
        _car.buildEngine(engine)
        return _car
    }
    
    // 获得产品实例
    var benchi1 = benChiDirector('small', 'big', { color: 'red', weight: '1600kg' })
    
    console.log(benchi1)
    
    // 输出:
    // {
    //   color: "red"
    //   weight: "1600kg"
    //   tyre: Tyre {tyreType: "小号轮胎", tyreIntro: "正在使用小号轮胎"}
    //   engine: Engine {engineType: "大马力发动机", engineIntro: "正在使用大马力发动机"}
    // }

如果访问者希望获得另一个型号的车,比如有「空调」功能的车,那么我们只需要给 CarBuilder 的原型 prototype 上增加一个空调部件的建造方法,然后再新建一个新的奔驰厂家指挥者方法。

也可以使用 ES6 的写法改造一下:

    // 建造者,汽车部件厂家,提供具体零部件的生产
    class CarBuilder {
        constructor({ color = 'white', weight = 0 }) {
            this.color = color
            this.weight = weight
        }
        
        /* 生产部件,轮胎 */
        buildTyre(type) {
            const tyre = {}
            switch (type) {
                case 'small':
                    tyre.tyreType = '小号轮胎'
                    tyre.tyreIntro = '正在使用小号轮胎'
                    break
                case 'normal':
                    tyre.tyreType = '中号轮胎'
                    tyre.tyreIntro = '正在使用中号轮胎'
                    break
                case 'big':
                    tyre.tyreType = '大号轮胎'
                    tyre.tyreIntro = '正在使用大号轮胎'
                    break
            }
            this.tyre = tyre
        }
        
        /* 生产部件,发动机 */
        buildEngine(type) {
            const engine = {}
            switch (type) {
                case 'small':
                    engine.engineType = '小马力发动机'
                    engine.engineIntro = '正在使用小马力发动机'
                    break
                case 'normal':
                    engine.engineType = '中马力发动机'
                    engine.engineIntro = '正在使用中马力发动机'
                    break
                case 'big':
                    engine.engineType = '大马力发动机'
                    engine.engineIntro = '正在使用大马力发动机'
                    break
            }
            this.engine = engine
        }
    }
    
    /* 指挥者,负责最终汽车产品的装配 */
    class BenChiDirector {
        constructor(tyre, engine, param) {
            const _car = new CarBuilder(param)
            _car.buildTyre(tyre)
            _car.buildEngine(engine)
            return _car
        }
    }
    
    // 获得产品实例
    const benchi1 = new BenChiDirector('small', 'big', { color: 'red', weight: '1600kg' })
    
    console.log(benchi1)
    
    // 输出:
    // {
    //   color: "red"
    //   weight: "1600kg"
    //   tyre: Tyre {tyreType: "小号轮胎", tyreIntro: "正在使用小号轮胎"}
    //   engine: Engine {engineType: "大马力发动机", engineIntro: "正在使用大马力发动机"}
    // }

作为灵活的 JavaScript,我们还可以使用链模式来完成部件的装配,对链模式还不熟悉的同学可以看一下后面有一篇单独介绍链模式的文章~

    // 建造者,汽车部件厂家
    class CarBuilder {
        constructor({ color = 'white', weight = '0' }) {
            this.color = color
            this.weight = weight
        }
        
        /* 生产部件,轮胎 */
        buildTyre(type) {
            const tyre = {}
            switch (type) {
                case 'small':
                    tyre.tyreType = '小号轮胎'
                    tyre.tyreIntro = '正在使用小号轮胎'
                    break
                case 'normal':
                    tyre.tyreType = '中号轮胎'
                    tyre.tyreIntro = '正在使用中号轮胎'
                    break
                case 'big':
                    tyre.tyreType = '大号轮胎'
                    tyre.tyreIntro = '正在使用大号轮胎'
                    break
            }
            this.tyre = tyre
            return this
        }
        
        /* 生产部件,发动机 */
        buildEngine(type) {
            const engine = {}
            switch (type) {
                case 'small':
                    engine.engineType = '小马力发动机'
                    engine.engineIntro = '正在使用小马力发动机'
                    break
                case 'normal':
                    engine.engineType = '中马力发动机'
                    engine.engineIntro = '正在使用中马力发动机'
                    break
                case 'big':
                    engine.engineType = '大马力发动机'
                    engine.engineIntro = '正在使用大马力发动机'
                    break
            }
            this.engine = engine
            return this
        }
    }
    
    // 汽车装配,获得产品实例
    const benchi1 = new CarBuilder({ color: 'red', weight: '1600kg' })
        .buildTyre('small')
        .buildEngine('big')
    
    console.log(benchi1)
    
    // 输出:
    // {
    //   color: "red"
    //   weight: "1600kg"
    //   tyre: Tyre {tyre: "小号轮胎", tyreIntro: "正在使用小号轮胎"}
    //   engine: Engine {engine: "大马力发动机", engineIntro: "正在使用大马力发动机"}
    // }

这样将最终产品的创建流程使用链模式来实现,相当于将指挥者退化,指挥的过程通过链模式让用户自己实现,这样既增加了灵活性,装配过程也一目了然。如果希望扩展产品的部件,那么在建造者上增加部件实现方法,再适当修改链模式即可。

3. 建造者模式的通用实现

我们提炼一下建造者模式,这里的生产汽车的奔驰厂家就相当于指挥者(Director),厂家负责将不同的部件组装成最后的产品(Product),而部件的生产者是部件厂家相当于建造者(Builder),我们通过指挥者就可以获得希望的复杂的产品对象,再通过访问不同指挥者获得装配方式不同的产品。主要有下面几个概念:

  • Director: 指挥者,调用建造者中的部件具体实现进行部件装配,相当于整车组装厂,最终返回装配完毕的产品;
  • Builder: 建造者,含有不同部件的生产方式给指挥者调用,是部件真正的生产者,但没有部件的装配流程;
  • Product: 产品,要返回给访问者的复杂对象;
  • 建造者模式的主要功能是构建复杂的产品,并且是复杂的、需要分步骤构建的产品,其构建的算法是统一的,构建的过程由指挥者决定,只要配置不同的指挥者,就可以构建出不同的复杂产品来。也就是说,建造者模式将产品装配的算法和具体部件的实现分离,这样构建的算法可以扩展和复用,部件的具体实现也可以方便地扩展和复用,从而可以灵活地通过组合来构建出不同的产品对象。

概略图如下:

下面是通用的实现。

首先使用 ES6 的 class 语法:

    // 建造者,部件生产
    class ProductBuilder {
        constructor(param) {
            this.param = param
        }
        
        /* 生产部件,part1 */
        buildPart1() {
            // ... Part1 生产过程
            this.part1 = 'part1'
            
        }
        
        /* 生产部件,part2 */
        buildPart2() {
            // ... Part2 生产过程
            this.part2 = 'part2'
        }
    }
    
    /* 指挥者,负责最终产品的装配 */
    class Director {
        constructor(param) {
            const _product = new ProductBuilder(param)
            _product.buildPart1()
            _product.buildPart2()
            return _product
        }
    }
    
    // 获得产品实例
    const product = new Director('param')
    结合链模式:
    
    // 建造者,汽车部件厂家
    class CarBuilder {
        constructor(param) {
            this.param = param
        }
        
        /* 生产部件,part1 */
        buildPart1() {
            this.part1 = 'part1'
            return this
        }
        
        /* 生产部件,part2 */
        buildPart2() {
            this.part2 = 'part2'
            return this
        }
    }
    
    // 汽车装配,获得产品实例
    const benchi1 = new CarBuilder('param')
        .buildPart1()
        .buildPart2()
  • 如果希望扩展实例的功能,那么只需要在建造者类的原型上增加一个实例方法,再返回 this 即可。
  • 值得一提的是,结合链模式的建造者模式中,装配复杂对象的链式装配过程就是指挥者 Director 角色,只不过在链式装配过程中不再封装在具体指挥者中,而是由使用者自己确定装配过程。

4. 实战中的建造者模式

4.1 重构一个具有很多参数的构造函数

有时候你会遇到一个参数很多的构造函数,比如:

    // 汽车建造者
    class CarBuilder {
        constructor(engine, weight, height, color, tyre, name, type) {
            this.engine = engine
            this.weight = weight
            this.height = height
            this.color = color
            this.tyre = tyre
            this.name = name
            this.type = type
        }
    }
    
    const benchi = new CarBuilder('大马力发动机', '2ton', 'white', '大号轮胎', '奔驰', 'AMG')

如果构造函数的参数多于 3 个,在使用的时候就很容易弄不清哪个参数对应的是什么含义,你可以使用对象解构赋值的方式来提高可读性和使用便利性,也可以使用建造者模式的思想来进行属性赋值,这是另一个思路。代码如下:

    // 汽车建造者
    class CarBuilder {
        constructor(engine, weight, height, color, tyre, name, type) {
            this.engine = engine
            this.weight = weight
            this.height = height
            this.color = color
            this.tyre = tyre
            this.name = name
            this.type = type
        }
        
        setCarProperty(key, value) {
            if (Object.getOwnPropertyNames(this).includes(key)) {
                this[key] = value
                return this
            }
            throw new Error(`Key error : ${ key } 不是本实例上的属性`)
        }
    }
    
    const benchi = new CarBuilder()
      .setCarProperty('engine', '大马力发动机')
      .setCarProperty('weight', '2ton')
      .setCarProperty('height', '2000mm')
      .setCarProperty('color', 'white')
      .setCarProperty('tyre', '大号轮胎')
      .setCarProperty('name', '奔驰')
      .setCarProperty('type', 'AMG')

每个键都是用一个同样的方法来设置,或许你觉得不太直观,我们可以将设置每个属性的操作都单独列为一个方法,这样可读性就更高了:

    // 汽车建造者
    class CarBuilder {
        constructor(engine, weight, height, color, tyre, name, type) {
            this.engine = engine
            this.weight = weight
            this.height = height
            this.color = color
            this.tyre = tyre
            this.name = name
            this.type = type
        }
        
        setPropertyFuncChain() {
            Object.getOwnPropertyNames(this)
              .forEach(key => {
                  const funcName = 'set' + key.replace(/^\w/g, str => str.toUpperCase())
                  this[funcName] = value => {
                      this[key] = value
                      return this
                  }
              })
            return this
        }
    }
    
    const benchi = new CarBuilder().setPropertyFuncChain()
      .setEngine('大马力发动机')
      .setWeight('2ton')
      .setHeight('2000mm')
      .setColor('white')
      .setTyre('大号轮胎')
      .setName('奔驰')
      .setType('AMG')

4.2 重构 React 的书写形式

  • 注意: 这个方式不一定推荐,只是用来开阔视野。
  • 当我们写一个 React 组件的时候,一般结构形式如下;
    class ContainerComponent extends Component {
      componentDidMount() {
        this.props.fetchThings()
      }
      render() {
        return <PresentationalComponent {...this.props}/>
      }
    }
    
    ContainerComponent.propTypes = {
      fetchThings: PropTypes.func.isRequired
    }
    
    const mapStateToProps = state => ({
      things: state.things
    })
    const mapDispatchToProps = dispatch => ({
      fetchThings: () => dispatch(fetchThings()),
      selectThing: id => dispatch(selectThing(id)),
      blowShitUp: () => dispatch(blowShitUp())
    })
    
    export default connect(
      mapStateToProps,
      mapDispatchToProps
    )(ContainerComponent)

通过建造者模式重构,我们可以将组件形式写成如下方式:

    export default ComponentBuilder('ContainerComponent')
      .render(props => <PresentationalComponent {...props}/>)
      .componentDidMount(props => props.fetchThings())
      .propTypes({
        fetchThings: PropTypes.func.isRequired
      })
      .mapStateToProps(state => ({
        things: state.things
      }))
      .mapDispatchToProps(dispatch => ({
        fetchThings: () => dispatch(fetchThings()),
        selectThing: id => dispatch(selectThing(id)),
        blowShitUp: () => dispatch(blowShitUp())
      }))
      .build()

5. 建造者模式的优缺点

建造者模式的优点:

  • 使用建造者模式可以使产品的构建流程和产品的表现分离,也就是将产品的创建算法和产品组成的实现隔离,访问者不必知道产品部件实现的细节;
  • 扩展方便,如果希望生产一个装配顺序或方式不同的新产品,那么直接新建一个指挥者即可,不用修改既有代码,符合开闭原则;
  • 更好的复用性,建造者模式将产品的创建算法和产品组成的实现分离,所以产品创建的算法可以复用,产品部件的实现也可以复用,带来很大的灵活性;

建造者模式的缺点:

  • 建造者模式一般适用于产品之间组成部件类似的情况,如果产品之间差异性很大、复用性不高,那么不要使用建造者模式;
  • 实例的创建增加了许多额外的结构,无疑增加了许多复杂度,如果对象粒度不大,那么我们最好直接创建对象;

6. 建造者模式的适用场景

  • 相同的方法,不同的执行顺序,产生不一样的产品时,可以采用建造者模式;
  • 产品的组成部件类似,通过组装不同的组件获得不同产品时,可以采用建造者模式;

7. 其他相关模式

7.1 建造者模式与工厂模式

  • 建造者模式和工厂模式最终都是创建一个完整的产品,但是在建造者模式中我们更关心对象创建的过程,将创建对象的方法模块化,从而更好地复用这些模块。
  • 当然建造者模式与工厂模式也是可以组合使用的,比如建造者中一般会提供不同的部件实现,那么这里就可以使用工厂模式来提供具体的部件对象,再通过指挥者来进行装配。

7.2 建造者模式与模版方法模式

  • 指挥者的实现可以和模版方法模式相结合。也就是说,指挥者中部件的装配过程,可以使用模版方法模式来固定装配算法,把部件实现方法分为模板方法和基本方法,进一步提取公共代码,扩展可变部分。
  • 是否采用模版方法模式看具体场景,如果产品的部件装配顺序很明确,但是具体的实现是未知的、灵活的,那么你可以适当考虑是否应该将算法骨架提取出来。

三、结构型模式

代理模式

代理模式 (Proxy Pattern)又称委托模式,它为目标对象创造了一个代理对象,以控制对目标对象的访问。

  • 代理模式把代理对象插入到访问者和目标对象之间,从而为访问者对目标对象的访问引入一定的间接性。正是这种间接性,给了代理对象很多操作空间,比如在调用目标对象前和调用后进行一些预操作和后操作,从而实现新的功能或者扩展目标的功能。

1. 你曾见过的代理模式

明星总是有个助理,或者说经纪人,如果某导演来请这个明星演出,或者某个品牌来找明星做广告,需要经纪人帮明星做接洽工作。而且经纪人也起到过滤的作用,毕竟明星也不是什么电影和广告都会接。类似的场景还有很多,再比如领导和秘书…(emmm)

  • 再看另一个例子。打官司是件非常麻烦的事,包括查找法律条文、起草法律文书、法庭辩论、签署法律文件、申请法院执行等等流程。此时,当事人就可聘请代理律师来完成整个打官司的所有事务。当事人只需与代理律师签订全权委托协议,那么整个打官司的过程,当事人都可以不用出现。法院的一些复杂事务都可以通过代理律师来完成,而法院需要当事人完成某些工作的时候,比如出庭,代理律师才会通知当事人,并为当事人出谋划策。

在类似的场景中,有以下特点:

  • 导演/法院(访问者)对明星/当事人(目标)的访问都是通过经纪人/律师(代理)来完成;
  • 经纪人/律师(代理)对访问有过滤的功能;

2. 实例的代码实现

我们使用 JavaScript 来将上面的明星例子实现一下。

    /* 明星 */
    var SuperStar = {
        name: '小鲜肉',
        playAdvertisement: function(ad) {
            console.log(ad)
        }
    }
    
    /* 经纪人 */
    var ProxyAssistant = {
        name: '经纪人张某',
        playAdvertisement: function(reward, ad) {
            if (reward > 1000000) {             // 如果报酬超过100w
                console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
                SuperStar.playAdvertisement(ad)
            } else
                console.log('没空,滚!')
        }
    }
    
    ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没空,滚

这里我们通过经纪人的方式来和明星取得联系,经纪人会视条件过滤一部分合作请求。

  • 我们可以升级一下,比如如果明星没有档期的话,可以通过经纪人安排档期,当明星有空的时候才让明星来拍广告。这里通过 Promise 的方式来实现档期的安排:
    /* 明星 */
    const SuperStar = {
        name: '小鲜肉',
        playAdvertisement(ad) {
            console.log(ad)
        }
    }
    
    /* 经纪人 */
    const ProxyAssistant = {
        name: '经纪人张某',
        scheduleTime() {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log('小鲜鲜有空了')
                    resolve()
                }, 2000)                        // 发现明星有空了
            })
        },
        playAdvertisement(reward, ad) {
            if (reward > 1000000) {             // 如果报酬超过100w
                console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
                ProxyAssistant.scheduleTime()   // 安排上了
                  .then(() => SuperStar.playAdvertisement(ad))
            } else
                console.log('没空,滚!')
        }
    }
    
    ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没空,滚
    
    ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
    // 2秒后
    // 输出: 小鲜鲜有空了
    // 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸

这里就简单实现了经纪人对请求的过滤,对明星档期的安排,实现了一个代理对象的基本功能。

3. 代理模式的概念

对于上面的例子,明星就相当于被代理的目标对象(Target),而经纪人就相当于代理对象(Proxy),希望找明星的人是访问者(Visitor),他们直接找不到明星,只能找明星的经纪人来进行业务商洽。主要有以下几个概念:

  • Target: 目标对象,也是被代理对象,是具体业务的实际执行者;
  • Proxy: 代理对象,负责引用目标对象,以及对访问的过滤和预处理;

概略图如下:

ES6 原生提供了 Proxy 构造函数,这个构造函数让我们可以很方便地创建代理对象:

    var proxy = new Proxy(target, handler);

参数中 target 是被代理对象,handler 用来设置代理行为。

这里使用 Proxy 来实现一下上面的经纪人例子:

    /* 明星 */
    const SuperStar = {
        name: '小鲜肉',
        scheduleFlag: false,            // 档期标识位,false-没空(默认值),true-有空
        playAdvertisement(ad) {
            console.log(ad)
        }
    }
    
    /* 经纪人 */
    const ProxyAssistant = {
        name: '经纪人张某',
        scheduleTime(ad) {
            const schedule = new Proxy(SuperStar, { 			// 在这里监听 scheduleFlag 值的变化
                set(obj, prop, val) {
                    if (prop !== 'scheduleFlag') return
                    if (obj.scheduleFlag === false &&
                      val === true) {                     // 小鲜肉现在有空了
                        obj.scheduleFlag = true
                        obj.playAdvertisement(ad)         // 安排上了
                    }
                }
            })
            
            setTimeout(() => {
                console.log('小鲜鲜有空了')
                schedule.scheduleFlag = true              // 明星有空了
            }, 2000)
        },
        playAdvertisement(reward, ad) {
            if (reward > 1000000) {             // 如果报酬超过100w
                console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
                ProxyAssistant.scheduleTime(ad)
            } else
                console.log('没空,滚!')
        }
    }
    
    ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没空,滚
    
    ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
    // 2秒后
    // 输出: 小鲜鲜有空了
    // 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸
ES6 之前,一般是使用 Object.defineProperty 来完成相同的功能,我们可以使用这个 API 改造一下:
    
    /* 明星 */
    const SuperStar = {
        name: '小鲜肉',
        scheduleFlagActually: false,            // 档期标识位,false-没空(默认值),true-有空
        playAdvertisement(ad) {
            console.log(ad)
        }
    }
    
    /* 经纪人 */
    const ProxyAssistant = {
        name: '经纪人张某',
        scheduleTime(ad) {
            Object.defineProperty(SuperStar, 'scheduleFlag', { 	// 在这里监听 scheduleFlag 值的变化
                get() {
                    return SuperStar.scheduleFlagActually
                },
                set(val) {
                    if (SuperStar.scheduleFlagActually === false &&
                      val === true) {                           // 小鲜肉现在有空了
                        SuperStar.scheduleFlagActually = true
                        SuperStar.playAdvertisement(ad)         // 安排上了
                    }
                }
            })
            
            setTimeout(() => {
                console.log('小鲜鲜有空了')
                SuperStar.scheduleFlag = true
            }, 2000)                            // 明星有空了
        },
        playAdvertisement(reward, ad) {
            if (reward > 1000000) {             // 如果报酬超过100w
                console.log('没问题,我们小鲜鲜最喜欢拍广告了!')
                ProxyAssistant.scheduleTime(ad)
            } else
                console.log('没空,滚!')
        }
    }
    
    ProxyAssistant.playAdvertisement(10000, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没空,滚
    
    ProxyAssistant.playAdvertisement(1000001, '纯蒸酸牛奶,味道纯纯,尽享纯蒸')
    // 输出: 没问题,我们小鲜鲜最喜欢拍广告了!
    // 2秒后
    // 输出: 小鲜鲜有空了
    // 输出: 纯蒸酸牛奶,味道纯纯,尽享纯蒸

4. 代理模式在实战中的应用 4.1 拦截器

上一小节使用代理模式代理对象的访问的方式,一般又被称为拦截器。

  • 拦截器的思想在实战中应用非常多,比如我们在项目中经常使用 Axios 的实例来进行 HTTP 的请求,使用拦截器 interceptor 可以提前对 request 请求和 response 返回进行一些预处理,比如:
  • request 请求头的设置,和 Cookie 信息的设置;
  • 权限信息的预处理,常见的比如验权操作或者 Token 验证;
  • 数据格式的格式化,比如对组件绑定的 Date 类型的数据在请求前进行一些格式约定好的序列化操作;
  • 空字段的格式预处理,根据后端进行一些过滤操作;
  • response 的一些通用报错处理,比如使用 Message 控件抛出错误;除了 HTTP 相关的拦截器之外,还有路由跳转的拦截器,可以进行一些路由跳转的预处理等操作。

4.2 前端框架的数据响应式化

  • 现在的很多前端框架或者状态管理框架都使用上面介绍的 Object.definePropertyProxy 来实现数据的响应式化,比如 VueMobxAvalonJS 等,Vue 2.xAvalonJS 使用前者,而 Vue 3.xMobx 5.x 使用后者。
  • Vue 2.x 中通过 Object.defineProperty 来劫持各个属性的 setter/getter,在数据变动时,通过发布-订阅模式发布消息给订阅者,触发相应的监听回调,从而实现数据的响应式化,也就是数据到视图的双向绑定。

为什么 Vue 2.x3.x 要从 Object.defineProperty 改用 Proxy 呢,是因为前者的一些局限性,导致的以下缺陷:

  • 无法监听利用索引直接设置数组的一个项,例如:vm.items[indexOfItem] = newValue;
  • 无法监听数组的长度的修改,例如:vm.items.length = newLength;
  • 无法监听 ES6SetWeakSetMapWeakMap 的变化;
  • 无法监听 Class 类型的数据;
  • 无法监听对象属性的新加或者删除;
  • 除此之外还有性能上的差异,基于这些原因,Vue 3.x 改用 Proxy 来实现数据监听了。当然缺点就是对 IE 用户的不友好,兼容性敏感的场景需要做一些取舍。

4.3 缓存代理

在高阶函数的文章中,就介绍了备忘模式,备忘模式就是使用缓存代理的思想,将复杂计算的结果缓存起来,下次传参一致时直接返回之前缓存的计算结果。

4.4 保护代理和虚拟代理

有的书籍中着重强调代理的两种形式:保护代理和虚拟代理:

  • 保护代理 :当一个对象可能会收到大量请求时,可以设置保护代理,通过一些条件判断对请求进行过滤;
  • 虚拟代理 :在程序中可以能有一些代价昂贵的操作,此时可以设置虚拟代理,虚拟代理会在适合的时候才执行操作。

保护代理其实就是对访问的过滤,之前的经纪人例子就属于这种类型。

而虚拟代理是为一个开销很大的操作先占位,之后再执行,比如:

一个很大的图片加载前,一般使用菊花图、低质量图片等提前占位,优化图片加载导致白屏的情况; 现在很流行的页面加载前使用骨架屏来提前占位,很多 WebAppNativeApp 都采用这种方式来优化用户白屏体验

4.5 正向代理与反向代理

还有个经常用的例子是反向代理(Reverse Proxy),反向代理对应的是正向代理(Forward Proxy),他们的区别是:

  • 正向代理: 一般的访问流程是客户端直接向目标服务器发送请求并获取内容,使用正向代理后,客户端改为向代理服务器发送请求,并指定目标服务器(原始服务器),然后由代理服务器和原始服务器通信,转交请求并获得的内容,再返回给客户端。正向代理隐藏了真实的客户端,为客户端收发请求,使真实客户端对服务器不可见;
  • 反向代理: 与一般访问流程相比,使用反向代理后,直接收到请求的服务器是代理服务器,然后将请求转发给内部网络上真正进行处理的服务器,得到的结果返回给客户端。反向代理隐藏了真实的服务器,为服务器收发请求,使真实服务器对客户端不可见。

反向代理一般在处理跨域请求的时候比较常用,属于服务端开发人员的日常操作了,另外在缓存服务器、负载均衡服务器等等场景也是使用到代理模式的思想。

5. 代理模式的优缺点

代理模式的主要优点有:

  • 代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;
  • 代理对象可以扩展目标对象的功能;
  • 代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开闭原则;
  • 代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式(十八线明星就别请经纪人了)

6. 其他相关模式

很多其他的模式,比如状态模式、策略模式、访问者模式其实也是使用了代理模式,包括在之前高阶函数处介绍的备忘模式,本质上也是一种缓存代理。

6.1 代理模式与适配器模式

代理模式和适配器模式都为另一个对象提供间接性的访问,他们的区别:

  • 适配器模式: 主要用来解决接口之间不匹配的问题,通常是为所适配的对象提供一个不同的接口;
  • 代理模式: 提供访问目标对象的间接访问,以及对目标对象功能的扩展,一般提供和目标对象一样的接口;

6.2 代理模式与装饰者模式

装饰者模式实现上和代理模式类似,都是在访问目标对象之前或者之后执行一些逻辑,但是目的和功能不同:

  • 装饰者模式: 目的是为了方便地给目标对象添加功能,也就是动态地添加功能;
  • 代理模式: 主要目的是控制其他访问者对目标对象的访问;

享元模式

享元模式 (Flyweight Pattern)运用共享技术来有效地支持大量细粒度对象的复用,以减少创建的对象的数量。

享元模式的主要思想是共享细粒度对象,也就是说如果系统中存在多个相同的对象,那么只需共享一份就可以了,不必每个都去实例化每一个对象,这样来精简内存资源,提升性能和效率。

Fly 意为苍蝇,Flyweight 指轻蝇量级,指代对象粒度很小。

1. 你曾见过的享元模式

我们去驾考的时候,如果给每个考试的人都准备一辆车,那考场就挤爆了,考点都堆不下考试车,因此驾考现场一般会有几辆车给要考试的人依次使用。如果考生人数少,就分别少准备几个自动档和手动档的驾考车,考生多的话就多准备几辆。如果考手动档的考生比较多,就多准备几辆手动档的驾考车。

我们去考四六级的时候(为什么这么多考试?😅),如果给每个考生都准备一个考场,怕是没那么多考场也没有这么多监考老师,因此现实中的大多数情况都是几十个考生共用一个考场。四级考试和六级考试一般同时进行,如果考生考的是四级,那么就安排四级考场,听四级的听力和试卷,六级同理。

生活中类似的场景还有很多,比如咖啡厅的咖啡口味,餐厅的菜品种类,拳击比赛的重量级等等。

在类似场景中,这些例子有以下特点:

  • 目标对象具有一些共同的状态,比如驾考考生考的是自动档还是手动档,四六级考生考的是四级还是六级;
  • 这些共同的状态所对应的对象,可以被共享出来;

2. 实例的代码实现

首先假设考生的 ID 为奇数则考的是手动档,为偶数则考的是自动档。如果给所有考生都 new 一个驾考车,那么这个系统中就会创建了和考生数量一致的驾考车对象:

    var candidateNum = 10   // 考生数量
    var examCarNum = 0      // 驾考车的数量
    
    /* 驾考车构造函数 */
    function ExamCar(carType) {
        examCarNum++
        this.carId = examCarNum
        this.carType = carType ? '手动档' : '自动档'
    }
    
    ExamCar.prototype.examine = function(candidateId) {
        console.log('考生- ' + candidateId + ' 在' + this.carType + '驾考车- ' + this.carId + ' 上考试')
    }
    
    for (var candidateId = 1; candidateId <= candidateNum; candidateId++) {
        var examCar = new ExamCar(candidateId % 2)
        examCar.examine(candidateId)
    }
    
    console.log('驾考车总数 - ' + examCarNum)
    // 输出: 驾考车总数 - 10

如果考生很多,那么系统中就会存在更多个驾考车对象实例,假如驾考车对象比较复杂,那么这些新建的驾考车实例就会占用大量内存。这时我们将同种类型的驾考车实例进行合并,手动档和自动档档驾考车分别引用同一个实例,就可以节约大量内存:

    var candidateNum = 10   // 考生数量
    var examCarNum = 0      // 驾考车的数量
    
    /* 驾考车构造函数 */
    function ExamCar(carType) {
        examCarNum++
        this.carId = examCarNum
        this.carType = carType ? '手动档' : '自动档'
    }
    
    ExamCar.prototype.examine = function(candidateId) {
        console.log('考生- ' + candidateId + ' 在' + this.carType + '驾考车- ' + this.carId + ' 上考试')
    }
    
    var manualExamCar = new ExamCar(true)
    var autoExamCar = new ExamCar(false)
    
    for (var candidateId = 1; candidateId <= candidateNum; candidateId++) {
        var examCar = candidateId % 2 ? manualExamCar : autoExamCar
        examCar.examine(candidateId)
    }
    
    console.log('驾考车总数 - ' + examCarNum)
    // 输出: 驾考车总数 - 2

可以看到我们使用 2 个驾考车实例就实现了刚刚 10 个驾考车实例实现的功能。这是仅有 10 个考生的情况,如果有几百上千考生,这时我们节约的内存就比较可观了,这就是享元模式要达到的目的。

3. 享元模式改进

  • 如果你阅读了之前文章关于继承部分的讲解,那么你实际上已经接触到享元模式的思想了。相比于构造函数窃取,在原型链继承和组合继承中,子类通过原型 prototype 来复用父类的方法和属性,如果子类实例每次都创建新的方法与属性,那么在子类实例很多的情况下,内存中就存在有很多重复的方法和属性,即使这些方法和属性完全一样,因此这部分内存完全可以通过复用来优化,这也是享元模式的思想。
  • 传统的享元模式是将目标对象的状态区分为内部状态和外部状态,内部状态相同的对象可以被共享出来指向同一个内部状态。正如之前举的驾考和四六级考试的例子中,自动档还是手动档、四级还是六级,就属于驾考考生、四六级考生中的内部状态,对应的驾考车、四六级考场就是可以被共享的对象。而考生的年龄、姓名、籍贯等就属于外部状态,一般没有被共享出来的价值。

主要的原理可以参看下面的示意图:

  • 享元模式的主要思想是细粒度对象的共享和复用,因此对之前的驾考例子,我们可以继续改进一下:
  • 如果某考生正在使用一辆驾考车,那么这辆驾考车的状态就是被占用,其他考生只能选择剩下未被占用状态的驾考车;
  • 如果某考生对驾考车的使用完毕,那么将驾考车开回考点,驾考车的状态改为未被占用,供给其他考生使用;
  • 如果所有驾考车都被占用,那么其他考生只能等待正在使用驾考车的考生使用完毕,直到有驾考车的状态变为未被占用;
  • 组织单位可以根据考生数量多准备几辆驾考车,比如手动档考生比较多,那么手动档驾考车就应该比自动档驾考车多准备几辆;
  • 我们可以简单实现一下,为了方便起见,这里就直接使用 ES6 的语法。
  • 首先创建 3 个手动档驾考车,然后注册 10 个考生参与考试,一开始肯定有 3 个考生同时上车,然后在某个考生考完之后其他考生接着后面考。为了实现这个过程,这里使用了 Promise,考试的考生在 0 到 2 秒后的随机时间考试完毕归还驾考车,其他考生在前面考生考完之后接着进行考试:
    let examCarNum = 0                  // 驾考车总数
    
    /* 驾考车对象 */
    class ExamCar {
        constructor(carType) {
            examCarNum++
            this.carId = examCarNum
            this.carType = carType ? '手动档' : '自动档'
            this.usingState = false    // 是否正在使用
        }
        
        /* 在本车上考试 */
        examine(candidateId) {
            return new Promise((resolve => {
                this.usingState = true
                console.log(`考生- ${ candidateId } 开始在${ this.carType }驾考车- ${ this.carId } 上考试`)
                setTimeout(() => {
                    this.usingState = false
                    console.log(`%c考生- ${ candidateId }${ this.carType }驾考车- ${ this.carId } 上考试完毕`, 'color:#f40')
                    resolve()                       // 0~2秒后考试完毕
                }, Math.random() * 2000)
            }))
        }
    }
    
    /* 手动档汽车对象池 */
    ManualExamCarPool = {
        _pool: [],                  // 驾考车对象池
        _candidateQueue: [],        // 考生队列
        
        /* 注册考生 ID 列表 */
        registCandidates(candidateList) {
            candidateList.forEach(candidateId => this.registCandidate(candidateId))
        },
        
        /* 注册手动档考生 */
        registCandidate(candidateId) {
            const examCar = this.getManualExamCar()    // 找一个未被占用的手动档驾考车
            if (examCar) {
                examCar.examine(candidateId)           // 开始考试,考完了让队列中的下一个考生开始考试
                  .then(() => {
                      const nextCandidateId = this._candidateQueue.length && this._candidateQueue.shift()
                      nextCandidateId && this.registCandidate(nextCandidateId)
                  })
            } else this._candidateQueue.push(candidateId)
        },
        
        /* 注册手动档车 */
        initManualExamCar(manualExamCarNum) {
            for (let i = 1; i <= manualExamCarNum; i++) {
                this._pool.push(new ExamCar(true))
            }
        },
        
        /* 获取状态为未被占用的手动档车 */
        getManualExamCar() {
            return this._pool.find(car => !car.usingState)
        }
    }
    
    ManualExamCarPool.initManualExamCar(3)          // 一共有3个驾考车
    ManualExamCarPool.registCandidates([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])  // 10个考生来考试

在浏览器中运行下试试:

可以看到一个驾考的过程被模拟出来了,这里只简单实现了手动档,自动档驾考场景同理,就不进行实现了。上面的实现还可以进一步优化,比如考生多的时候自动新建驾考车,考生少的时候逐渐减少驾考车,但又不能无限新建驾考车对象,这些情况读者可以自行发挥~

  • 如果可以将目标对象的内部状态和外部状态区分的比较明显,就可以将内部状态一致的对象很方便地共享出来,但是对 JavaScript 来说,我们并不一定要严格区分内部状态和外部状态才能进行资源共享,比如资源池模式。

4. 资源池

  • 面这种改进的模式一般叫做资源池(Resource Pool),或者叫对象池(Object Pool),可以当作是享元模式的升级版,实现不一样,但是目的相同。资源池一般维护一个装载对象的池子,封装有获取、释放资源的方法,当需要对象的时候直接从资源池中获取,使用完毕之后释放资源等待下次被获取。
  • 在上面的例子中,驾考车相当于有限资源,考生作为访问者根据资源的使用情况从资源池中获取资源,如果资源池中的资源都正在被占用,要么资源池创建新的资源,要么访问者等待占用的资源被释放。
  • 资源池在后端应用相当广泛,比如缓冲池、连接池、线程池、字符常量池等场景,前端使用场景不多,但是也有使用,比如有些频繁的 DOM 创建销毁操作,就可以引入对象池来节约一些 DOM 创建损耗。

下面介绍资源池的几种主要应用。

4.1 线程池

Node.js 中的线程池为例,Node.jsJavaScript 引擎是执行在单线程中的,启动的时候会新建 4 个线程放到线程池中,当遇到一些异步 I/O 操作(比如文件异步读写、DNS 查询等)或者一些 CPU 密集的操作(CryptoZlib 模块等)的时候,会在线程池中拿出一个线程去执行。如果有需要,线程池会按需创建新的线程。

线程池在整个 Node.js 事件循环中的位置可以参照下图:

上面这个图就是 Node.js 的事件循环(Event Loop)机制,简单解读一下(扩展视野,不一定需要懂):

  • 所有任务都在主线程上执行,形成执行栈(Execution Context Stack);
  • 主线程之外维护一个任务队列(Task Queue),接到请求时将请求作为一个任务放入这个队列中,然后继续接收其他请求;
  • 一旦执行栈中的任务执行完毕,主线程空闲时,主线程读取任务队列中的任务,检查队列中是否有要处理的事件,这时要分两种情况:如果是非 I/O 任务,就亲自处理,并通过回调函数返回到上层调用;如果是 I/O 任务,将传入的参数和回调函数封装成请求对象,并将这个请求对象推入线程池等待执行,主线程则读取下一个任务队列的任务,以此类推处理完任务队列中的任务;
  • 线程池当线程可用时,取出请求对象执行 I/O 操作,任务完成以后归还线程,并把这个完成的事件放到任务队列的尾部,等待事件循环,当主线程再次循环到该事件时,就直接处理并返回给上层调用;

4.2 缓存

根据二八原则,80% 的请求其实访问的是 20% 的资源,我们可以将频繁访问的资源缓存起来,如果用户访问被缓存起来的资源就直接返回缓存的版本,这就是 Web 开发中经常遇到的缓存。

缓存服务器就是缓存的最常见应用之一,也是复用资源的一种常用手段。缓存服务器的示意图如下:

  • 缓存服务器位于访问者与业务服务器之间,对业务服务器来说,减轻了压力,减小了负载,提高了数据查询的性能。对用户来说,提升了网页打开速度,优化了体验。
  • 缓存技术用的非常多,不仅仅用在缓存服务器上,浏览器本地也有缓存,查询的 DNS 也有缓存,包括我们的电脑 CPU 上,也有缓存硬件。

4.3 连接池

我们知道对数据库进行操作需要先创建一个数据库连接对象,然后通过创建好的数据库连接来对数据库进行 CRUD(增删改查)操作。如果访问量不大,对数据库的 CRUD 操作就不多,每次访问都创建连接并在使用完销毁连接就没什么,但是如果访问量比较多,并发的要求比较高时,频繁创建和销毁连接就比较消耗资源了。

  • 这时,可以不销毁连接,一直使用已创建的连接,就可以避免频繁创建销毁连接的损耗了。但是有个问题,一个连接同一时间只能做一件事,某使用者(一般是线程)正在使用时,其他使用者就不可以使用了,所以如果只创建一个不关闭的连接显然不符合要求,我们需要创建多个不关闭的连接。
  • 这就是连接池的来源,创建多个数据库连接,当有调用的时候直接在创建好的连接中拿出来使用,使用完毕之后将连接放回去供其他调用者使用。
  • 我们以 Node.jsmysql 模块的连接池应用为例,看看后端一般是如何使用数据库连接池的。在 Node.js 中使用 mysql 创建单个连接,一般这样使用:
    var mysql = require('mysql')
    
    var connection = mysql.createConnection({     // 创建数据库连接
        host: 'localhost',
        user: 'root',         // 用户名
        password: '123456',   // 密码
        database: 'db',       // 指定数据库
        port: '3306'          // 端口号
    })
    
    // 连接回调,在回调中增删改查
    connection.connect(...)
    
    // 关闭连接
    connection.end(...)
    在 Node.js 中使用 mysql 模块的连接池创建连接:
    
    var mysql = require('mysql')
    
    var pool = mysql.createPool({     // 创建数据库连接池
        host: 'localhost',
        user: 'root',         // 用户名
        password: '123456',   // 密码
        database: 'db',       // 制定数据库
        port: '3306'          // 端口号
    })
    
    // 从连接池中获取一个连接,进行增删改查
    pool.getConnection(function(err, connection) {
        // ... 数据库操作
        connection.release()  // 将连接释放回连接池中
    })
    
    // 关闭连接池
    pool.end()
  • 一般连接池在初始化的时候,都会自动打开 n 个连接,称为连接预热。如果这 n 个连接都被使用了,再从连接池中请求新的连接时,会动态地隐式创建额外连接,即自动扩容。如果扩容后的连接池一段时间后有不少连接没有被调用,则自动缩容,适当释放空闲连接,增加连接池中连接的使用效率。在连接失效的时候,自动抛弃无效连接。在系统关闭的时候,自动释放所有连接。为了维持连接池的有效运转和避免连接池无限扩容,还会给连接池设置最大最小连接数。
  • 这些都是连接池的功能,可以看到连接池一般可以根据当前使用情况自动地进行缩容和扩容,来进行连接池资源的最优化,和连接池连接的复用效率最大化。这些连接池的功能点,看着是不是和之前驾考例子的优化过程有点似曾相识呢~
  • 在实际项目中,除了数据库连接池外,还有 HTTP 连接池。使用 HTTP 连接池管理长连接可以复用 HTTP 连接,省去创建 TCP 连接的 3 次握手和关闭 TCP 连接的 4 次挥手的步骤,降低请求响应的时间。

连接池某种程度也算是一种缓冲池,只不过这种缓冲池是专门用来管理连接的。

4.4 字符常量池

很多语言的引擎为了减少字符串对象的重复创建,会在内存中维护有一个特殊的内存,这个内存就叫字符常量池。当创建新的字符串时,引擎会对这个字符串进行检查,与字符常量池中已有的字符串进行比对,如果存在有相同内容的字符串,就直接将引用返回,否则在字符常量池中创建新的字符常量,并返回引用。

似于 JavaC# 这些语言,都有字符常量池的机制。JavaScript 有多个引擎,以 Chrome 的 V8 引擎为例,V8 在把 JavaScript 编译成字节码过程中就引入了字符常量池这个优化手段,这就是为什么很多 JavaScript 的书籍都提到了 JavaScript 中的字符串具有不可变性,因为如果内存中的字符串可变,一个引用操作改变了字符串的值,那么其他同样的字符串也会受到影响。

可以引用《JavaScript 高级程序设计》中的话解释一下:

ECMAScript 中的字符串是不可变的,也就是说,字符串一旦创建,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后再用另一个包含新值的字符串填充该变量。

字符常量池也是复用资源的一种手段,只不过这种手段通常用在编译器的运行过程中,通常开发(搬砖)过程用不到,了解即可。

5. 享元模式的优缺点

享元模式的优点:

  • 由于减少了系统中的对象数量,提高了程序运行效率和性能,精简了内存占用,加快运行速度;
  • 外部状态相对独立,不会影响到内部状态,所以享元对象能够在不同的环境被共享;

享元模式的缺点:

  • 引入了共享对象,使对象结构变得复杂;
  • 共享对象的创建、销毁等需要维护,带来额外的复杂度(如果需要把共享对象维护起来的话);

6. 享元模式的适用场景

  • 如果一个程序中大量使用了相同或相似对象,那么可以考虑引入享元模式;
  • 如果使用了大量相同或相似对象,并造成了比较大的内存开销;
  • 对象的大多数状态可以被转变为外部状态;
  • 剥离出对象的外部状态后,可以使用相对较少的共享对象取代大量对象;
  • 在一些程序中,如果引入享元模式对系统的性能和内存的占用影响不大时,比如目标对象不多,或者场景比较简单,则不需要引入,以免适得其反。

7. 其他相关模式

  • 享元模式和单例模式、工厂模式、组合模式、策略模式、状态模式等等经常会一起使用。

7.1 享元模式和工厂模式、单例模式

  • 在区分出不同种类的外部状态后,创建新对象时需要选择不同种类的共享对象,这时就可以使用工厂模式来提供共享对象,在共享对象的维护上,经常会采用单例模式来提供单实例的共享对象。

7.2 享元模式和组合模式

  • 在使用工厂模式来提供共享对象时,比如某些时候共享对象中的某些状态就是对象不需要的,可以引入组合模式来提升自定义共享对象的自由度,对共享对象的组成部分进一步归类、分层,来实现更复杂的多层次对象结构,当然系统也会更难维护。

7.3 享元模式和策略模式

策略模式中的策略属于一系列功能单一、细粒度的细粒度对象,可以作为目标对象来考虑引入享元模式进行优化,但是前提是这些策略是会被频繁使用的,如果不经常使用,就没有必要了。

适配器模式

  • 适配器模式(Adapter Pattern)又称包装器模式,将一个类(对象)的接口(方法、属性)转化为用户需要的另一个接口,解决类(对象)之间接口不兼容的问题。
  • 主要功能是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,访问者需要的功能应该是已经实现好了的,不需要适配器模式来实现,适配器模式主要是负责把不兼容的接口转换成访问者期望的格式而已。

1. 你曾见过的适配器模式

  • 现实生活中我们会遇到形形色色的适配器,最常见的就是转接头了,比如不同规格电源接口的转接头、iPhone 手机的 3.5 毫米耳机插口转接头、DP/miniDP/HDMI/DVI/VGA 等视频转接头、电脑、手机、ipad 的电源适配器,都是属于适配器的范畴。
  • 还有一个比较典型的翻译官场景,比如老板张三去国外谈合作,带了个翻译官李四,那么李四就是作为讲不同语言的人之间交流的适配器 ?,老板张三的话的内容含义没有变化,翻译官将老板的话转换成国外客户希望的形式。

在类似场景中,这些例子有以下特点:

  • 旧有接口格式已经不满足现在的需要;
  • 通过增加适配器来更好地使用旧有接口;

2. 适配器模式的实现

我们可以实现一下电源适配器的例子,一开始我们使用的中国插头标准:

    var chinaPlug = {
        type: '中国插头',
        chinaInPlug() {
            console.log('开始供电')
        }
    }
    
    chinaPlug.chinaInPlug()
    // 输出:开始供电

但是我们出国旅游了,到了日本,需要增加一个日本插头到中国插头的电源适配器,来将我们原来的电源线用起来:

    var chinaPlug = {
        type: '中国插头',
        chinaInPlug() {
            console.log('开始供电')
        }
    }
    
    var japanPlug = {
        type: '日本插头',
        japanInPlug() {
            console.log('开始供电')
        }
    }
    
    /* 日本插头电源适配器 */
    function japanPlugAdapter(plug) {
        return {
            chinaInPlug() {
                return plug.japanInPlug()
            }
        }
    }
    
    japanPlugAdapter(japanPlug).chinaInPlug()
    // 输出:开始供电

由于适配器模式的例子太简单,如果希望看更多的实战相关应用,可以看下一个小节。

适配器模式的原理大概如下图:

访问者需要目标对象的某个功能,但是这个对象的接口不是自己期望的,那么通过适配器模式对现有对象的接口进行包装,来获得自己需要的接口格式。

3. 适配器模式在实战中的应用

适配器模式在日常开发中还是比较频繁的,其实可能你已经使用了,但却不知道原来这就是适配器模式啊。 ?

我们可以推而广之,适配器可以将新的软件实体适配到老的接口,也可以将老的软件实体适配到新的接口,具体如何来进行适配,可以根据具体使用场景来灵活使用。

3.1 jQuery.ajax 适配 Axios

有的使用 jQuery 的老项目使用 $.ajax 来发送请求,现在的新项目一般使用 Axios,那么现在有个老项目的代码中全是 $.ajax,如果你挨个修改,那么 bug 可能就跟地鼠一样到处冒出来让你焦头烂额,这时可以采用适配器模式来将老的使用形式适配到新的技术栈上:

    /* 适配器 */
    function ajax2AxiosAdapter(ajaxOptions) {
        return axios({
            url: ajaxOptions.url,
            method: ajaxOptions.type,
            responseType: ajaxOptions.dataType,
            data: ajaxOptions.data
        })
          .then(ajaxOptions.success)
          .catch(ajaxOptions.error)
    }
    
    /* 经过适配器包装 */
    $.ajax = function(options) {
        return ajax2AxiosAdapter(options)
    }
    
    $.ajax({
        url: '/demo-url',
        type: 'POST',
        dataType: 'json',
        data: {
            name: '张三',
            id: '2345'
        },
        success: function(data) {
            console.log('访问成功!')
        },
        error: function(err) {
            console.err('访问失败~')
        }
    })

可以看到老的代码表现形式依然不变,但是真正发送请求是通过新的发送方式来进行的。当然你也可以把 Axios 的请求适配到 $.ajax 上,就看你如何使用适配器了。

3.2 业务数据适配

  • 在实际项目中,我们经常会遇到树形数据结构和表形数据结构的转换,比如全国省市区结构、公司组织结构、军队编制结构等等。以公司组织结构为例,在历史代码中,后端给了公司组织结构的树形数据,在以后的业务迭代中,会增加一些要求非树形结构的场景。比如增加了将组织维护起来的功能,因此就需要在新增组织的时候选择上级组织,在某个下拉菜单中选择这个新增组织的上级菜单。或者增加了将人员归属到某一级组织的需求,需要在某个下拉菜单中选择任一级组织。
  • 在这些业务场景中,都需要将树形结构平铺开,但是我们又不能直接将旧有的树形结构状态进行修改,因为在项目别的地方已经使用了老的树形结构状态,这时我们可以引入适配器来将老的数据结构进行适配:
    /* 原来的树形结构 */
    const oldTreeData = [
        {
            name: '总部',
            place: '一楼',
            children: [
                { name: '财务部', place: '二楼' },
                { name: '生产部', place: '三楼' },
                {
                    name: '开发部', place: '三楼', children: [
                        {
                            name: '软件部', place: '四楼', children: [
                                { name: '后端部', place: '五楼' },
                                { name: '前端部', place: '七楼' },
                                { name: '技术支持部', place: '六楼' }]
                        }, {
                            name: '硬件部', place: '四楼', children: [
                                { name: 'DSP部', place: '八楼' },
                                { name: 'ARM部', place: '二楼' },
                                { name: '调试部', place: '三楼' }]
                        }]
                }
            ]
        }
    ]
    
    /* 树形结构平铺 */
    function treeDataAdapter(treeData, lastArrayData = []) {
        treeData.forEach(item => {
            if (item.children) {
                treeDataAdapter(item.children, lastArrayData)
            }
            const { name, place } = item
            lastArrayData.push({ name, place })
        })
        return lastArrayData
    }
    
    treeDataAdapter(oldTreeData)
    
    // 返回平铺的组织结构

增加适配器后,就可以将原先状态的树形结构转化为所需的结构,而并不改动原先的数据,也不对原来使用旧数据结构的代码有所影响。

3.3 Vue 计算属性

Vue 中的计算属性也是一个适配器模式的实例,以官网的例子为例,我们可以一起来理解一下:

    <template>
        <div id="example">
            <p>Original message: "{{ message }}"</p>  <!-- Hello -->
            <p>Computed reversed message: "{{ reversedMessage }}"</p>  <!-- olleH -->
        </div>
    </template>
    
    <script type='text/javascript'>
        export default {
            name: 'demo',
            data() {
                return {
                    message: 'Hello'
                }
            },
            computed: {
                reversedMessage: function() {
                    return this.message.split('').reverse().join('')
                }
            }
        }
    </script>

旧有 data 中的数据不满足当前的要求,通过计算属性的规则来适配成我们需要的格式,对原有数据并没有改变,只改变了原有数据的表现形式。

4. 源码中的适配器模式

Axios 是比较热门的网络请求库,在浏览器中使用的时候,Axios 的用来发送请求的 adapter 本质上是封装浏览器提供的 API XMLHttpRequest,我们可以看看源码中是如何封装这个 API 的,为了方便观看,进行了一些省略:

    module.exports = function xhrAdapter(config) {
        return new Promise(function dispatchXhrRequest(resolve, reject) {
            var requestData = config.data
            var requestHeaders = config.headers
            
            var request = new XMLHttpRequest()
            
            // 初始化一个请求
            request.open(config.method.toUpperCase(),
              buildURL(config.url, config.params, config.paramsSerializer), true)
            
            // 设置最大超时时间
            request.timeout = config.timeout
            
            // readyState 属性发生变化时的回调
            request.onreadystatechange = function handleLoad() { ... }
            
            // 浏览器请求退出时的回调
            request.onabort = function handleAbort() { ... }
            
            // 当请求报错时的回调
            request.onerror = function handleError() { ... }
            
            // 当请求超时调用的回调
            request.ontimeout = function handleTimeout() { ... }
            
            // 设置HTTP请求头的值
            if ('setRequestHeader' in request) {
                request.setRequestHeader(key, val)
            }
            
            // 跨域的请求是否应该使用证书
            if (config.withCredentials) {
                request.withCredentials = true
            }
            
            // 响应类型
            if (config.responseType) {
                request.responseType = config.responseType
            }
            
            // 发送请求
            request.send(requestData)
        })
    }

可以看到这个模块主要是对请求头、请求配置和一些回调的设置,并没有对原生的 API 有改动,所以也可以在其他地方正常使用。这个适配器可以看作是对 XMLHttpRequest 的适配,是用户对 Axios 调用层到原生 XMLHttpRequest 这个 API 之间的适配层。

源码可以参见 Github 仓库: axios/lib/adapters/xhr.js

5. 适配器模式的优缺点

适配器模式的优点:

  • 已有的功能如果只是接口不兼容,使用适配器适配已有功能,可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码;
  • 可扩展性良好,在实现适配器功能的时候,可以调用自己开发的功能,从而方便地扩展系统的功能;
  • 灵活性好,因为适配器并没有对原有对象的功能有所影响,如果不想使用适配器了,那么直接删掉即可,不会对使用原有对象的代码有影响;
  • 适配器模式的缺点:会让系统变得零乱,明明调用 A,却被适配到了 B,如果系统中这样的情况很多,那么对可阅读性不太友好。如果没必要使用适配器模式的话,可以考虑重构,如果使用的话,可以考虑尽量把文档完善。

6. 适配器模式的适用场景

  • 当你想用已有对象的功能,却想修改它的接口时,一般可以考虑一下是不是可以应用适配器模式。
  • 如果你想要使用一个已经存在的对象,但是它的接口不满足需求,那么可以使用适配器模式,把已有的实现转换成你需要的接口;
  • 如果你想创建一个可以复用的对象,而且确定需要和一些不兼容的对象一起工作,这种情况可以使用适配器模式,然后需要什么就适配什么;

7. 其他相关模式

适配器模式和代理模式、装饰者模式看起来比较类似,都是属于包装模式,也就是用一个对象来包装另一个对象的模式,他们之间的异同在代理模式中已经详细介绍了,这里再简单对比一下。

7.1 适配器模式与代理模式

  • 适配器模式: 提供一个不一样的接口,由于原来的接口格式不能用了,提供新的接口以满足新场景下的需求;
  • 代理模式: 提供一模一样的接口,由于不能直接访问目标对象,找个代理来帮忙访问,使用者可以就像访问目标对象一样来访问代理对象;

7.2 适配器模式、装饰者模式与代理模式

  • 适配器模式: 功能不变,只转换了原有接口访问格式;
  • 装饰者模式: 扩展功能,原有功能不变且可直接使用;
  • 代理模式: 原有功能不变,但一般是经过限制访问的;

装饰者模式

装饰者模式 (Decorator Pattern)又称装饰器模式,在不改变原对象的基础上,通过对其添加属性或方法来进行包装拓展,使得原有对象可以动态具有更多功能。

本质是功能动态组合,即动态地给一个对象添加额外的职责,就增加功能角度来看,使用装饰者模式比用继承更为灵活。好处是有效地把对象的核心职责和装饰功能区分开,并且通过动态增删装饰去除目标对象中重复的装饰逻辑。

1. 你曾见过的装饰者模式

  • 相信大家都有过房屋装修的经历,当毛坯房建好的时候,已经可以居住了,虽然不太舒适。一般我们自己住当然不会住毛坯,因此我们还会通水电、墙壁刷漆、铺地板、家具安装、电器安装等等步骤,让房屋渐渐具有各种各样的特性,比如墙壁刷漆和铺地板之后房屋变得更加美观,有了家具居住变得更加舒适,但这些额外的装修并没有影响房屋是用来居住的这个基本功能,这就是装饰的作用。
  • 再比如现在我们经常喝的奶茶,除了奶茶之外,还可以添加珍珠、波霸、椰果、仙草、香芋等等辅料,辅料的添加对奶茶的饮用并无影响,奶茶喝起来还是奶茶的味道,只不过辅料的添加让这杯奶茶的口感变得更多样化。
  • 生活中类似的场景还有很多,比如去咖啡厅喝咖啡,点了杯摩卡之后我们还可以选择添加糖、冰块、牛奶等等调味品,给咖啡添加特别的口感和风味,但这些调味品的添加并没有影响咖啡的基本性质,不会因为添加了调味品,咖啡就变成奶茶。

在类似场景中,这些例子有以下特点:

  • 装饰不影响原有的功能,原有功能可以照常使用;
  • 装饰可以增加多个,共同给目标对象添加额外功能;

2. 实例的代码实现

我们可以使用 JavaScript 来将装修房子的例子实现一下:

    /* 毛坯房 - 目标对象 */
    function OriginHouse() {}
    
    OriginHouse.prototype.getDesc = function() {
        console.log('毛坯房')
    }
    
    /* 搬入家具 - 装饰者 */
    function Furniture(house) {
        this.house = house
    }
    
    Furniture.prototype.getDesc = function() {
        this.house.getDesc()
        console.log('搬入家具')
    }
    
    /* 墙壁刷漆 - 装饰者 */
    function Painting(house) {
        this.house = house
    }
    
    Painting.prototype.getDesc = function() {
        this.house.getDesc()
        console.log('墙壁刷漆')
    }
    
    var house = new OriginHouse()
    house = new Furniture(house)
    house = new Painting(house)
    
    house.getDesc()
    // 输出: 毛坯房  搬入家具  墙壁刷漆
    使用 ES6 的 Class 语法:
    
    /* 毛坯房 - 目标对象 */
    class OriginHouse {
        getDesc() {
            console.log('毛坯房')
        }
    }
    
    /* 搬入家具 - 装饰者 */
    class Furniture {
        constructor(house) {
            this.house = house
        }
        
        getDesc() {
            this.house.getDesc()
            console.log('搬入家具')
        }
    }
    
    /* 墙壁刷漆 - 装饰者 */
    class Painting {
        constructor(house) {
            this.house = house
        }
        
        getDesc() {
            this.house.getDesc()
            console.log('墙壁刷漆')
        }
    }
    
    let house = new OriginHouse()
    house = new Furniture(house)
    house = new Painting(house)
    
    house.getDesc()
    // 输出: 毛坯房  搬入家具  墙壁刷漆

是不是感觉很麻烦,装饰个功能这么复杂?我们 JSer 大可不必走这一套面向对象花里胡哨的,毕竟 JavaScript 的优点就是灵活:

    /* 毛坯房 - 目标对象 */
    var originHouse = {
        getDesc() {
            console.log('毛坯房 ')
        }
    }
    
    /* 搬入家具 - 装饰者 */
    function furniture() {
        console.log('搬入家具 ')
    }
    
    /* 墙壁刷漆 - 装饰者 */
    function painting() {
        console.log('墙壁刷漆 ')
    }
    
    /* 添加装饰 - 搬入家具 */
    originHouse.getDesc = function() {
        var getDesc = originHouse.getDesc
        return function() {
            getDesc()
            furniture()
        }
    }()
    
    /* 添加装饰 - 墙壁刷漆 */
    originHouse.getDesc = function() {
        var getDesc = originHouse.getDesc
        return function() {
            getDesc()
            painting()
        }
    }()
    
    originHouse.getDesc()
    // 输出: 毛坯房  搬入家具  墙壁刷漆

简洁明了,且更符合前端日常使用的场景。

3. 装饰者模式的原理

装饰者模式的原理如下图:

可以从上图看出,在表现形式上,装饰者模式和适配器模式比较类似,都属于包装模式。在装饰者模式中,一个对象被另一个对象包装起来,形成一条包装链,并增加了原先对象的功能。

4. 实战中的装饰者模式 4.1 给浏览器事件添加新功能

之前介绍的添加装饰器函数的方式,经常被用来给原有浏览器或 DOM 绑定事件上绑定新的功能,比如在 onload 上增加新的事件,或在原来的事件绑定函数上增加新的功能,或者在原本的操作上增加用户行为埋点:

    window.onload = function() {
        console.log('原先的 onload 事件 ')
    }
    
    /* 发送埋点信息 */
    function sendUserOperation() {
        console.log('埋点:用户当前行为路径为 ...')
    }
    
    /* 将新的功能添加到 onload 事件上 */
    window.onload = function() {
        var originOnload = window.onload
        return function() {
            originOnload && originOnload()
            sendUserOperation()
        }
    }()
    
    // 输出: 原先的 onload 事件
    // 输出: 埋点:用户当前行为路径为 ...

可以看到通过添加装饰函数,为 onload 事件回调增加新的方法,且并不影响原本的功能,我们可以把上面的方法提取出来作为一个工具方法:

    window.onload = function() {
        console.log('原先的 onload 事件 ')
    }
    
    /* 发送埋点信息 */
    function sendUserOperation() {
        console.log('埋点:用户当前行为路径为 ...')
    }
    
    /* 给原生事件添加新的装饰方法 */
    function originDecorateFn(originObj, originKey, fn) {
        originObj[originKey] = function() {
            var originFn = originObj[originKey]
            return function() {
                originFn && originFn()
                fn()
            }
        }()
    }
    
    // 添加装饰功能
    originDecorateFn(window, 'onload', sendUserOperation)
    
    // 输出: 原先的 onload 事件
    // 输出: 埋点:用户当前行为路径为 ...

4.2 TypeScript 中的装饰器

  • 现在的越来越多的前端项目或 Node 项目都在拥抱 JavaScript 的超集语言 TypeScript,如果你了解过 C# 中的特性 AttributeJava 中的注解 AnnotationPython 中的装饰器 Decorator,那么你就不会对 TypeScript 中的装饰器感到陌生,下面我们简单介绍一下 TypeScript 中的装饰器。

TypeScript 中的装饰器可以被附加到类声明、方法、访问符、属性和参数上,装饰器的类型有参数装饰器、方法装饰器、访问器或参数装饰器、参数装饰器。

  • TypeScript 中的装饰器使用 @expression 这种形式,expression 求值后为一个函数,它在运行时被调用,被装饰的声明信息会被做为参数传入。

多个装饰器应用使用在同一个声明上时:

  • 由上至下依次对装饰器表达式求值;
  • 求值的结果会被当成函数,由下至上依次调用;

那么使用官网的一个例子:

    function f() {
        console.log("f(): evaluated");
        return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
            console.log("f(): called");
        }
    }
    
    function g() {
        console.log("g(): evaluated");
        return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
            console.log("g(): called");
        }
    }
    
    class C {
        @f()
        @g()
        method() {}
    }
    
    // f(): evaluated
    // g(): evaluated
    // g(): called
    // f(): called

可以看到上面的代码中,高阶函数 fg 返回了另一个函数(装饰器函数),所以 fg 这里又被称为装饰器工厂,即帮助用户传递可供装饰器使用的参数的工厂。另外注意,演算的顺序是从下到上,执行的时候是从下到上的。

再比如下面一个场景

    class Greeter {
        greeting: string;
        
        constructor(message: string) {
            this.greeting = message;
        }
        
        greet() {
            return "Hello, " + this.greeting;
        }
    }
    
    for (let key in new Greeter('Jim')) {
        console.log(key);
    }
    // 输出: greeting  greet

如果我们不希望 greetfor-in 循环遍历出来,可以通过装饰器的方式来方便地修改属性的属性描述符:

    function enumerable(value: boolean) {
        return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
            descriptor.enumerable = value;
        };
    }
    
    class Greeter {
        greeting: string;
        
        constructor(message: string) {
            this.greeting = message;
        }
        
        @enumerable(false)
        greet() {
            return "Hello, " + this.greeting;
        }
    }
    
    for (let key in new Greeter('Jim')) {
        console.log(key);
    }
    // 输出: greeting
  • 这样 greet 就变成不可枚举了,使用起来比较方便,对其他属性进行声明不可枚举的时候也只用在之前加一行 @enumerable(false) 即可,不用大费周章的 Object.defineProperty(...) 进行繁琐的声明了。
  • TypeScript 的装饰器还有很多有用的用法,感兴趣的同学可以阅读一下 TypeScriptDecorators 官网文档 相关内容。

5. 装饰者模式的优缺点

装饰者模式的优点:

  • 我们经常使用继承的方式来实现功能的扩展,但这样会给系统中带来很多的子类和复杂的继承关系,装饰者模式允许用户在不引起子类数量暴增的前提下动态地修饰对象,添加功能,装饰者和被装饰者之间松耦合,可维护性好;
  • 被装饰者可以使用装饰者动态地增加和撤销功能,可以在运行时选择不同的装饰器,实现不同的功能,灵活性好;
  • 装饰者模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,有利于装饰器功能的复用;
  • 可以通过选择不同的装饰者的组合,创造不同行为和功能的结合体,原有对象的代码无须改变,就可以使得原有对象的功能变得更强大和更多样化,符合开闭原则;

装饰者模式的缺点:

  • 使用装饰者模式时会产生很多细粒度的装饰者对象,这些装饰者对象由于接口和功能的多样化导致系统复杂度增加,功能越复杂,需要的细粒度对象越多;
  • 由于更大的灵活性,也就更容易出错,特别是对于多级装饰的场景,错误定位会更加繁琐;

6. 装饰者模式的适用场景

  • 如果不希望系统中增加很多子类,那么可以考虑使用装饰者模式;
  • 需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,这时采用装饰者模式可以很好实现;
  • 当对象的功能要求可以动态地添加,也可以动态地撤销,可以考虑使用装饰者模式;

7. 其他相关模式 7.1 装饰者模式与适配器模式

装饰者模式和适配器模式都是属于包装模式,然而他们的意图有些不一样:

  • 装饰者模式: 扩展功能,原有功能还可以直接使用,一般可以给目标对象多次叠加使用多个装饰者;
  • 适配器模式: 功能不变,但是转换了原有接口的访问格式,一般只给目标对象使用一次;

7.2 装饰者模式与组合模式

这两个模式有相似之处,都涉及到对象的递归调用,从某个角度来说,可以把装饰者模式看做是只有一个组件的组合模式。

  • 装饰者模式: 动态地给对象增加功能;
  • 组合模式: 管理组合对象和叶子对象,为它们提供一致的操作接口给客户端,方便客户端的使用;

7.3 装饰者模式与策略模式

装饰者模式和策略模式都包含有许多细粒度的功能模块,但是他们的使用思路不同:

  • 装饰者模式: 可以递归调用,使用多个功能模式,功能之间可以叠加组合使用;
  • 策略模式: 只有一层选择,选择某一个功能;

外观模式

外观模式 (Facade Pattern)又叫门面模式,定义一个将子系统的一组接口集成在一起的高层接口,以提供一个一致的外观。外观模式让外界减少与子系统内多个模块的直接交互,从而减少耦合,让外界可以更轻松地使用子系统。本质是封装交互,简化调用。

外观模式在源码中使用很多,具体可以参考后文中源码阅读部分。

1. 你曾见过的外观模式

最近这些年无人机很流行,特别是大疆的旋翼无人机。旋翼无人机的种类也很多,四旋翼、六旋翼、八旋翼、十六旋翼甚至是共轴双桨旋翼机,他们因为结构不同而各自有一套原理类似,但实现细节不同的旋翼控制方式。

  • 如果用户需要把每种旋翼的控制原理弄清楚,那么门槛就太高了,所以无人机厂商会把具体旋翼控制的细节封装起来,用户所要接触的只是手上的遥控器,无论什么类型的无人机,遥控器的控制方式都一样,前后左右上下和左转右转。
  • 对于使用者来说,遥控器就相当于是无人机系统的外观,使用者只要操纵遥控器就可以达到控制无人机的目的,而具体无人机内部的飞行控制器、电调(电子调速器)、电机、数字电传、陀螺仪、加速度计等等子模块之间复杂的调用关系将被封装起来,对于使用者而言不需要了解,因此也降低了使用难度。
  • 类似的例子也有不少,比如常见的空调、冰箱、洗衣机、洗碗机,内部结构都并不简单,对于我们使用者而言,理解他们内部的运行机制的门槛比较高,但是理解遥控器/控制面板上面寥寥几个按钮就相对容易的多,这就是外观模式的意义。

在类似场景中,这些例子有以下特点:

  • 一个统一的外观为复杂的子系统提供一个简单的高层功能接口;
  • 原本访问者直接调用子系统内部模块导致的复杂引用关系,现在可以通过只访问这个统一的外观来避免;

2. 实例的代码实现

无人机系统的模块图大概如下:

可以看到无人机系统还是比较复杂的,系统内模块众多,如果用户需要对每个模块的作用都了解的话,那就太麻烦了,有了遥控器之后,使用者只要操作摇杆,发出前进、后退等等的命令,无人机系统接受到信号之后会经过算法把计算后的指令发送到电调,控制对应电机以不同转速带动桨叶,给无人机提供所需的扭矩和升力,从而实现目标运动。

关于无人机的例子,因为子模块众多,写成代码有点太啰嗦,这里只给出一个简化版本的代码:

    var uav = {
        /* 电子调速器 */
        diantiao1: {
            up() {
                console.log('电调1发送指令:电机1增大转速')
                uav.dianji1.up()
            },
            down() {
                console.log('电调1发送指令:电机1减小转速')
                uav.dianji1.up()
            }
        },
        diantiao2: {
            up() {
                console.log('电调2发送指令:电机2增大转速')
                uav.dianji2.up()
            },
            down() {
                console.log('电调2发送指令:电机2减小转速')
                uav.dianji2.down()
            }
        },
        diantiao3: {
            up() {
                console.log('电调3发送指令:电机3增大转速')
                uav.dianji3.up()
            },
            down() {
                console.log('电调3发送指令:电机3减小转速')
                uav.dianji3.down()
            }
        },
        diantiao4: {
            up() {
                console.log('电调4发送指令:电机4增大转速')
                uav.dianji4.up()
            },
            down() {
                console.log('电调4发送指令:电机4减小转速')
                uav.dianji4.down()
            }
        },
        
        /* 电机 */
        dianji1: {
            up() { console.log('电机1增大转速') },
            down() { console.log('电机1减小转速') }
        },
        dianji2: {
            up() { console.log('电机2增大转速') },
            down() { console.log('电机2减小转速') }
        },
        dianji3: {
            up() { console.log('电机3增大转速') },
            down() { console.log('电机3减小转速') }
        },
        dianji4: {
            up() { console.log('电机4增大转速') },
            down() { console.log('电机4减小转速') }
        },
        
        /* 遥控器 */
        controller: {
            /* 上升 */
            up() {
                uav.diantiao1.up()
                uav.diantiao2.up()
                uav.diantiao3.up()
                uav.diantiao4.up()
            },
            
            /* 前进 */
            forward() {
                uav.diantiao1.down()
                uav.diantiao2.down()
                uav.diantiao3.up()
                uav.diantiao4.up()
            },
            
            /* 下降 */
            down() {
                uav.diantiao1.down()
                uav.diantiao2.down()
                uav.diantiao3.down()
                uav.diantiao4.down()
            },
            
            /* 左转 */
            left() {
                uav.diantiao1.up()
                uav.diantiao2.down()
                uav.diantiao3.up()
                uav.diantiao4.down()
            }
        }
    }
    
    /* 操纵无人机 */
    uav.controller.down()    // 发送下降指令
    uav.controller.left()    // 发送左转指令

无人机系统是比较复杂,但是可以看到无人机的操纵却比较简单,正是因为有遥控器这个外观的存在。

3. 外观模式的原理

  • 正如之前无人机的例子,虽然无人机实际操控比较复杂,但是通过对 controller 这个遥控器的使用,让使用者对无人机这个系统的控制变得简单,只需调用遥控器这个外观提供的方法即可,而这个方法里封装的一系列复杂操作,则不是我们要关注的重点。
  • 从中就可以理解外观模式的意义了,遥控器作为无人机系统的功能出口,降低了使用者对复杂的无人机系统使用的难度,甚至让广场上的小朋友都能玩起来了

概略图如下:

注意:外观模式一般是作为子系统的功能出口出现,使用的时候可以在其中增加新的功能,但是不推介这样做,因为外观应该是对已有功能的包装,不应在其中掺杂新的功能。

4. 实战中的外观模式 4.1 函数参数重载

有一种情况,比如某个函数有多个参数,其中一个参数可以传递也可以不传递,你当然可以直接弄两个接口,但是使用函数参数重载的方式,可以让使用者获得更大的自由度,让两个使用上基本类似的方法获得统一的外观。

    function domBindEvent(nodes, type, selector, fn) {
        if (fn === undefined) {
            fn = selector
            selector = null
        }
        // ... 剩下相关逻辑
    }
    
    domBindEvent(nodes, 'click', '#div1', fn)
    domBindEvent(nodes, 'click', fn)
  • 这种方式在一些工具库或者框架提供的多功能方法上经常得到使用,特别是在通用 API 的某些参数可传可不传的时候。
  • 参数重载之后的函数在使用上会获得更大的自由度,而不必重新创建一个新的 API,这在 VueReactjQueryLodash 等库中使用非常频繁。

4.2 抹平浏览器兼容性问题

外观模式经常被用于 JavaScript 的库中,封装一些接口用于兼容多浏览器,让我们可以间接调用我们封装的外观,从而屏蔽了浏览器差异,便于使用。

比如经常用的兼容不同浏览器的事件绑定方法:

    function addEvent(element, type, fn) {
        if (element.addEventListener) {      // 支持 DOM2 级事件处理方法的浏览器
            element.addEventListener(type, fn, false)
        } else if (element.attachEvent) {    // 不支持 DOM2 级但支持 attachEvent
            element.attachEvent('on' + type, fn)
        } else {
            element['on' + type] = fn        // 都不支持的浏览器
        }
    }
    
    var myInput = document.getElementById('myinput')
    
    addEvent(myInput, 'click', function() {
        console.log('绑定 click 事件')
    })
  • 下面一个小节我们可以看看 jQuery 的源码是如何进行事件绑定的。
  • 除了事件绑定之外,在抹平浏览器兼容性的其他问题上我们也经常使用外观模式:
    // 移除 DOM 上的事件
    function removeEvent(element, type, fn) {
        if (element.removeEventListener) {
            element.removeEventListener(type, fn, false)
        } else if (element.detachEvent) {
            element.detachEvent('on' + type, fn)
        } else {
            element['on' + type] = null
        }
    }
    
    // 获取样式
    function getStyle(obj, styleName) {
        if (window.getComputedStyle) {
            var styles = getComputedStyle(obj, null)[styleName]
        } else {
            var styles = obj.currentStyle[styleName]
        }
        return styles
    }
    
    // 阻止默认事件
    var preventDefault = function(event) {
        if (event.preventDefault) {
            event.preventDefault()
        } else {                    // IE 下
            event.returnValue = false
        }
    }
    
    // 阻止事件冒泡
    var cancelBubble = function(event) {
        if (event.stopPropagation) {
            event.stopPropagation()
        } else {                    // IE 下
            event.cancelBubble = true
        }
    }

通过将处理不同浏览器兼容性问题的过程封装成一个外观,我们在使用的时候可以直接使用外观方法即可,在遇到兼容性问题的时候,这个外观方法自然帮我们解决,方便又不容易出错。

5. 源码中的外观模式 5.1 Vue 源码中的函数参数重载

Vue 提供的一个创建元素的方法 createElement 就使用了函数参数重载,使得使用者在使用这个参数的时候很灵活:

    export function createElement(
      context,
      tag,
      data,
      children,
      normalizationType,
      alwaysNormalize
    ) {
        if (Array.isArray(data) || isPrimitive(data)) {     // 参数的重载
            normalizationType = children
            children = data
            data = undefined
        }
        
        // ...
    }
  • createElement 方法里面对第三个参数 data 进行了判断,如果第三个参数的类型是 arraystringnumberboolean 中的一种,那么说明是 createElement(tag [, data], children, ...) 这样的使用方式,用户传的第二个参数不是 data,而是 children
  • data 这个参数是包含模板相关属性的数据对象,如果用户没有什么要设置,那这个参数自然不传,不使用函数参数重载的情况下,需要用户手动传递 null 或者 undefined 之类,参数重载之后,用户对 data 这个参数可传可不传,使用自由度比较大,也很方便。
  • createElement 方法的源码参见 Github 链接 vue/src/core/vdom/create-element.js

5.2 Lodash 源码中的函数参数重载

Lodash 的 range 方法的 API 为 _.range([start=0], end, [step=1]),这就很明显使用了参数重载,这个方法调用了一个内部函数 createRange

    function createRange(fromRight) {
      return (start, end, step) => {
        // ...
        
        if (end === undefined) {
          end = start
          start = 0
        }
        
        // ...
      }
    }

意思就是,如果没有传第二个参数,那么就把传入的第一个参数作为 end,并把 start 置为默认值。

createRange 方法的源码参见 Github 链接 lodash/.internal/createRange.js

5.3 jQuery 源码中的函数参数重载

函数参数重载在源码中使用比较多,jQuery 中也有大量使用,比如 onoffbindoneloadajaxPrefilter 等方法,这里以 off 方法为例,该方法在选择元素上移除一个或多个事件的事件处理函数。源码如下:

    off: function (types, selector, fn) {
        // ...
      
        if (selector === false || typeof selector === 'function') {
            // ( types [, fn] ) 的使用方式
            fn = selector
            selector = undefined
        }
      
        // ...
    }

可以看到如果传入第二个参数为 false 或者是函数的时候,就是 off(types [, fn])的使用方式。

  • off 方法的源码参见 Github 链接 jquery/src/event.js

再比如 load 方法的源码:

    jQuery.fn.load = function(url, params, callback) {
        // ...
      
        if (isFunction(params)) {
            callback = params
            params = undefined
        }
      
        // ...
    }
  • 可以看到 jQuery 对第二个参数进行了判断,如果是函数,就是 load(url [, callback]) 的使用方式。
  • load 方法的源码参见 Github 链接 jquery/src/ajax/load.js

5.4 jQuery 源码中的外观模式

当我们使用 jQuery$(document).ready(...) 来给浏览器加载事件添加回调时,jQuery 会使用源码中的 bindReady 方法:

    bindReady: function() {
        // ...
        
        // Mozilla, Opera and webkit 支持
        if (document.addEventListener) {
            document.addEventListener('DOMContentLoaded', DOMContentLoaded, false)
            
            // A fallback to window.onload, that will always work
            window.addEventListener('load', jQuery.ready, false)
            
            // 如果使用了 IE 的事件绑定形式
        } else if (document.attachEvent) {
            document.attachEvent('onreadystatechange', DOMContentLoaded)
            
            // A fallback to window.onload, that will always work
            window.attachEvent('onload', jQuery.ready)
        }
        
        // ...
    }
  • 通过这个方法,jQuery 帮我们将不同浏览器下的不同绑定形式隐藏起来,从而简化了使用。
  • bindReady 方法的源码参见 Github 链接 jquery/src/core.js

除了屏蔽浏览器兼容性问题之外,jQuery 还有其他的一些其他外观模式的应用:

  • 比如修改 css 的时候可以 $('p').css('color', 'red') ,也可以 $('p').css('width', 100),对不同样式的操作被封装到同一个外观方法中,极大地方便了使用,对不同样式的特殊处理(比如设置 width 的时候不用加 px)也一同被封装了起来。
  • 源码参见 Github 链接 jquery/src/css.js
  • 再比如 jQueryajaxAPI $.ajax(url [, settings]),当我们在设置以 JSONP的形式发送请求的时候,只要传入 dataType: 'jsonp' 设置,jQuery会进行一些额外操作帮我们启动JSONP流程,并不需要使用者手动添加代码,这些都被封装在$.ajax()` 这个外观方法中了。

源码参见 Github 链接 jquery/src/ajax/jsonp.js

5.5 Axios 源码中的外观模式

Axios 可以使用在不同环境中,那么在不同环境中发送 HTTP 请求的时候会使用不同环境中的特有模块,Axios 这里是使用外观模式来解决这个问题的:

    function getDefaultAdapter() {
      // ...
    
      if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
        // Nodejs 中使用 HTTP adapter
        adapter = require('./adapters/http');
      } else if (typeof XMLHttpRequest !== 'undefined') {
        // 浏览器使用 XHR adapter
        adapter = require('./adapters/xhr');
      }
      
      // ...
    }

这个方法进行了一个判断,如果在 Nodejs 的环境中则使用 NodejsHTTP 模块来发送请求,在浏览器环境中则使用 XMLHTTPRequest 这个浏览器 API

getDefaultAdapter 方法源码参见 Github 链接 axios/lib/defaults.js

6. 外观模式的优缺点

外观模式的优点:

  • 访问者不需要再了解子系统内部模块的功能,而只需和外观交互即可,使得访问者对子系统的使用变得简单,符合最少知识原则,增强了可移植性和可读性;
  • 减少了与子系统模块的直接引用,实现了访问者与子系统中模块之间的松耦合,增加了可维护性和可扩展性;
  • 通过合理使用外观模式,可以帮助我们更好地划分系统访问层次,比如把需要暴露给外部的功能集中到外观中,这样既方便访问者使用,也很好地隐藏了内部的细节,提升了安全性;

外观模式的缺点:

  • 不符合开闭原则,对修改关闭,对扩展开放,如果外观模块出错,那么只能通过修改的方式来解决问题,因为外观模块是子系统的唯一出口;
  • 不需要或不合理的使用外观会让人迷惑,过犹不及;

7. 外观模式的适用场景

  • 维护设计粗糙和难以理解的遗留系统,或者系统非常复杂的时候,可以为这些系统设置外观模块,给外界提供清晰的接口,以后新系统只需与外观交互即可;
  • 你写了若干小模块,可以完成某个大功能,但日后常用的是大功能,可以使用外观来提供大功能,因为外界也不需要了解小模块的功能;
  • 团队协作时,可以给各自负责的模块建立合适的外观,以简化使用,节约沟通时间;
  • 如果构建多层系统,可以使用外观模式来将系统分层,让外观模块成为每层的入口,简化层间调用,松散层间耦合;

8. 其他相关模式 8.1 外观模式与中介者模式

  • 外观模式: 封装子使用者对子系统内模块的直接交互,方便使用者对子系统的调用;
  • 中介者模式: 封装子系统间各模块之间的直接交互,松散模块间的耦合;

8.2 外观模式与单例模式

有时候一个系统只需要一个外观,比如之前举的 AxiosHTTP 模块例子。这时我们可以将外观模式和单例模式可以一起使用,把外观实现为单例。

组合模式

组合模式 (Composite Pattern)又叫整体-部分模式,它允许你将对象组合成树形结构来表现整体- 部分层次结构,让使用者可以以一致的方式处理组合对象以及部分对象

1. 你曾见过的组合模式

大家电脑里的文件夹结构相比很熟悉了,文件夹下面可以有子文件夹,也可以有文件,子文件夹下面还可以有文件夹和文件,以此类推,共同组成了一个文件树,结构如下:

    Folder 1
    ├── Folder 2
    │   ├── File 1.txt
    │   ├── File 2.txt
    │   └── File 3.txt
    └── Folder 3
        ├── File 4.txt
        ├── File 5.txt
        └── File 6.txt

文件夹是树形结构的容器节点,容器节点可以继续包含其他容器节点,像树枝上还可以有其他树枝一样;也可以包含文件,不再增加新的层级,就像树的叶子一样处于末端,因此被称为叶节点。本文中,叶节点又称为叶对象,容器节点因为可以包含容器节点和非容器节点,又称为组合对象。

  • 类似这样的结构还有公司的组织层级,比如企业下面可以有部门,部门有部门员工和科室,科室可以有科室员工和团队,团队下面又可以有团队员工和组,依次类推,共同组成完整的企业。还有生活中的容器,比如柜子可以直接放东西,也可以放盆,盆里可以放东西也可以放碗,以此类推。甚至我们的家庭结构也属于这种结构,祖父家庭有父亲家庭、伯伯家庭、叔叔家庭、姑姑家庭等,父亲家庭又有哥哥家庭、弟弟家庭,这也是很典型的整体-部分层次的结构。
  • 当我们在某个文件夹下搜索某个文件的时候,通常我们希望搜索的结果包含组合对象的所有子孙对象;开家族会议的时候,开会的命令会被传达到家族中的每一个成员;领导希望我们 996 的时候,只要跟部门领导说一声,部门领导就会通知所有的员工来修福报,无论你是下属哪个组织的,都跑不掉…😅

在类似的场景中,有以下特点:

  • 结构呈整体-部分的树形关系,整体部分一般称为组合对象,组合对象下还可以有组合对象和叶对象;
  • 组合对象和叶对象有一致的接口和数据结构,以保证操作一致;
  • 请求从树的最顶端往下传递,如果当前处理请求的对象是叶对象,叶对象自身会对请求作出相应的处理;如果当前处理的是组合对象,则遍历其下的子节点(叶对象),将请求继续传递给这些子节点;

2. 实例的代码实现

我们可以使用 JavaScript 来将之前的文件夹例子实现一下。

在本地一个「电影」文件夹下有两个子文件夹「漫威英雄电影」和「DC英雄电影」,分别各自有一些电影文件,我们要做的就是在这个电影文件夹里找大于 2G 的电影文件,无论是在这个文件夹下还是在子文件夹下,并输出它的文件名和文件大小。

    /* 创建文件夹 */
    var createFolder = function(name) {
        return {
            name: name,
            _children: [],
            
            /* 在文件夹下增加文件或文件夹 */
            add(fileOrFolder) {
                this._children.push(fileOrFolder)
            },
            
            /* 扫描方法 */
            scan(cb) {
                this._children.forEach(function(child) {
                    child.scan(cb)
                })
            }
        }
    }
    
    /* 创建文件 */
    var createFile = function(name, size) {
        return {
            name: name,
            size: size,
            
            /* 在文件下增加文件,应报错 */
            add() {
                throw new Error('文件下面不能再添加文件')
            },
            
            /* 执行扫描方法 */
            scan(cb) {
                cb(this)
            }
        }
    }
    
    var foldMovies = createFolder('电影')
    
    // 创建子文件夹,并放入根文件夹
    var foldMarvelMovies = createFolder('漫威英雄电影')
    foldMovies.add(foldMarvelMovies)
    
    var foldDCMovies = createFolder('DC英雄电影')
    foldMovies.add(foldDCMovies)
    
    // 为两个子文件夹分别添加电影
    foldMarvelMovies.add(createFile('钢铁侠.mp4', 1.9))
    foldMarvelMovies.add(createFile('蜘蛛侠.mp4', 2.1))
    foldMarvelMovies.add(createFile('金刚狼.mp4', 2.3))
    foldMarvelMovies.add(createFile('黑寡妇.mp4', 1.9))
    foldMarvelMovies.add(createFile('美国队长.mp4', 1.4))
    
    foldDCMovies.add(createFile('蝙蝠侠.mp4', 2.4))
    foldDCMovies.add(createFile('超人.mp4', 1.6))
    
    console.log('size 大于2G的文件有:')
    foldMovies.scan(function(item) {
        if (item.size > 2) {
            console.log('name:' + item.name + ' size:' + item.size + 'GB')
        }
    })
    
    // size 大于2G的文件有:
    // name:蜘蛛侠.mp4 size:2.1GB
    // name:金刚狼.mp4 size:2.3GB
    // name:蝙蝠侠.mp4 size:2.4GB

作为灵活的 JavaScript,我们还可以使用链模式来进行改造一下,让我们添加子文件更加直观和方便。对链模式还不熟悉的同学可以看一下后面有一篇单独介绍链模式的文章~

    /* 创建文件夹 */
    const createFolder = function(name) {
        return {
            name: name,
            _children: [],
            
            /* 在文件夹下增加文件或文件夹  */
            add(...fileOrFolder) {
                this._children.push(...fileOrFolder)
                return this
            },
            
            /* 扫描方法 */
            scan(cb) {
                this._children.forEach(child => child.scan(cb))
            }
        }
    }
    
    /* 创建文件 */
    const createFile = function(name, size) {
        return {
            name: name,
            size: size,
          
            /* 在文件下增加文件,应报错 */
            add() {
                throw new Error('文件下面不能再添加文件')
            },
            
            /* 执行扫描方法 */
            scan(cb) {
                cb(this)
            }
        }
    }
    
    const foldMovies = createFolder('电影')
      .add(
        createFolder('漫威英雄电影')
          .add(createFile('钢铁侠.mp4', 1.9))
          .add(createFile('蜘蛛侠.mp4', 2.1))
          .add(createFile('金刚狼.mp4', 2.3))
          .add(createFile('黑寡妇.mp4', 1.9))
          .add(createFile('美国队长.mp4', 1.4)),
        createFolder('DC英雄电影')
          .add(createFile('蝙蝠侠.mp4', 2.4))
          .add(createFile('超人.mp4', 1.6))
      )
    
    console.log('size 大于2G的文件有:')
    
    foldMovies.scan(item => {
        if (item.size > 2) {
            console.log(`name:${ item.name } size:${ item.size }GB`)
        }
    })
    
    // size 大于2G的文件有:
    // name:蜘蛛侠.mp4 size:2.1GB
    // name:金刚狼.mp4 size:2.3GB
    // name:蝙蝠侠.mp4 size:2.4GB

上面的代码比较 JavaScript 特色,如果我们使用传统的类呢,也是可以实现的,下面使用 ES6 的 class 语法来改写一下:

    /* 文件夹类 */
    class Folder {
        constructor(name, children) {
            this.name = name
            this.children = children
        }
        
        /* 在文件夹下增加文件或文件夹 */
        add(...fileOrFolder) {
            this.children.push(...fileOrFolder)
            return this
        }
        
        /* 扫描方法 */
        scan(cb) {
            this.children.forEach(child => child.scan(cb))
        }
    }
    
    /* 文件类 */
    class File {
        constructor(name, size) {
            this.name = name
            this.size = size
        }
        
        /* 在文件下增加文件,应报错 */
        add(...fileOrFolder) {
            throw new Error('文件下面不能再添加文件')
        }
        
        /* 执行扫描方法 */
        scan(cb) {
            cb(this)
        }
    }
    
    const foldMovies = new Folder('电影', [
        new Folder('漫威英雄电影', [
            new File('钢铁侠.mp4', 1.9),
            new File('蜘蛛侠.mp4', 2.1),
            new File('金刚狼.mp4', 2.3),
            new File('黑寡妇.mp4', 1.9),
            new File('美国队长.mp4', 1.4)]),
        new Folder('DC英雄电影', [
            new File('蝙蝠侠.mp4', 2.4),
            new File('超人.mp4', 1.6)])
    ])
    
    console.log('size 大于2G的文件有:')
    
    foldMovies.scan(item => {
        if (item.size > 2) {
            console.log(`name:${ item.name } size:${ item.size }GB`)
        }
    })
    
    // size 大于2G的文件有:
    // name:蜘蛛侠.mp4 size:2.1GB
    // name:金刚狼.mp4 size:2.3GB
    // name:蝙蝠侠.mp4 size:2.4GB

在传统的语言中,为了保证叶对象和组合对象的外观一致,还会让他们实现同一个抽象类或接口。

3. 组合模式的概念

  • 组合模式定义的包含组合对象和叶对象的层次结构,叶对象可以被组合成更复杂的组合对象,而这个组合对象又可以被组合,这样不断地组合下去。
  • 在实际使用时,任何用到叶对象的地方都可以使用组合对象了。使用者可以不在意到底处理的节点是叶对象还是组合对象,也就不用写一些判断语句,让客户可以一致地使用组合结构的各节点,这就是所谓面向接口编程,从而减少耦合,便于扩展和维护。

组合模式的示意图如下:

4. 实战中的组合模式

类似于组合模式的结构其实我们经常碰到,比如浏览器的 DOM 树,从 <html/> 根节点到 <head/><body/><style/> 等节点,而 <body/> 节点又可以有 <div/><span/><p/><a/> 等等节点,这些节点下面还可以有节点,而且这些节点的操作方式有的也比较类似。

我们可以借用上面示例代码的例子,方便地创建一个 DOM 树,由于浏览器 API 的返回值不太友好,因此我们稍微改造一下;

    const createElement = ({ tag, attr, children }) => {
        const node = tag
          ? document.createElement(tag)
          : document.createTextNode(attr.text)
        tag && Object.keys(attr)
          .forEach(key => node.setAttribute(key, attr[key]))
        children && children
          .forEach(child =>
            node.appendChild(createElement.call(null, child)))
        return node
    }
    
    const ulElement = createElement({
        tag: 'ul',
        attr: { id: 'data-list' },
        children: [
            {
                tag: 'li',
                attr: { class: 'data-item' },
                children: [{ attr: { text: 'li-item 1' } }]
            },
            {
                tag: 'li',
                attr: { class: 'data-item' },
                children: [{ attr: { text: 'li-item 2' } }]
            },
            {
                tag: 'li',
                attr: { class: 'data-item' },
                children: [{ attr: { text: 'li-item 3' } }]
            }
        ]
    })
    
    // 输出:
    // <ul id='data-list'>
    //     <li class='data-item'>li-item 1</li>
    //     <li class='data-item'>li-item 2</li>
    //     <li class='data-item'>li-item 3</li>
    // </ul>

另外,之前的代码中添加文件的方式是不是很眼熟 😏,Vue/React 里创建元素节点的方法 createElement 也是类似这样使用,来组装元素节点:

    // Vue
    createElement('h3', { class: 'main-title' }, [
        createElement('img', { class: 'avatar', attrs: { src: '../avatar.jpg' } }),
        createElement('p', { class: 'user-desc' }, '长得帅老的快,长得丑活得久')
    ])
    
    // React
    React.createElement('h3', { className: 'user-info' },
      React.createElement('img', { src: '../avatar.jpg', className: 'avatar' }),
      React.createElement('p', { className: 'user-desc' }, '长得帅老的快,长得丑活得久')
    )

类似的,Vue 中的虚拟 DOM 树,也是这样的结构:

    {
        tagName: 'ul',   // 节点标签名
        props: {         // 属性
            id: 'data-list'
        },
        children: [      // 节点的子节点
            {
                tagName: 'li',
                props: { class: 'data-item' },
                children: ['li-item 1']
            },
            {
                tagName: 'li',
                props: { class: 'data-item' },
                children: ['li-item 2']
            }, {
                tagName: 'li',
                props: { class: 'data-item' },
                children: ['li-item 3']
            }]
    }

这样的虚拟 DOM 树,会被渲染成:

    <ul id='data-list'>
        <li class='data-item'>li-item 1</li>
        <li class='data-item'>li-item 2</li>
        <li class='data-item'>li-item 3</li>
    </ul>
  • 虚拟 DOM 树中的每个虚拟 DOM 都是 VNode 类的实例,因此具有基本统一的外观,在操作时对父节点和子节点的操作是一致的,这也是组合模式的思想。
  • 浏览器的 DOM 树、Vue 的虚拟 DOM 树等可以说和组织模式形似,也就是具有整体-部分的层次结构,但是在操作传递方面,没有组合模式所定义的特性。
  • 这个特性就是职责链模式的特性,组合模式天生具有职责链,当请求组合模式中的组合对象时,请求会顺着父节点往子节点传递,直到遇到可以处理这个请求的节点,也就是叶节点。

5. 组合模式的优缺点 组合模式的优点:

  • 由于组合对象和叶对象具有同样的接口,因此调用的是组合对象还是叶对象对使用者来说没有区别,使得使用者面向接口编程;
  • 如果想在组合模式的树中增加一个节点比较容易,在目标组合对象中添加即可,不会影响到其他对象,对扩展友好,符合开闭原则,利于维护;

组合模式的缺点:

  • 增加了系统复杂度,如果树中对象不多,则不一定需要使用;
  • 如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起;

6. 组合模式的适用场景

  • 如果对象组织呈树形结构就可以考虑使用组合模式,特别是如果操作树中对象的方法比较类似时;
  • 使用者希望统一对待树形结构中的对象,比如用户不想写一堆 if-else 来处理树中的节点时,可以使用组合模式;

7. 其他相关模式 7.1 组合模式和职责链模式

正如前文所说,组合模式是天生实现了职责链模式的。

  • 组合模式: 请求在组合对象上传递,被深度遍历到组合对象的所有子孙叶节点具体执行;
  • 职责链模式: 实现请求的发送者和接受者之间的解耦,把多个接受者组合起来形成职责链,请求在链上传递,直到有接受者处理请求为止;

7.2 组合模式和迭代器模式

组合模式可以结合迭代器模式一起使用,在遍历组合对象的叶节点的时候,可以使用迭代器模式来遍历。

7.3 组合模式和命令模式

命令模式里有一个用法「宏命令」,宏命令就是组合模式和命令模式一起使用的结果,是组合模式组装而成

桥接模式

  • 桥接模式(Bridge Pattern)又称桥梁模式,将抽象部分与它的实现部分分离,使它们都可以独立地变化。使用组合关系代替继承关系,降低抽象和实现两个可变维度的耦合度。
  • 抽象部分和实现部分可能不太好理解,举个例子,香蕉、苹果、西瓜,它们共同的抽象部分就是水果,可以吃,实现部分就是不同的水果实体。再比如黑色手提包、红色钱包、蓝色公文包,它们共同的抽象部分是包和颜色,这部分的共性就可以被作为抽象提取出来。

1. 你曾见过的桥接模式

厂家在生产洗衣机、冰箱、空调等电器的时候,不同型号产品之间有一些部件,比如变频洗衣机:

  • 产品型号 A 有小功率电机、直立滚筒、小功率变频器;
  • 产品型号 B 有中功率电机、横置滚筒、中功率变频器;
  • 产品型号 C 有大功率电机、横置滚筒、大功率变频器;

洗衣机产品由这三个部分组成,那么可以提取电机、滚筒、变频器部件作为抽象维度,在新建洗衣机实例的时候,把抽象出来的部件桥接起来组成一个完整的洗衣机实例。在变频洗衣机系列产品中,产品的部件可以沿着各自维度独立地变化。

再比如皮包,包的种类比如钱包、书包、公文包是一个维度,包的尺寸是一个维度,包的颜色又是一个维度,这些维度可以自由变化。这种情况在系统设计中,如果给每个种类对应的每个尺寸和颜色都设置一个类,那么系统中的类就会很多,如果根据实际需要对种类、尺寸、颜色这些维度进行组合,那么将大大减少系统中类的个数。

在类似场景中,这些例子有以下特点:

  • 将抽象和实现分离,互相独立互不影响;
  • 产品有多个维度(部件),每个维度都可以独立变化(实例化过程),洗衣机这个例子的维度就是电机、滚筒、变频器,洗衣机- 产品在这几个维度可以独立地进行变化,从而组装成不同的洗衣机产品;

2. 实例的代码实现

我们可以使用 JavaScript 来将之前的变频洗衣机例子实现一下。

    /* 组装洗衣机 */
    function Washer(motorType, rollerType, transducerType) {
        this.motor = new Motor(motorType)
        this.roller = new Roller(rollerType)
        this.transducer = new Transducer(transducerType)
    }
    
    Washer.prototype.work = function() {
        this.motor.run()
        this.roller.run()
        this.transducer.run()
    }
    
    /* 电机 */
    function Motor(type) {
        this.motorType = type + '电机'
    }
    
    Motor.prototype.run = function() {
        console.log(this.motorType + '开始工作')
    }
    
    /* 滚筒 */
    function Roller(type) {
        this.rollerType = type + '滚筒'
    }
    
    Roller.prototype.run = function() {
        console.log(this.rollerType + '开始工作')
    }
    
    /* 变频器 */
    function Transducer(type) {
        this.transducerType = type + '变频器'
    }
    
    Transducer.prototype.run = function() {
        console.log(this.transducerType + '开始工作')
    }
    
    // 新建洗衣机
    var washerA = new Washer('小功率', '直立', '小功率')
    washerA.work()
    
    // 输出:小功率电机开始工作
    //      直立滚筒开始工作
    //      小功率变频器开始工作
    由于产品部件可以独立变化,所以创建新的洗衣机产品就非常容易:
    
    var washerD = new Washer('小功率', '直立', '中功率')
    washerD.work()
    
    // 输出:小功率电机开始工作
    //      直立滚筒开始工作
    //      中功率变频器开始工作

可以看到由于洗衣机的结构被分别抽象为几个部件的组合,部件的实例化是在部件类各自的构造函数中完成,因此部件之间的实例化不会相互影响,新产品的创建也变得容易,这就是桥接模式的好处。

下面我们用 ES6 的 Class 语法实现一下:

    /* 组装洗衣机 */
    class Washer {
        constructor(motorType, rollerType, transducerType) {
            this.motor = new Motor(motorType)
            this.roller = new Roller(rollerType)
            this.transducer = new Transducer(transducerType)
        }
        
        /* 开始使用 */
        work() {
            this.motor.run()
            this.roller.run()
            this.transducer.run()
        }
    }
    
    /* 电机 */
    class Motor {
        constructor(type) {
            this.motorType = type + '电机'
        }
        
        run() {
            console.log(this.motorType + '开始工作')
        }
    }
    
    /* 滚筒 */
    class Roller {
        constructor(type) {
            this.rollerType = type + '滚筒'
        }
        
        run() {
            console.log(this.rollerType + '开始工作')
        }
    }
    
    /* 变频器 */
    class Transducer {
        constructor(type) {
            this.transducerType = type + '变频器'
        }
        
        run() {
            console.log(this.transducerType + '开始工作')
        }
    }
    
    const washerA = new Washer('小功率', '直立', '小功率')
    washerA.work()
    
    // 输出:小功率电机开始工作
    //      直立滚筒开始工作
    //      小功率变频器开始工作
  • 如果再精致一点,可以让电机、滚筒、变频器等部件实例继承自各自的抽象类,将面向抽象进行到底,但是桥接模式在 JavaScript 中应用不多,适当了解即可,不用太死扣。
  • 有时候为了更复用部件,可以将部件的实例化拿出来,对于洗衣机来说一个实体部件当然不能用两次,这里使用皮包的例子:
    /* 皮包 */
    class Bag {
        constructor(type, color) {
            this.type = type
            this.color = color
        }
        
        /* 展示 */
        show() {
            console.log(
              this.color.show() + this.type.show()
            )
        }
    }
    
    /* 皮包类型 */
    class Type {
        constructor(type) {
            this.typeType = type
        }
        
        show() {
            return this.typeType
        }
    }
    
    /* 皮包颜色 */
    class Color {
        constructor(type) {
            this.colorType = type
        }
        
        show() {
            return this.colorType
        }
    }
    
    
    /* 抽象实例化 */
    const redColor = new Color('红色')
    const walletType = new Type('钱包')
    const briefcaseType = new Type('公文包')
    
    const bagA = new Bag(walletType, redColor)
    bagA.show()
    
    // 输出:红色钱包
    
    const bagB = new Bag(briefcaseType, redColor)
    bagB.show()
    
    // 输出:红色公文包

3. 桥接模式的原理

我们可以提炼一下桥接模式,洗衣机是产品(Product),电机、滚筒、变频器属于抽象出来的部件种类(Components),也属于独立的维度,而具体的部件实体小功率电机、直立滚筒、大功率变频器等属于部件实例(Instances),这些实例可以沿着各自维度变化,共同组成对应产品。主要有以下几个概念:

  • Product: 产品,由多个独立部件组成的产品;
  • Component: 部件,组成产品的部件类;
  • Instance: 部件类的实例;

概略图如下:

4. 实战中的桥接模式

在某一个开发场景,一个按钮的前景色本为黑色背景色为浅灰色,当光标 mouseover 的时候改变前景色为蓝色、背景色为绿色、尺寸变为 1.5 倍,当光标 mouseleave 的时候还原前景色、背景色、尺寸,在鼠标按下的时候前景色变为红色、背景色变为紫色、尺寸变为 0.5 倍,抬起后恢复原状。怎么样,这个需求是不是有点麻烦,别管为什么有这么奇葩的需求(产品:这个需求很简单,怎么实现我不管),现在需求已经怼到脸上了,我们要如何去实现呢?

我们自然可以这样写:

    var btn = document.getElementById('btn')
    
    btn.addEventListener('mouseover', function() {
        btn.style.setProperty('color', 'blue')
        btn.style.setProperty('background-color', 'green')
        btn.style.setProperty('transform', 'scale(1.5)')
    })
    
    btn.addEventListener('mouseleave', function() {
        btn.style.setProperty('color', 'black')
        btn.style.setProperty('background-color', 'lightgray')
        btn.style.setProperty('transform', 'scale(1)')
    })
    
    btn.addEventListener('mousedown', function() {
        btn.style.setProperty('color', 'red')
        btn.style.setProperty('background-color', 'purple')
        btn.style.setProperty('transform', 'scale(.5)')
    })
    
    btn.addEventListener('mouseup', function() {
        btn.style.setProperty('color', 'black')
        btn.style.setProperty('background-color', 'lightgray')
        btn.style.setProperty('transform', 'scale(1)')
    })

的确可以达到目标需求,但是我们可以使用桥接模式来改造一下,我们可以把 DOM 对象的前景色、背景色作为其外观部件,尺寸属性是另一个尺寸部件,这样的话对各自部件的操作可以作为抽象被提取出来,使得对各自部件可以独立且方便地操作:

    var btn = document.getElementById('btn')
    
    /* 设置前景色和背景色 */
    function setColor(element, color = 'black', bgc = 'lightgray') {
        element.style.setProperty('color', color)
        element.style.setProperty('background-color', bgc)
    }
    
    /* 设置尺寸 */
    function setSize(element, size = '1') {
        element.style.setProperty('transform', `scale(${ size })`)
    }
    
    btn.addEventListener('mouseover', function() {
        setColor(btn, 'blue', 'green')
        setSize(btn, '1.5')
    })
    
    btn.addEventListener('mouseleave', function() {
        setColor(btn)
        setSize(btn)
    })
    
    btn.addEventListener('mousedown', function() {
        setColor(btn, 'red', 'purple')
        setSize(btn, '.5')
    })
    
    btn.addEventListener('mouseup', function() {
        setColor(btn)
        setSize(btn)
    })

是不是看起来清晰多了,这里的 setColorsetSize 就是桥接函数,是将 DOM (产品)及其属性(部件)连接在一起的桥梁,用户只要给桥接函数传递参数即可,十分便捷。其他 DOM 要有类似的对外观部件和尺寸部件的操作,也可以方便地进行复用。

5. 桥接模式的优缺点

桥接模式的优点:

  • 分离了抽象和实现部分,将实现层(DOM 元素事件触发并执行具体修改逻辑)和抽象层( 元素外观、尺寸部分的修改函数)解耦,有利于分层;
  • 提高了可扩展性,多个维度的部件自由组合,避免了类继承带来的强耦合关系,也减少了部件类的数量;
  • 使用者不用关心细节的实现,可以方便快捷地进行使用;

桥接模式的缺点:

  • 桥接模式要求两个部件没有耦合关系,否则无法独立地变化,因此要求正确的对系统变化的维度进行识别,使用范围存在局限性;
  • 桥接模式的引入增加了系统复杂度;

6. 桥接模式的适用场景

  • 如果产品的部件有独立的变化维度,可以考虑桥接模式;
  • 不希望使用继承,或因为多层次继承导致系统类的个数急剧增加的系统;
  • 产品部件的粒度越细,部件复用的必要性越大,可以考虑桥接模式;

7. 其他相关模式 7.1 桥接模式和策略模式

  • 桥接模式: 复用部件类,不同部件的实例相互之间无法替换,但是相同部件的实例一般可以替换;