系列链接
- JavaScript 设计模式(上)——基础知识
- JavaScript 设计模式(中)——1.单例模式
- JavaScript 设计模式(中)——2.策略模式
- JavaScript 设计模式(中)——3.代理模式
- JavaScript 设计模式(中)——4.迭代器模式
- JavaScript 设计模式(中)——5.发布订阅模式
- JavaScript 设计模式(中)——6.命令模式
- JavaScript 设计模式(中)——7.组合模式
- JavaScript 设计模式(中)——8.模板方法模式
- JavaScript 设计模式(中)——9.享元模式
- JavaScript 设计模式(中)——10.职责链模式
- JavaScript 设计模式(中)——11. 中介者模式
- JavaScript 设计模式(中)——12. 装饰者模式
- JavaScript 设计模式(中)——13.状态模式
- JavaScript 设计模式(中)——14.适配器模式
- JavaScript 设计模式(下)——设计原则
- JavaScript 设计模式练习代码
本文主要参考了《JavaScript设计模式和开发实践》一书
设计模式的一些知识:
- 设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案,通俗说设计模式是在某种场合下对某个问题的一种解决方案;
- 学习模式的作用:模式是一些经过了大量实际项目验证的优秀解决方案,熟悉这些模式的程序员会对某些模式的理解也许形成了条件反射,当合适的场景出现时,就可以很快地找到某种模式作为解决方案;
- 设计模式的适用性:所有设计模式的实现都遵循一条原则,即“找出程序中变化的地方,并将变化封装起来”,一个程序的设计总是可以分为可变的部分和不变的部分,将可变的封装起来;模式应该用在正确的地方,只有在我们深刻理解了模式的意图之后,再结合项目的实际场景才会知道是否算正确应用;
- 分辨模式的关键是意图而不是结构:有很多模式的类图和结构确实很相似,但这不太重要,辨别模式的关键是这个模式出现的场景,以及为我们解决了什么问题,例如区别代理模式和装饰者模式,策略模式和状态模式,策略模式和智能命令模式;
一. 基础知识
JavaScript 没有提供传统面向对象语言中的类式继承,而是通过原型委托的方式来实现对象与对象之间的继承;
1.1 面向对象的JavaScript
1.1.1 动态类型语言和鸭子类型
静态类型语言: 编译时便已确定变量的类型;
优点:编译时就能发现类型不匹配的错误,编辑器可以帮助提前避免程序在运行期间有可能发生的一些错误;
缺点:依照强契约来编写程序,为每个变量规定数据类型,类型的声明也会增加更多的代码;
动态类型语言:到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型;
优点:编写的代码数量更少,整体代码量越少,专注于逻辑表达;
缺点:无法保证变量的类型,从而在程序的运行期有可能发生跟类型相关的错误;
鸭子类型:只关注对象的行为,而不关注对象本身,也就是关注 HAS-A, 而不是 IS-A;
var duck = {
duckSinging: function(){ console.log( '嘎嘎嘎' ); }
};
var chicken = {
duckSinging: function(){ console.log( '嘎嘎嘎' ); }
};
var choir = [];
var joinChoir = function( animal ){
if ( animal && typeof animal.duckSinging === 'function' ){
choir.push( animal );
console.log( '恭喜加入, 已有成员数量:' + choir.length );
}
};
joinChoir( duck );
joinChoir( chicken );
在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”;例如,一个对象若有 push 和 pop 方法,并且这些方法提供了正确的实现,它就可以被当作栈来使用。一个对象如果有 length 属性,也可以依照下标来存取属性(最好还要拥有 slice 和 splice 等方法),这个对象就可以被当作数组来使用;
1.1.2 多态
含义:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。
多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与 “可能改变的事物”分离开来;
var makeSound = function( animal ){
animal.sound();
};
var Duck = function(){};
Duck.prototype.sound = function(){
console.log( '嘎嘎嘎' );
};
makeSound( new Duck() ); // 嘎嘎嘎
var Chicken = function(){};
Chicken.prototype.sound = function(){
console.log( '咯咯咯' );
};
makeSound( new Chicken() ); // 咯咯咯
多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句;
var googleMap = {
show: function() { console.log("开始渲染谷歌地图"); }
};
var baiduMap = {
show: function() { console.log("开始渲染百度地图"); }
};
var renderMap = function(map) {
if (map.show instanceof Function) { map.show(); }
};
renderMap( googleMap ); // 输出:开始渲染谷歌地图
renderMap( baiduMap ); // 输出:开始渲染百度地图
绝大部分设计模式的实现都离不开多态性的思想,例如:
-
命令模式:请求被封装在一些命令对象中,这使得命令的调用者和命令的接收者可以完全解耦开来,当调用命令的
execute
方法时,不同的命令会做不同的事情,从而会产生不同的执行结果。而做这些事情的过程是早已被封装在命令对象内部的,作为调用命令的客户,根本不必去关心命令执行的具体过程。 - 组合模式:多态性使得客户可以完全忽略组合对象和叶节点对象之前的区别,这正是组合模式最大的作用所在。对组合对象和叶节点对象发出同一个消息的时候,它们会各自做自己应该做的事情,组合对象把消息继续转发给下面的叶节点对象,叶节点对象则会对这些消息作出真实的反馈。
-
策略模式:
Context
并没有执行算法的能力,而是把这个职责委托给了某个策略对象。每个策略对象负责的算法已被各自封装在对象内部。当我们对这些策略对象发出“计算”的消息时,它们会返回各自不同的计算结果。
JavaScript
是将函数作为一等对象的语言,函数本身也是对象,函数用来封装行为并且能够被四处传递。当对一些函数发出“调用”的消息时,这些函数会返回不同的执行结果,这是“多态性”的一种体现,也是很多设计模式在 JavaScript
中可以用高阶函数来代替实现的原因。
1.1.3 封装
封装的目的是将信息隐藏,封装包括封装数据、封装实现、封装类型和封装变化;
- 封装数据:JavaScript 并没有提供对这些关键字的支持,只能依赖变量的作用域来实现封装特性;ECMAScript 6 中可以使用
let
和Symbol
实现;
// 使用函数来创建作用域
var myObject = (function(){
var __name = 'sven'; // 私有( private)变量
return {
getName: function(){ // 公开( public)方法
return __name;
}
}
})();
console.log( myObject.getName() ); // 输出: sven
console.log( myObject.__name ) // 输出: undefined
- 封装实现细节:封装使得对象内部的变化对其他对象而言是透明的,不可见的。对象对它自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能;
如迭代器,其作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。如编写了一个
each
函数,它的作用就是遍历一个聚合对象,使用这个each
函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确便可以。即使each
函数修改了内部源代码,只要对外的接口或者调用方式没有变化,用户就不用关心它内部实现的改变;
- 封装类型:是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。比如工厂方法模式、组合模式等;
在 JavaScript 中,并没有对抽象类和接口的支持。 JavaScript 本身也是一门类型模糊的语言。在封装类型方面, JavaScript 没有能力,也没有必要做得更多。对于 JavaScript 的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。
- 封装变化:封装变化是设计模式角度出发的更重要的层面体现;
《设计模式》一书中共归纳总结了 23种设计模式。从意图上区分, 这 23 种设计模式分别被划分为创建型模式、结构型模式和行为型模式。拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。
1.1.4 原型模式和基于原型继承的JavaScript对象系统
JavaScript 也同样遵守这些原型编程的基本规则:
- 所有的数据都是对象:
- JavaScript根对象是
Object.prototype
对象,可以利用 ECMAScript 5 提供的Object.getPrototypeOf
来查看这两个对象的原型:console.log( Object.getPrototypeOf( {} ) === Object.prototype ); // 输出: true
- JavaScript根对象是
- 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它:
- JavaScript中显式地调用
var obj1 = new Object()
或者var obj2 = {}
,引擎内部会从Object.prototype
上面克隆一个对象出来
- JavaScript中显式地调用
- 对象会记住它的原型:
- JavaScript 的对象有一个名为proto的隐藏属性,该对象的
__proto__
属性默认会指向它的构造器的原型对象,即{Constructor}.prototype
;
- JavaScript 的对象有一个名为proto的隐藏属性,该对象的
- 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型;
1.2 this、 call 和 apply
1.2.1 this 指向
JavaScript 的 this
总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境,即函数中的 this
基本都指向函数的调用者;
this 的指向大致可以分为以下 4 种:
- 作为对象的方法调用:当函数作为对象的方法被调用时, this 指向该对象;
var obj = {
a: 1,
getA: function(){
alert ( this === obj ); // 输出: true
alert ( this.a ); // 输出: 1
}
};
obj.getA();
- 作为普通函数调用:this 总是指向全局对象,在浏览器的 JavaScript 里,这个全局对象是 window 对象;
window.name = 'globalName';
var myObject = {
name: 'sven',
getName: function(){
return this.name;
}
};
var getName = myObject.getName;
console.log( getName() ); // globalName
- 构造器调用:构造器里的
this
就指向返回的这个对象;当new
调用构造器时,且构造器显式地返回了一个object
类型的对象,那么最终会返回这个对象; -
Function.prototype.call
或Function.prototype.apply
调用:动态地改变传入函数的 this;
var obj1 = {
name: 'aaa',
getName: function(){
return this.name;
}
};
var obj2 = {
name: 'bbb'
};
console.log( obj1.getName() ); // 输出: aaa
console.log( obj1.getName.call( obj2 ) ); // 输出: bbb
例子: 改写document.getElementById
方法(注意其中的this
指向document
)
document.getElementById = (function( func ){
return function(){
return func.apply( document, arguments );
}
})( document.getElementById );
var getId = document.getElementById;
var div = getId( 'div1' );
alert (div.id); // 输出: div1
1.2.2 call 和 apply
- call和apply的区别:传入参数形式的不同;
- apply: 接受两个参数,第一个参数指定了函数体内 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组, apply 方法把这个集合中的元素作为参数传递给被调用的函数;
var func = function( a, b, c ){
alert ( [ a, b, c ] ); // 输出 [ 1, 2, 3 ]
};
func.apply( null, [ 1, 2, 3 ] );
- call:call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,
从第二个参数开始往后,每个参数被依次传入函数:
var func = function( a, b, c ){
alert ( [ a, b, c ] ); // 输出 [ 1, 2, 3 ]
};
func.call( null, 1, 2, 3 );
- call和apply的用途:改变函数内部的 this 指向;
var obj1 = { name: 'aaa' };
var obj2 = { name: 'bbb' };
window.name = 'window';
var getName = function(){
alert ( this.name );
};
getName(); // 输出: window
getName.call( obj1 ); // 输出: aaa
getName.call( obj2 ); // 输出: bbb
Function.prototype.bind
函数模拟实现如下:
Function.prototype.bind = function( context ){
var self = this; // 保存原函数
return function(){ // 返回一个新的函数
return self.apply( context, arguments ); // 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
}
};
var obj = { name: 'sven' };
var func = function(){
alert ( this.name ); // 输出: sven
}.bind( obj);
func();
通过 Function.prototype.bind
来“包装” func 函数,并且传入一个对象 context
当作参数,这个 context
对象就是我们想修正的 this
对象。在 Function.prototype.bind
的内部实现中,先把 func
函数的引用保存起来,然后返回一个新的函数。当在将来执行 func
函数时,实际上先执行的是这个刚刚返回的新函数。在新
函数内部, self.apply( context, arguments )
这句代码才是执行原来的 func
函数,并且指定 context对象为 func 函数体内的 this;
Function.prototype.bind
函数升级版本
Function.prototype.bind = function(){
var self = this, // 保存原函数
context = [].shift.call( arguments ), // 需要绑定的 this 上下文
args = [].slice.call( arguments ); // 剩余的参数转成数组
return function(){ // 返回一个新的函数
return self.apply( context, [].concat.call( args, [].slice.call( arguments ) ) );
// 执行新的函数的时候,会把之前传入的 context 当作新函数体内的 this
// 并且组合两次分别传入的参数,作为新函数的参数
}
};
var obj = { name: 'aaa' };
var func = function( a, b, c, d ){
alert ( this.name ); // 输出: aaa
alert ( [ a, b, c, d ] ) // 输出: [ 1, 2, 3, 4 ]
}.bind( obj, 1, 2 );
func( 3, 4 );
- 借用其他对象的方法:
函数的参数列表 arguments 是一个类数组对象,虽然它也有“下标”,但它并非真正的数组,所以也不能像数组一样,进行排序操作或者往集合里添加一个新的元素。这种情况下常会借用 Array.prototype
对象上的方法。比如想往 arguments
中添加一个新的元素,通常会借用 Array.prototype.push
:
(function(){
Array.prototype.push.call( arguments, 3 );
console.log ( arguments ); // 输出[1,2,3]
})( 1, 2 );
在操作 arguments 的时候,我们经常非常频繁地找 Array.prototype
对象借用方法;
1.3 闭包和高阶函数
1.3.1 闭包的作用
闭包的形成与变量的作用域以及变量的生存周期密切相关,因而需要了解变量的作用域
和变量的生存周期
;
- 封装变量:闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”;
// 例如:mult 函数接受 number 类型的参数,并返回这些参数的乘积,并对该函数加入缓存机制;
var cache = {};
var mult = function(){
var args = Array.prototype.join.call( arguments, ',' );
if ( cache[ args ] ){
return cache[ args ];
}
var a = 1;
for ( var i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return cache[ args ] = a;
};
alert ( mult( 1,2,3 ) ); // 输出: 6
alert ( mult( 1,2,3 ) ); // 输出: 6
// 方式2:使用闭包避免 cache 变量和 mult 函数一起平行地暴露在全局作用域下
var mult = (function(){
var cache = {};
return function(){
var args = Array.prototype.join.call( arguments, ',' );
if ( args in cache ){
return cache[ args ];
}
var a = 1;
for ( var i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return cache[ args ] = a;
}
})();
// 提炼其中的乘积函数
var mult = (function(){
var cache = {};
var calculate = function(){ // 封闭 calculate 函数
var a = 1;
for ( var i = 0, l = arguments.length; i < l; i++ ){
a = a * arguments[i];
}
return a;
};
return function(){
var args = Array.prototype.join.call( arguments, ',' );
if ( args in cache ){
return cache[ args ];
}
return cache[ args ] = calculate.apply( null, arguments );
}
})();
- 延续局部变量的寿命:避免局部变量在函数调用后销毁;
1.3.2 高阶函数
满足可以作为参数被传递或可以作为返回值输出的函数称为高阶函数;
- 实例:判断数据的类型;
// 方式1. 基本实现
var isString = function( obj ){
return Object.prototype.toString.call( obj ) === '[object String]';
};
var isArray = function( obj ){
return Object.prototype.toString.call( obj ) === '[object Array]';
};
var isNumber = function( obj ){
return Object.prototype.toString.call( obj ) === '[object Number]';
};
// 方式2:字符串作为参数,进行函数封装
var isType = function( type ){
return function( obj ){
return Object.prototype.toString.call( obj ) === '[object '+ type +']';
}
};
var isString = isType( 'String' );
var isArray = isType( 'Array' );
var isNumber = isType( 'Number' );
console.log( isArray( [ 1, 2 ] ) ); // 输出: true
// 方式3:用循环语句,来批量注册 isType 函数
var Type = {};
for ( var i = 0, type; type = [ 'String', 'Array', 'Number' ][ i++ ]; ){
(function( type ){
Type[ 'is' + type ] = function( obj ){
return Object.prototype.toString.call( obj ) === '[object '+ type +']';
}
})( type )
};
Type.isArray( [] ); // 输出: true
Type.isString( "str" ); // 输出: true
- 实例:单例模式中既把函数当作参数传递,又让函数执行后返回了另外一个函数;
var getSingle = function ( fn ) {
var ret;
return function () {
return ret || ( ret = fn.apply( this, arguments ) );
};
};
- 高阶函数的其他应用:
- 函数柯里化(function currying):currying 又称部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值;
// 实例:计算总数,只需要最后一次返回整个结果;
var currying = function( fn ){
var args = [];
return function(){
if ( arguments.length === 0 ){
return fn.apply( this, args );
}else{
[].push.apply( args, arguments );
return arguments.callee;
}
}
};
var cost = (function(){
var money = 0;
return function(){
for ( var i = 0, l = arguments.length; i < l; i++ ){
money += arguments[ i ];
}
return money;
}
})();
var cost = currying( cost ); // 转化成 currying 函数
cost( 100 ); // 未真正求值
cost( 200 ); // 未真正求值
alert ( cost() ); // 求值并输出: 400