常见设计模式 1.为什么要学习设计模式? 在许多访谈中,你可能会遇到很多面向对象编程中的接口,抽象类,代理和以及其他与设计模式相关的问题。 一旦了解了设计模式,它会让你轻松应对任何访谈,并可以在你的项目中应用这些特性。在应用程序中实现设计模式已经得到验证和测试。
为了使应用程序具有可扩展性,可靠性和易维护性,应该编写符合设计模式的代码。
2.什么是设计模式。 设计模式是我们每天编程遇到的问题的可重用解决方案。
设计模式主要是为了解决对象的生成和整合问题。
换句话说,设计模式可以作为可应用于现实世界编程问题的模板。
3.设计模式的发展历史 设计模式的概念是由四人帮(《设计模式(可复用面向对象软件的基础)》的四位作者)提出。
四人帮把这本书分成两部分:
第一部分解释面向对象编程的优缺点。
第二部分是关于 23 个经典设计模式的演变。
自提出设计模式概念后,四人帮设计模式在软件开发生命周期中发挥了重要作用。
4.设计模式分类 根据实际应用中遇到的不同问题,四人帮将设计模式分为三种类型。
接下来将概述属于这三种类型的 23 种设计模式的主要概念。
一、基础篇 this、new、bind、call、apply 1. this 指向的类型 this 在函数的指向有以下几种场景:
作为构造函数被 new 调用;
作为对象的方法使用;
作为函数直接调用;
被 call、apply、bind 调用;
箭头函数中的 this;
1.1 new 绑定
函数如果作为构造函数使用 new 调用时, this 绑定的是新创建的构造函数的实例。
1 2 3 4 5 function Foo ( ) { console .log(this ) } var bar = new Foo()
实际上使用 new 调用构造函数时,会依次执行下面的操作:
创建一个新对象;
构造函数的 prototype 被赋值给这个新对象的 __proto__;
将新对象赋给当前的 this;
执行构造函数;
如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象,如果返回的不是对象将被忽略;
1.2 显式绑定
通过 call、apply、bind 我们可以修改函数绑定的 this,使其成为我们指定的对象。通过这些方法的第一个参数我们可以显式地绑定 this。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 function foo (name, price ) { this .name = name this .price = price } function Food (category, name, price ) { foo.call(this , name, price) this .category = category } new Food('食品' , '汉堡' , '5块钱' )call 和 apply 的区别是 call 方法接受的是参数列表,而 apply 方法接受的是一个参数数组。 func.call(thisArg, arg1, arg2, ...) func.apply(thisArg, [arg1, arg2, ...])
而 bind 方法是设置 this 为给定的值,并返回一个新的函数,且在调用新函数时,将给定参数列表作为原函数的参数序列的前若干项。
1 func.bind(thisArg[, arg1[, arg2[, ...]]])
举个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 var food = { name: '汉堡' , price: '5块钱' , getPrice: function (place ) { console .log(place + this .price) } } food.getPrice('KFC ' ) var getPrice1 = food.getPrice.bind({ name : '鸡腿' , price : '7块钱' }, '肯打鸡 ' )getPrice1() 关于 bind 的原理,我们可以使用 apply 方法自己实现一个 bind 看一下: Function .prototype.bind = Function .prototype.bind || function ( ) { let self = this let rest1 = Array .prototype.slice.call(arguments ) let context = rest1.shift() return function ( ) { let rest2 = Array .prototype.slice.call(arguments ) return self.apply(context, rest1.concat(rest2)) } } 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 参数、数组解构
注意: 如果你把 null 或 undefined 作为 this 的绑定对象传入 call、apply、bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
1 2 3 4 5 6 7 var a = 'hello' function foo ( ) { console .log(this .a) } foo.call(null )
bind(this)链式调用,实际上后几项未执行,实质上 this 指向第一个绑定
1.3 隐式绑定
函数是否在某个上下文对象中调用,如果是的话 this 绑定的是那个上下文对象。
1 2 3 4 5 6 7 8 9 10 var a = 'hello' var obj = { a: 'world' , foo: function ( ) { console .log(this .a) } } obj.foo()
上面代码中,foo 方法是作为对象的属性调用的,那么此时 foo 方法执行时,this 指向 obj 对象。也就是说,此时 this 指向调用这个方法的对象,如果嵌套了多个对象,那么指向最后一个调用这个方法的对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 var a = 'hello' var obj = { a: 'world' , b:{ a:'China' , foo: function ( ) { console .log(this .a) } } } obj.b.foo()
最后一个对象是 obj 上的 b,那么此时 foo 方法执行时,其中的 this 指向的就是 b 对象。
1.4 默认绑定
函数独立调用,直接使用不带任何修饰的函数引用进行调用,也是上面几种绑定途径之外的方式。非严格模式下 this 绑定到全局对象(浏览器下是 winodw,node 环境是 global),严格模式下 this 绑定到 undefined (因为严格模式不允许 this 指向全局对象)。
1 2 3 4 5 6 7 8 9 10 11 12 var a = 'hello' function foo ( ) { var a = 'world' console .log(this .a) console .log(this ) } foo()
上面代码中,变量 a 被声明在全局作用域,成为全局对象 window 的一个同名属性。函数 foo 被执行时,this 此时指向的是全局对象,因此打印出来的 a 是全局对象的属性。
注意有一种情况:
1 2 3 4 5 6 7 8 9 10 11 12 var a = 'hello' var obj = { a: 'world' , foo: function ( ) { console .log(this .a) } } var bar = obj.foobar()
此时 bar 函数,也就是 obj 上的 foo 方法为什么又指向了全局对象呢,是因为 bar 方法此时是作为函数独立调用的,所以此时的场景属于默认绑定,而不是隐式绑定。这种情况和把方法作为回调函数的场景类似:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 var a = 'hello' var obj = { a: 'world' , foo: function ( ) { console .log(this .a) } } function func (fn ) { fn() } func(obj.foo)
参数传递实际上也是一种隐式的赋值,只不过这里 obj.foo 方法是被隐式赋值给了函数 func 的形参 fn,而之前的情景是自己赋值,两种情景实际上类似。这种场景我们遇到的比较多的是 setTimeout 和 setInterval,如果回调函数不是箭头函数,那么其中的 this 指向的就是全局对象.
其实我们可以把默认绑定当作是隐式绑定的特殊情况,比如上面的 bar(),我们可以当作是使用 window.bar() 的方式调用的,此时 bar 中的 this 根据隐式绑定的情景指向的就是 window。
2. this 绑定的优先级
this 存在多个使用场景,那么多个场景同时出现的时候,this 到底应该如何指向呢。这里存在一个优先级的概念,this 根据优先级来确定指向。优先级:new 绑定 > 显示绑定 > 隐式绑定 > 默认绑定
所以 this 的判断顺序:
new 绑定: 函数是否在 new 中调用?如果是的话 this 绑定的是新创建的对象;
显式绑定: 函数是否是通过 bind、call、apply 调用?如果是的话,this 绑定的是指定的对象;
隐式绑定: 函数是否在某个上下文对象中调用?如果是的话,this 绑定的是那个上下文对象;
如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象;
3. 箭头函数中的 this
箭头函数 是根据其声明的地方来决定 this 的
箭头函数的 this 绑定是无法通过 call、apply、bind 被修改的,且因为箭头函数没有构造函数 constructor,所以也不可以使用 new 调用,即不能作为构造函数,否则会报错。
1 2 3 4 5 6 7 8 9 10 var a = 'hello' var obj = { a: 'world' , foo: () => { console .log(this .a) } } obj.foo()
4. 一个 this 的小练习 用一个小练习来实战一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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() bar() new bar()
稍微解释一下:
var a = 20 这句在全局变量 window 上创建了个属性 a 并赋值为 20;
首先执行的是 obj.foo(),这是一个箭头函数,箭头函数不创建新的函数作用域直接沿用语句外部的作用域,因此 obj.foo() 执行时箭头函数中 this 是全局 window,首先打印出 window 上的属性 a 的值 20,箭头函数返回了一个原型上有个值为 50 的属性 a 的函数对象 func 给 bar;
继续执行的是 bar(),这里执行的是刚刚箭头函数返回的闭包 func,其内部的 this 指向 window,因此 this.a 修改了 window.a 的值为 60 并打印出来;
然后执行的是 new bar(),根据之前的表述,new 操作符会在 func 函数中创建一个继承了 func 原型的实例对象并用 this 指向它,随后 this.a = 60 又在实例对象上创建了一个属性 a,在之后的打印中已经在实例上找到了属性 a,因此就不继续往对象原型上查找了,所以打印出第三个 60;
如果把上面例子的箭头函数换成普通函数呢,结果会是什么样?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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() bar() new bar()
闭包与高阶函数 1. 闭包 1.1 什么是闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
我们首先来看一个闭包的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 function foo ( ) { var a = 2 function bar ( ) { console .log(a) } return bar } var baz = foo()baz()
foo 函数传递出了一个函数 bar,传递出来的 bar 被赋值给 baz 并调用,虽然这时 baz 是在 foo 作用域外执行的,但 baz 在调用的时候可以访问到前面的 bar 函数所在的 foo 的内部作用域。
由于 bar 声明在 foo 函数内部,bar 拥有涵盖 foo 内部作用域的闭包,使得 foo 的内部作用域一直存活不被回收。一般来说,函数在执行完后其整个内部作用域都会被销毁,因为 JavaScript 的 GC(Garbage Collection)垃圾回收机制会自动回收不再使用的内存空间。但是闭包会阻止某些 GC,比如本例中 foo() 执行完,因为返回的 bar 函数依然持有其所在作用域的引用,所以其内部作用域不会被回收。
注意: 如果不是必须使用闭包,那么尽量避免创建它,因为闭包在处理速度和内存消耗方面对性能具有负面影响。
1.2 利用闭包实现结果缓存(备忘模式)
备忘模式就是应用闭包的特点的一个典型应用。比如有个函数:
1 2 3 function add (a ) { return a + 1 ; }
多次运行 add() 时,每次得到的结果都是重新计算得到的,如果是开销很大的计算操作的话就比较消耗性能了,这里可以对已经计算过的输入做一个缓存。
所以这里可以利用闭包的特点来实现一个简单的缓存,在函数内部用一个对象存储输入的参数,如果下次再输入相同的参数,那就比较一下对象的属性,如果有缓存,就直接把值从这个对象里面取出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 ) adder(1 ) adder(2 )
使用 ES6 的方式会更优雅一些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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 ) adder(1 ) adder(2 )
稍微解释一下:
备忘函数中用 JSON.stringify 把传给 adder 函数的参数序列化成字符串,把它当做 cache 的索引,将 add 函数运行的结果当做索引的值传递给 cache,这样 adder 运行的时候如果传递的参数之前传递过,那么就返回缓存好的计算结果,不用再计算了,如果传递的参数没计算过,则计算并缓存 fn.apply(fn, args),再返回计算的结果。
当然这里的实现如果要实际应用的话,还需要继续改进一下,比如:
缓存不可以永远扩张下去,这样太耗费内存资源,我们可以只缓存最新传入的 n 个;
在浏览器中使用的时候,我们可以借助浏览器的持久化手段,来进行缓存的持久化,比如 cookie、localStorage 等;
这里的复杂计算函数可以是过去的某个状态,比如对某个目标的操作,这样把过去的状态缓存起来,方便地进行状态回退。
复杂计算函数也可以是一个返回时间比较慢的异步操作,这样如果把结果缓存起来,下次就可以直接从本地获取,而不是重新进行异步请求。
注意: cache 不可以是 Map,因为 Map 的键是使用 === 比较的,因此当传入引用类型值作为键时,虽然它们看上去是相等的,但实际并不是,比如 [1]!==[1],所以还是被存为不同的键。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 ) adder(1 ) adder(2 )
2. 高阶函数
高阶函数就是输入参数里有函数,或者输出是函数的函数。
2.1 函数作为参数
如果你用过 setTimeout、setInterval、ajax 请求,那么你已经用过高阶函数了,这是我们最常看到的场景:回调函数,因为它将函数作为参数传递给另一个函数。
比如 ajax 请求中,我们通常使用回调函数来定义请求成功或者失败时的操作逻辑:
1 2 3 $.ajax("/request/url" , function (result ) { console .log("请求成功!" ) })
在 Array、Object、String 等等基本对象的原型上有很多操作方法,可以接受回调函数来方便地进行对象操作。这里举一个很常用的 Array.prototype.filter() 方法,这个方法返回一个新创建的数组,包含所有回调函数执行后返回 true 或真值的数组元素。
1 2 3 4 5 var words = ['spray' , 'limit' , 'elite' , 'exuberant' , 'destruction' , 'present' ];var result = words.filter(function (word ) { return word.length > 6 })
回调函数还有一个应用就是钩子,如果你用过 Vue 或者 React 等框架,那么你应该对钩子很熟悉了,它的形式是这样的:
1 2 3 4 function foo (callback ) { callback() }
2.2 函数作为返回值
另一个经常看到的高阶函数的场景是在一个函数内部输出另一个函数,比如:
1 2 3 function foo ( ) { return function bar ( ) {} }
主要是利用闭包来保持着作用域:
1 2 3 4 5 6 7 8 9 10 function add ( ) { var num = 0 return function (a ) { return num = num + a } } var adder = add()adder(1 ) adder(2 )
1. 柯里化
柯里化(Currying),又称部分求值(Partial Evaluation),是把接受多个参数的原函数变换成接受一个单一参数(原函数的第一个参数)的函数,并且返回一个新函数,新函数能够接受余下的参数,最后返回同原函数一样的结果。
核心思想是把多参数传入的函数拆成单(或部分)参数函数,内部再返回调用下一个单(或部分)参数函数,依次处理剩余的参数。
柯里化有 3 个常见作用:
先来看看柯里化的通用实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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)) } } function currying (fn, ...rest1 ) { return function (...rest2 ) { return fn.apply(null , rest1.concat(rest2)) } }
用它将一个 sayHello 函数柯里化试试:
1 2 3 4 5 6 7 8 9 10 function sayHello (name, age, fruit ) { console .log(console .log(`我叫 ${name} ,我 ${age} 岁了, 我喜欢吃 ${fruit} ` )) } var curryingShowMsg1 = currying(sayHello, '小明' )curryingShowMsg1(22 , '苹果' ) var curryingShowMsg2 = currying(sayHello, '小衰' , 20 )curryingShowMsg2('西瓜' )
更高阶的用法参见:JavaScript 函数式编程技巧 - 柯里化
2. 反柯里化
柯里化是固定部分参数,返回一个接受剩余参数的函数,也称为部分计算函数,目的是为了缩小适用范围,创建一个针对性更强的函数。核心思想是把多参数传入的函数拆成单参数(或部分)函数,内部再返回调用下一个单参数(或部分)函数,依次处理剩余的参数。
而反柯里化,从字面讲,意义和用法跟函数柯里化相比正好相反,扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象。
先来看看反柯里化的通用实现吧~
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 Function .prototype.unCurrying = function ( ) { var self = this return function ( ) { var rest = Array .prototype.slice.call(arguments ) return Function .prototype.call.apply(self, rest) } } Function .prototype.unCurrying = function ( ) { const self = this return function (...rest ) { return Function .prototype.call.apply(self, rest) } } function unCurrying (fn ) { return function (tar ) { var rest = Array .prototype.slice.call(arguments ) rest.shift() return fn.apply(tar, rest) } } function unCurrying (fn ) { return function (tar, ...argu ) { return fn.apply(tar, argu) } }
简单说,函数柯里化就是对高阶函数的降阶处理,缩小适用范围,创建一个针对性更强的函数。
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 )
而反柯里化就是反过来,增加适用范围,让方法使用场景更大。使用反柯里化, 可以把原生方法借出来,让任何对象拥有原生对象的方法。
可以这样理解柯里化和反柯里化的区别:
柯里化是在运算前提前传参,可以传递多个参数;
反柯里化是延迟传参,在运算时把原来已经固定的参数或者 this 上下文等当作参数延迟到未来传递。
更高阶的用法参见:JavaScript 函数式编程技巧 - 反柯里化
3. 偏函数
偏函数是创建一个调用另外一个部分(参数或变量已预制的函数)的函数,函数可以根据传入的参数来生成一个真正执行的函数。其本身不包括我们真正需要的逻辑代码,只是根据传入的参数返回其他的函数,返回的函数中才有真正的处理逻辑比如:
1 2 3 4 5 6 7 8 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()(),这时的柯里化函数和偏函数概念类似,可以认为偏函数是柯里化函数的退化版
设计原则
在前文我们介绍了面向对象三大特性之继承,本文将主要介绍面向对象六大原则中的单一职责原则(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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 的时候判断这个实例有没有创建过,创建过就直接返回,否则走创建流程。我们可以用 ES6 的 class 语法改造一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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)
上面方法的缺点在于维护的实例作为静态属性直接暴露,外部可以直接修改。
可以使用闭包或块状作用域隐藏内部变量:
单例模式赋能
之前的例子中,单例模式的创建逻辑和原先这个类的一些功能逻辑(比如 init 等操作)混杂在一起,根据单一职责原则,这个例子我们还可以继续改进一下,将单例模式的创建逻辑和特定类的功能逻辑拆开,这样功能逻辑就可以和正常的类一样。
惰性单例、懒汉式-饿汉式
有时候一个实例化过程比较耗费性能的类,但是却一直用不到,如果一开始就对这个类进行实例化就显得有些浪费,那么这时我们就可以使用惰性创建,即延迟创建该类的单例。之前的例子都属于惰性单例,实例的创建都是 new 的时候才进行。
单例模式的优缺点
单例模式主要解决的问题就是节约资源,保持访问一致性。
简单分析一下它的优点:
单例模式在创建后在内存中只存在一个实例,节约了内存开支和实例化时的性能开支,特别是需要重复使用一个创建开销比较大的类时,比起实例不断地销毁和重新实例化,单例能节约更多资源,比如数据库连接;
单例模式可以解决对资源的多重占用,比如写文件操作时,因为只有一个实例,可以避免对一个文件进行同时操作;
只使用一个实例,也可以减小垃圾回收机制 GC(Garbage Collecation) 的压力,表现在浏览器中就是系统卡顿减少,操作更流畅,CPU 资源占用更少;
单例模式也是有缺点的
单例模式对扩展不友好,一般不容易扩展,因为单例模式一般自行实例化,没有接口;
与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化;
单例模式的使用场景
那我们应该在什么场景下使用单例模式呢:
当一个类的实例化过程消耗的资源过多,可以使用单例模式来避免性能浪费;
当项目中需要一个公共的状态,那么需要使用单例模式来保证访问一致性;
工厂模式
工厂模式 (Factory Pattern),根据不同的输入返回不同类的实例,一般用来创建同一类对象。工厂方式的主要思想是将对象的创建与对象的实现分离。
访问者只需要知道产品名,就可以从工厂获得对应实例;
访问者不关心实例创建过程;
代码实现
如果你使用过 document.createElement 方法创建过 DOM 元素,那么你已经使用过工厂方法了,虽然这个方法实际上很复杂,但其使用的就是工厂方法的思想:访问者只需提供标签名(如 div、img),那么这个方法就会返回对应的 DOM 元素。
我们可以使用 JavaScript 将上面饭馆例子实现一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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('红烧排骨' )
工厂方法中这里使用 switch-case 语法,你也可以用 if-else,都可以。
下面使用 ES6 的 class 语法改写一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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('红烧排骨' )
这样就完成了一个工厂模式,但是这个实现有一个问题:工厂方法中包含了很多与创建产品相关的过程,如果产品种类很多的话,这个工厂方法中就会罗列很多产品的创建逻辑,每次新增或删除产品种类,不仅要增加产品类,还需要对应修改在工厂方法,违反了开闭原则,也导致这个工厂方法变得臃肿、高耦合。
严格上这种实现在面向对象语言中叫做简单工厂模式。适用于产品种类比较少,创建逻辑不复杂的时候使用。
工厂模式的本意是将实际创建对象的过程推迟到子类中,一般用抽象类来作为父类,创建过程由抽象类的子类来具体实现。JavaScript 中没有抽象类,所以我们可以简单地将工厂模式看做是一个实例化对象的工厂类即可。关于抽象类的有关内容,可以参看抽象工厂模式。
然而作为灵活的 JavaScript,我们不必如此较真,可以把易变的参数提取出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 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' )
我们还给 Restaurant 类增加了 addMenu/removeMenu 私有方法,以便于扩展。
当然这里如果菜品参数不太一致,可以在 addMenu 时候注册构造函数或者类,创建的时候返回 new 出的对应类实例,灵活变通即可。
工厂模式的优缺点
工厂模式将对象的创建和实现分离,这带来了优点:
良好的封装,代码结构清晰,访问者无需知道对象的创建流程,特别是创建比较复杂的情况下;
扩展性优良,通过工厂方法隔离了用户和创建流程隔离,符合开放封闭原则;
解耦了高层逻辑和底层产品类,符合最少知识原则,不需要的就不要去交流;
工厂模式的缺点:带来了额外的系统复杂度,增加了抽象性;
工厂模式的使用场景
那么什么时候使用工厂模式呢:
对象的创建比较复杂,而访问者无需知道创建的具体流程;
处理大量具有相同属性的小对象;
抽象工厂模式
工厂模式 (Factory Pattern),根据输入的不同返回不同类的实例,一般用来创建同一类对象。工厂方式的主要思想是将对象的创建与对象的实现分离。
抽象工厂 (Abstract Factory):通过对类的工厂抽象使其业务用于对产品类簇的创建,而不是负责创建某一类产品的实例。关键在于使用抽象类制定了实例的结构,调用者直接面向实例的结构编程,从实例的具体实现中解耦。
我们知道 JavaScript 并不是强面向对象语言,所以使用传统编译型语言比如 JAVA、C#、C++ 等实现的设计模式和 JavaScript 不太一样,比如 JavaScript 中没有原生的类和接口等(不过 ES6+ 渐渐提供类似的语法糖),我们可以用变通的方式来解决。最重要的是设计模式背后的核心思想,和它所要解决的问题。
代码实现
我们知道 JavaScript 并不强面向对象,也没有提供抽象类(至少目前没有提供),但是可以模拟抽象类。用对 new.target 来判断 new 的类,在父类方法中 throw new Error(),如果子类中没有实现这个方法就会抛错,这样来模拟抽象类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class AbstractClass1 { constructor ( ) { if (new .target === AbstractClass1) { throw new Error ('抽象类不能直接实例化!' ) } } operate ( ) { throw new Error ('抽象方法不能调用!' ) } } var AbstractClass2 = function ( ) { if (new .target === AbstractClass2) { throw new Error ('抽象类不能直接实例化!' ) } } AbstractClass2.prototype.operate = function ( ) { throw new Error ('抽象方法不能调用!' ) }
下面用 JavaScript 将上面介绍的饭店例子实现一下。
首先使用原型方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 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('红烧排骨' )使用 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() const dish1 = Restaurant.orderDish('鱼香肉丝' )dish1.eat() const dish2 = Restaurant.orderDish('红烧排骨' )
抽象工厂模式的优缺点
抽象模式的优点:
抽象产品类将产品的结构抽象出来,访问者不需要知道产品的具体实现,只需要面向产品的结构编程即可,从产品的具体实现中解耦;
抽象模式的缺点:
扩展新类簇的产品类比较困难,因为需要创建新的抽象产品类,并且还要修改工厂类,违反开闭原则;
带来了系统复杂度,增加了新的类,和新的继承关系;
抽象工厂模式的使用场景
如果一组实例都有相同的结构,那么就可以使用抽象工厂模式。
抽象工厂模式与工厂模式
工厂模式和抽象工厂模式的区别:
工厂模式 主要关注单独的产品实例的创建;
抽象工厂模式 主要关注产品类簇实例的创建,如果产品类簇只有一个产品,那么这时的抽象工厂模式就退化为工厂模式了;根据场景灵活使用即可。
建造者模式
建造者模式(Builder Pattern)又称生成器模式,分步构建一个复杂对象,并允许按步骤构造。同样的构建过程可以采用不同的表示,将一个复杂对象的构建层与其表示层分离。
在工厂模式中,创建的结果都是一个完整的个体,我们对创建的过程并不关心,只需了解创建的结果。而在建造者模式中,我们关心的是对象的创建过程,因此我们通常将创建的复杂对象的模块化,使得被创建的对象的每一个子模块都可以得到高质量的复用,当然在灵活的 JavaScript 中我们可以有更灵活的实现。
汽车装配代码模式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 // 建造者,汽车部件厂家,提供具体零部件的生产 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: "正在使用大马力发动机"} // }
ES6写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 // 建造者,汽车部件厂家,提供具体零部件的生产 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,我们还可以使用链模式来完成部件的装配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 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)
这样将最终产品的创建流程使用链模式来实现,相当于将指挥者退化,指挥的过程通过链模式让用户自己实现,这样既增加了灵活性,装配过程也一目了然。如果希望扩展产品的部件,那么在建造者上增加部件实现方法,再适当修改链模式即可。
建造者模式的优点:
使用建造者模式可以使产品的构建流程和产品的表现分离,也就是将产品的创建算法和产品组成的实现隔离,访问者不必知道产品部件实现的细节;
扩展方便,如果希望生产一个装配顺序或方式不同的新产品,那么直接新建一个指挥者即可,不用修改既有代码,符合开闭原则;
更好的复用性,建造者模式将产品的创建算法和产品组成的实现分离,所以产品创建的算法可以复用,产品部件的实现也可以复用,带来很大的灵活性;
建造者模式的缺点:
建造者模式一般适用于产品之间组成部件类似的情况,如果产品之间差异性很大、复用性不高,那么不要使用建造者模式;
实例的创建增加了许多额外的结构,无疑增加了许多复杂度,如果对象粒度不大,那么我们最好直接创建对象;
建造者模式的适用场景
相同的方法,不同的执行顺序,产生不一样的产品时,可以采用建造者模式;
产品的组成部件类似,通过组装不同的组件获得不同产品时,可以采用建造者模式;
建造者模式与工厂模式
建造者模式和工厂模式最终都是创建一个完整的产品,但是在建造者模式中我们更关心对象创建的过程,将创建对象的方法模块化,从而更好地复用这些模块。
当然建造者模式与工厂模式也是可以组合使用的,比如建造者中一般会提供不同的部件实现,那么这里就可以使用工厂模式来提供具体的部件对象,再通过指挥者来进行装配。
** 建造者模式与模版方法模式**
指挥者的实现可以和模版方法模式相结合。也就是说,指挥者中部件的装配过程,可以使用模版方法模式来固定装配算法,把部件实现方法分为模板方法和基本方法,进一步提取公共代码,扩展可变部分。
是否采用模版方法模式看具体场景,如果产品的部件装配顺序很明确,但是具体的实现是未知的、灵活的,那么你可以适当考虑是否应该将算法骨架提取出来。
三、结构型模式 代理模式
代理模式 (Proxy Pattern)又称委托模式,它为目标对象创造了一个代理对象,以控制对目标对象的访问。
代理模式把代理对象插入到访问者和目标对象之间,从而为访问者对目标对象的访问引入一定的间接性。正是这种间接性,给了代理对象很多操作空间,比如在调用目标对象前和调用后进行一些预操作和后操作,从而实现新的功能或者扩展目标的功能。
实例的代码实现
我们使用 JavaScript 来将上面的明星例子实现一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 var SuperStar = { name: '小鲜肉' , playAdvertisement: function (ad ) { console .log(ad) } } var ProxyAssistant = { name: '经纪人张某' , playAdvertisement: function (reward, ad ) { if (reward > 1000000 ) { console .log('没问题,我们小鲜鲜最喜欢拍广告了!' ) SuperStar.playAdvertisement(ad) } else console .log('没空,滚!' ) } } ProxyAssistant.playAdvertisement(10000 , '纯蒸酸牛奶,味道纯纯,尽享纯蒸' )
我们可以升级一下,比如如果明星没有档期的话,可以通过经纪人安排档期,当明星有空的时候才让明星来拍广告。这里通过 Promise 的方式来实现档期的安排:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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 ) { console .log('没问题,我们小鲜鲜最喜欢拍广告了!' ) ProxyAssistant.scheduleTime() .then(() => SuperStar.playAdvertisement(ad)) } else console .log('没空,滚!' ) } } ProxyAssistant.playAdvertisement(10000 , '纯蒸酸牛奶,味道纯纯,尽享纯蒸' ) ProxyAssistant.playAdvertisement(1000001 , '纯蒸酸牛奶,味道纯纯,尽享纯蒸' )
这里就简单实现了经纪人对请求的过滤,对明星档期的安排,实现了一个代理对象的基本功能。
代理模式的优缺点
代理模式的主要优点有:
代理对象在访问者与目标对象之间可以起到中介和保护目标对象的作用;
代理对象可以扩展目标对象的功能;
代理模式能将访问者与目标对象分离,在一定程度上降低了系统的耦合度,如果我们希望适度扩展目标对象的一些功能,通过修改代理对象就可以了,符合开闭原则;
代理模式的缺点主要是增加了系统的复杂度,要斟酌当前场景是不是真的需要引入代理模式(十八线明星就别请经纪人了)
其他相关模式
很多其他的模式,比如状态模式、策略模式、访问者模式其实也是使用了代理模式,包括在之前高阶函数处介绍的备忘模式,本质上也是一种缓存代理。
代理模式与适配器模式
代理模式和适配器模式都为另一个对象提供间接性的访问,他们的区别:
适配器模式: 主要用来解决接口之间不匹配的问题,通常是为所适配的对象提供一个不同的接口;
代理模式: 提供访问目标对象的间接访问,以及对目标对象功能的扩展,一般提供和目标对象一样的接口;
代理模式与装饰者模式
装饰者模式实现上和代理模式类似,都是在访问目标对象之前或者之后执行一些逻辑,但是目的和功能不同:
装饰者模式: 目的是为了方便地给目标对象添加功能,也就是动态地添加功能;
代理模式: 主要目的是控制其他访问者对目标对象的访问;
享元模式
享元模式 (Flyweight Pattern)运用共享技术来有效地支持大量细粒度对象的复用,以减少创建的对象的数量。
享元模式的主要思想是共享细粒度对象,也就是说如果系统中存在多个相同的对象,那么只需共享一份就可以了,不必每个都去实例化每一个对象,这样来精简内存资源,提升性能和效率。
Fly 意为苍蝇,Flyweight 指轻蝇量级,指代对象粒度很小。
代码实现
首先假设考生的 ID 为奇数则考的是手动档,为偶数则考的是自动档。如果给所有考生都 new 一个驾考车,那么这个系统中就会创建了和考生数量一致的驾考车对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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)
如果考生很多,那么系统中就会存在更多个驾考车对象实例,假如驾考车对象比较复杂,那么这些新建的驾考车实例就会占用大量内存。这时我们将同种类型的驾考车实例进行合并,手动档和自动档档驾考车分别引用同一个实例,就可以节约大量内存:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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 个驾考车实例就实现了刚刚 10 个驾考车实例实现的功能。这是仅有 10 个考生的情况,如果有几百上千考生,这时我们节约的内存就比较可观了,这就是享元模式要达到的目的。
享元模式的优缺点
享元模式的优点:
由于减少了系统中的对象数量,提高了程序运行效率和性能,精简了内存占用,加快运行速度;
外部状态相对独立,不会影响到内部状态,所以享元对象能够在不同的环境被共享;
享元模式的缺点:
引入了共享对象,使对象结构变得复杂;
共享对象的创建、销毁等需要维护,带来额外的复杂度(如果需要把共享对象维护起来的话);
享元模式的适用场景
如果一个程序中大量使用了相同或相似对象,那么可以考虑引入享元模式;
如果使用了大量相同或相似对象,并造成了比较大的内存开销;
对象的大多数状态可以被转变为外部状态;
剥离出对象的外部状态后,可以使用相对较少的共享对象取代大量对象;
在一些程序中,如果引入享元模式对系统的性能和内存的占用影响不大时,比如目标对象不多,或者场景比较简单,则不需要引入,以免适得其反。
其他相关模式
享元模式和单例模式、工厂模式、组合模式、策略模式、状态模式等等经常会一起使用。
享元模式和工厂模式、单例模式
在区分出不同种类的外部状态后,创建新对象时需要选择不同种类的共享对象,这时就可以使用工厂模式来提供共享对象,在共享对象的维护上,经常会采用单例模式来提供单实例的共享对象。
享元模式和组合模式
在使用工厂模式来提供共享对象时,比如某些时候共享对象中的某些状态就是对象不需要的,可以引入组合模式来提升自定义共享对象的自由度,对共享对象的组成部分进一步归类、分层,来实现更复杂的多层次对象结构,当然系统也会更难维护。
享元模式和策略模式
策略模式中的策略属于一系列功能单一、细粒度的细粒度对象,可以作为目标对象来考虑引入享元模式进行优化,但是前提是这些策略是会被频繁使用的,如果不经常使用,就没有必要了。
适配器模式
适配器模式(Adapter Pattern)又称包装器模式,将一个类(对象)的接口(方法、属性)转化为用户需要的另一个接口,解决类(对象)之间接口不兼容的问题。
主要功能是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,访问者需要的功能应该是已经实现好了的,不需要适配器模式来实现,适配器模式主要是负责把不兼容的接口转换成访问者期望的格式而已。
代码实现
我们可以实现一下电源适配器的例子,一开始我们使用的中国插头标准:
1 2 3 4 5 6 7 8 9 var chinaPlug = { type: '中国插头' , chinaInPlug ( ) { console .log('开始供电' ) } } chinaPlug.chinaInPlug()
但是我们出国旅游了,到了日本,需要增加一个日本插头到中国插头的电源适配器,来将我们原来的电源线用起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 var chinaPlug = { type: '中国插头' , chinaInPlug ( ) { console .log('开始供电' ) } } var japanPlug = { type: '日本插头' , japanInPlug ( ) { console .log('开始供电' ) } } function japanPlugAdapter (plug ) { return { chinaInPlug ( ) { return plug.japanInPlug() } } } japanPlugAdapter(japanPlug).chinaInPlug()
适配器模式的优缺点
适配器模式的优点:
已有的功能如果只是接口不兼容,使用适配器适配已有功能,可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码;
可扩展性良好,在实现适配器功能的时候,可以调用自己开发的功能,从而方便地扩展系统的功能;
灵活性好,因为适配器并没有对原有对象的功能有所影响,如果不想使用适配器了,那么直接删掉即可,不会对使用原有对象的代码有影响;
适配器模式的缺点:会让系统变得零乱,明明调用 A,却被适配到了 B,如果系统中这样的情况很多,那么对可阅读性不太友好。如果没必要使用适配器模式的话,可以考虑重构,如果使用的话,可以考虑尽量把文档完善。
适配器模式的适用场景
当你想用已有对象的功能,却想修改它的接口时,一般可以考虑一下是不是可以应用适配器模式。
如果你想要使用一个已经存在的对象,但是它的接口不满足需求,那么可以使用适配器模式,把已有的实现转换成你需要的接口;
如果你想创建一个可以复用的对象,而且确定需要和一些不兼容的对象一起工作,这种情况可以使用适配器模式,然后需要什么就适配什么;
其他相关模式
适配器模式和代理模式、装饰者模式看起来比较类似,都是属于包装模式,也就是用一个对象来包装另一个对象的模式,他们之间的异同在代理模式中已经详细介绍了,这里再简单对比一下。
适配器模式与代理模式
适配器模式: 提供一个不一样的接口,由于原来的接口格式不能用了,提供新的接口以满足新场景下的需求;
代理模式: 提供一模一样的接口,由于不能直接访问目标对象,找个代理来帮忙访问,使用者可以就像访问目标对象一样来访问代理对象;
适配器模式、装饰者模式与代理模式
适配器模式: 功能不变,只转换了原有接口访问格式;
装饰者模式: 扩展功能,原有功能不变且可直接使用;
代理模式: 原有功能不变,但一般是经过限制访问的;
装饰者模式
装饰者模式 (Decorator Pattern)又称装饰器模式,在不改变原对象的基础上,通过对其添加属性或方法来进行包装拓展,使得原有对象可以动态具有更多功能。
本质是功能动态组合,即动态地给一个对象添加额外的职责,就增加功能角度来看,使用装饰者模式比用继承更为灵活。好处是有效地把对象的核心职责和装饰功能区分开,并且通过动态增删装饰去除目标对象中重复的装饰逻辑。
我们可以使用 JavaScript 来将装修房子的例子实现一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 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 的优点就是灵活:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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()
简洁明了,且更符合前端日常使用的场景。
装饰者模式的优缺点
装饰者模式的优点:
我们经常使用继承的方式来实现功能的扩展,但这样会给系统中带来很多的子类和复杂的继承关系,装饰者模式允许用户在不引起子类数量暴增的前提下动态地修饰对象,添加功能,装饰者和被装饰者之间松耦合,可维护性好;
被装饰者可以使用装饰者动态地增加和撤销功能,可以在运行时选择不同的装饰器,实现不同的功能,灵活性好;
装饰者模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,有利于装饰器功能的复用;
可以通过选择不同的装饰者的组合,创造不同行为和功能的结合体,原有对象的代码无须改变,就可以使得原有对象的功能变得更强大和更多样化,符合开闭原则;
装饰者模式的缺点:
使用装饰者模式时会产生很多细粒度的装饰者对象,这些装饰者对象由于接口和功能的多样化导致系统复杂度增加,功能越复杂,需要的细粒度对象越多;
由于更大的灵活性,也就更容易出错,特别是对于多级装饰的场景,错误定位会更加繁琐;
装饰者模式的适用场景
如果不希望系统中增加很多子类,那么可以考虑使用装饰者模式;
需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,这时采用装饰者模式可以很好实现;
当对象的功能要求可以动态地添加,也可以动态地撤销,可以考虑使用装饰者模式;
其他相关模式
** 装饰者模式与适配器模式**
装饰者模式和适配器模式都是属于包装模式,然而他们的意图有些不一样:
装饰者模式: 扩展功能,原有功能还可以直接使用,一般可以给目标对象多次叠加使用多个装饰者;
适配器模式: 功能不变,但是转换了原有接口的访问格式,一般只给目标对象使用一次;
装饰者模式与组合模式
这两个模式有相似之处,都涉及到对象的递归调用,从某个角度来说,可以把装饰者模式看做是只有一个组件的组合模式。
装饰者模式: 动态地给对象增加功能;
组合模式: 管理组合对象和叶子对象,为它们提供一致的操作接口给客户端,方便客户端的使用;
装饰者模式与策略模式
装饰者模式和策略模式都包含有许多细粒度的功能模块,但是他们的使用思路不同:
装饰者模式: 可以递归调用,使用多个功能模式,功能之间可以叠加组合使用;
策略模式: 只有一层选择,选择某一个功能;
外观模式
外观模式 (Facade Pattern)又叫门面模式,定义一个将子系统的一组接口集成在一起的高层接口,以提供一个一致的外观。外观模式让外界减少与子系统内多个模块的直接交互,从而减少耦合,让外界可以更轻松地使用子系统。本质是封装交互,简化调用。
外观模式在源码中使用很多,具体可以参考后文中源码阅读部分。
简化版本的代码: 无人机
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 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()
无人机系统是比较复杂,但是可以看到无人机的操纵却比较简单,正是因为有遥控器这个外观的存在。
外观模式的优点:
访问者不需要再了解子系统内部模块的功能,而只需和外观交互即可,使得访问者对子系统的使用变得简单,符合最少知识原则,增强了可移植性和可读性;
减少了与子系统模块的直接引用,实现了访问者与子系统中模块之间的松耦合,增加了可维护性和可扩展性;
通过合理使用外观模式,可以帮助我们更好地划分系统访问层次,比如把需要暴露给外部的功能集中到外观中,这样既方便访问者使用,也很好地隐藏了内部的细节,提升了安全性;
外观模式的缺点:
不符合开闭原则,对修改关闭,对扩展开放,如果外观模块出错,那么只能通过修改的方式来解决问题,因为外观模块是子系统的唯一出口;
不需要或不合理的使用外观会让人迷惑,过犹不及;
外观模式的适用场景
维护设计粗糙和难以理解的遗留系统,或者系统非常复杂的时候,可以为这些系统设置外观模块,给外界提供清晰的接口,以后新系统只需与外观交互即可;
你写了若干小模块,可以完成某个大功能,但日后常用的是大功能,可以使用外观来提供大功能,因为外界也不需要了解小模块的功能;
团队协作时,可以给各自负责的模块建立合适的外观,以简化使用,节约沟通时间;
如果构建多层系统,可以使用外观模式来将系统分层,让外观模块成为每层的入口,简化层间调用,松散层间耦合;
其他相关模式
外观模式与中介者模式
外观模式: 封装子使用者对子系统内模块的直接交互,方便使用者对子系统的调用;
中介者模式: 封装子系统间各模块之间的直接交互,松散模块间的耦合;
外观模式与单例模式
有时候一个系统只需要一个外观,比如之前举的 Axios 的 HTTP 模块例子。这时我们可以将外观模式和单例模式可以一起使用,把外观实现为单例。
组合模式
组合模式 (Composite Pattern)又叫整体-部分模式,它允许你将对象组合成树形结构来表现整体-部分层次结构,让使用者可以以一致的方式处理组合对象以及部分对象
你曾见过的组合模式
大家电脑里的文件夹结构相比很熟悉了,文件夹下面可以有子文件夹,也可以有文件,子文件夹下面还可以有文件夹和文件,以此类推,共同组成了一个文件树,结构如下:
1 2 3 4 5 6 7 8 9 Folder 1 ├── Folder 2 │ ├── File 1. txt │ ├── File 2. txt │ └── File 3. txt └── Folder 3 ├── File 4. txt ├── File 5. txt └── File 6. txt
文件夹是树形结构的容器节点,容器节点可以继续包含其他容器节点,像树枝上还可以有其他树枝一样;也可以包含文件,不再增加新的层级,就像树的叶子一样处于末端,因此被称为叶节点。本文中,叶节点又称为叶对象,容器节点因为可以包含容器节点和非容器节点,又称为组合对象。
代码实现
我们可以使用 JavaScript 来将之前的文件夹例子实现一下。
在本地一个「电影」文件夹下有两个子文件夹「漫威英雄电影」和「DC英雄电影」,分别各自有一些电影文件,我们要做的就是在这个电影文件夹里找大于 2G 的电影文件,无论是在这个文件夹下还是在子文件夹下,并输出它的文件名和文件大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 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' ) } })
作为灵活的 JavaScript,我们还可以使用链模式来进行改造一下,让我们添加子文件更加直观和方便。对链模式还不熟悉的同学可以看一下后面有一篇单独介绍链模式的文章~
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 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` ) } })
上面的代码比较 JavaScript 特色,如果我们使用传统的类呢,也是可以实现的,下面使用 ES6 的 class 语法来改写一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 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` ) } })
在传统的语言中,为了保证叶对象和组合对象的外观一致,还会让他们实现同一个抽象类或接口。
组合模式的优缺点 组合模式的优点:
由于组合对象和叶对象具有同样的接口,因此调用的是组合对象还是叶对象对使用者来说没有区别,使得使用者面向接口编程;
如果想在组合模式的树中增加一个节点比较容易,在目标组合对象中添加即可,不会影响到其他对象,对扩展友好,符合开闭原则,利于维护;
组合模式的缺点:
增加了系统复杂度,如果树中对象不多,则不一定需要使用;
如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起;
组合模式的适用场景
如果对象组织呈树形结构就可以考虑使用组合模式,特别是如果操作树中对象的方法比较类似时;
使用者希望统一对待树形结构中的对象,比如用户不想写一堆 if-else 来处理树中的节点时,可以使用组合模式;
其他相关模式
组合模式和职责链模式
正如前文所说,组合模式是天生实现了职责链模式的。
组合模式: 请求在组合对象上传递,被深度遍历到组合对象的所有子孙叶节点具体执行;
职责链模式: 实现请求的发送者和接受者之间的解耦,把多个接受者组合起来形成职责链,请求在链上传递,直到有接受者处理请求为止;
组合模式和迭代器模式
组合模式可以结合迭代器模式一起使用,在遍历组合对象的叶节点的时候,可以使用迭代器模式来遍历。
组合模式和命令模式
命令模式里有一个用法「宏命令」,宏命令就是组合模式和命令模式一起使用的结果,是组合模式组装而成
桥接模式
桥接模式(Bridge Pattern)又称桥梁模式,将抽象部分与它的实现部分分离,使它们都可以独立地变化。使用组合关系代替继承关系,降低抽象和实现两个可变维度的耦合度。
抽象部分和实现部分可能不太好理解,举个例子,香蕉、苹果、西瓜,它们共同的抽象部分就是水果,可以吃,实现部分就是不同的水果实体。再比如黑色手提包、红色钱包、蓝色公文包,它们共同的抽象部分是包和颜色,这部分的共性就可以被作为抽象提取出来。
实例的代码实现
我们可以使用 JavaScript 来将之前的变频洗衣机例子实现一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 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 语法实现一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 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 中应用不多,适当了解即可,不用太死扣。
有时候为了更复用部件,可以将部件的实例化拿出来,对于洗衣机来说一个实体部件当然不能用两次,这里使用皮包的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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()
四、行为型模式 暂时搞不懂,后续理解
五、其他模式 MVC、MVP、MVVM
在下文中,如果某些内容和你看的某本书或者某个帖子上的不一样,不要惊慌,多看几本书,多打开几个帖子,你会发现每个都不一样,所以模式具体是如何表现并不重要,重要的是,了解这三个模式主要的目的和思想是什么:
MVC 模式: 从大锅烩时代进化,引入了分层的概念,但是层与层之间耦合明显,维护起来不容易;
MVP 模式: 在 MVC 基础上进一步解耦,视图层和模型层完全隔离,交互只能通过管理层来进行,问题是更新视图需要管理层手动来进行;
MVVM 模式: 引入双向绑定机制,帮助实现一些更新视图层和模型层的工作,让开发者可以更专注于业务逻辑,相比于之前的模式,可以使用更少的代码量完成更复杂的交互; MVC、MVP、MVVM 模式是我们经常遇到的概念,其中 MVVM 是最常用到的,在实际项目中往往没有严格按照模式的定义来设计的系统,开发中也不一定要纠结自己用的到底是哪个模式,合适的才是最好的。
1. MVC (Model View Controller)
MVC 模式将程序分为三个部分:模型(Model)、视图(View)、控制器(Controller)。
Model 模型层: 业务数据的处理和存储,数据更新后更新;
View 视图层: 人机交互接口,一般为展示给用户的界面;
Controller 控制器层 : 负责连接 Model 层和 View 层,接受并处理 View 层触发的事件,并在 Model 层的数据状态变动时更新 View 层;
MVC 模式的目的是通过引入 Controller 层来将 Model 层和 View 层分离,分层的引入是原来大锅烩方式的改进,使得系统在可维护性和可读性上有了进步。
MVC 模式提出已经有四十余年,MVC 模式在各个书、各个教程、WIKI 的解释有各种版本,甚至 MVC 模式在不同系统中的具体表现也不同,这里只介绍典型 MVC 模式的思路。
典型思路是 View 层通过事件通知到 Controller 层,Controller 层经过对事件的处理完成相关业务逻辑,要求 Model 层改变数据状态,Model 层再将新数据更新到 View层。
在实际操作时,用户可以直接对 View 层的 UI 进行操作,以通过事件通知 Controller 层,经过处理后修改 Model 层的数据,Model 层使用最新数据更新 View。
用户也可以直接触发 Controller 去更新 Model 层状态,再更新 View 层
某些场景下,View 层直接采用观察者/发布订阅模式监听 Model 层的变化,这样 View层和 Model 层相互持有、相互操作,导致紧密耦合,在可维护性上有待提升。由此,MVP 模式应运而生 。
2. MVP (Model View Presenter)
MVP 模式将程序分为三个部分:模型(Model)、视图(View)、管理层(Presenter)。
Model 模型层: 只负责存储数据,与 View 呈现无关,也与 UI 处理逻辑无关,发生更新也不用主动通知 View;
View 视图层: 人机交互接口,一般为展示给用户的界面;
Presenter 管理层 : 负责连接 Model 层和 View 层,处理 View 层的事件,负责获取数据并将获取的数据经过处理后更新 View;
MVC 模式的 View 层和 Model 层存在耦合,为了解决这个问题,MVP 模式将 View 层和 Model 层解耦,之间的交互只能通过 Presenter 层,实际上,MVP 模式的目的就是将 View 层和 Model 层完全解耦,使得对 View 层的修改不会影响到 Model 层,而对 Model 层的数据改动也不会影响到View 层。
典型流程是 View 层触发的事件传递到 Presenter 层中处理,Presenter 层去操作 Model 层,并且将数据返回给 View层,这个过程中,View 层和 Model 层没有直接联系。而 View 层不部署业务逻辑,除了展示数据和触发事件之外,其它时间都在等着 Presenter 层来更新自己,被称为「被动视图」。
在实际操作时,用户可以直接对 View 层的 UI 进行操作,View 层通知 Presenter 层,Presenter 层操作 Model 层的数据,Presenter 层获取到数据之后更新 View。
由于 Presenter 层负责了数据获取、数据处理、交互逻辑、UI 效果等等功能,所以 Presenter 层就变得强大起来,相应的,Model 层只负责数据存储,而 View 层只负责视图,Model 和 View 层的责任纯粹而单一,如果我们需要添加或修改功能模块,只需要修改 Presenter 层就够了。由于 Presenter 层需要调用 View 层的方法更新视图,Presenter 层直接持有 View 层导致了 Presenter 对 View 的依赖。
正如上所说,更新视图需要 Presenter 层直接持有 View 层,并通过调用 View 层中的方法来实现,还是需要一系列复杂操作,有没有什么机制自动去更新视图而不用我们手动去更新呢,所以,MVVM 模式应运而生。
3. MVVM (Model View ViewModel)
MVVM 模式将程序分为三个部分:模型(Model)、视图(View)、视图模型(View-Model)。
和 MVP 模式类似,Model 层和 View 层也被隔离开,彻底解耦,ViewModel 层相当于 Presenter 层,负责绑定 Model 层和 View 层,相比于 MVP 增加了双向绑定机制。
MVVM 模式的特征是 ViewModel 层和 View 层采用双向绑定的形式(Binding),View 层的变动,将自动反映在 ViewModel 层,反之亦然。
但是双向绑定给调试和错误定位带来困难,View 层的异常可能是 View 的代码有问题,也有可能是 Model 层的问题。数据绑定使得一个位置的 Bug 被传递到别的位置,要定位原始出问题的地方就变得不那么容易了。
对简单UI 来说,实现 MVVM 模式的开销是不必要的,而对于大型应用来说,引入 MVVM 模式则会节约大量手动更新视图的复杂过程,是否使用,还是看使用场景。
这是为什么呢,因为 MVVM 模式要求 Model 层和 View 层完全解耦,但是由于 Vue 还提供了 ref 这样的 API,使得 Model 也可以直接持有 View:
但是大多数帖子都说直接称呼 Vue 为 MVVM 框架,可见这些模式的划分也不是那么严格。