JavaScript 设计模式(上)——基础知识

系列链接

  1. JavaScript 设计模式(上)——基础知识
  2. JavaScript 设计模式(中)——1.单例模式
  3. JavaScript 设计模式(中)——2.策略模式
  4. JavaScript 设计模式(中)——3.代理模式
  5. JavaScript 设计模式(中)——4.迭代器模式
  6. JavaScript 设计模式(中)——5.发布订阅模式
  7. JavaScript 设计模式(中)——6.命令模式
  8. JavaScript 设计模式(中)——7.组合模式
  9. JavaScript 设计模式(中)——8.模板方法模式
  10. JavaScript 设计模式(中)——9.享元模式
  11. JavaScript 设计模式(中)——10.职责链模式
  12. JavaScript 设计模式(中)——11. 中介者模式
  13. JavaScript 设计模式(中)——12. 装饰者模式
  14. JavaScript 设计模式(中)——13.状态模式
  15. JavaScript 设计模式(中)——14.适配器模式
  16. JavaScript 设计模式(下)——设计原则
  17. 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 );

在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”;例如,一个对象若有 pushpop 方法,并且这些方法提供了正确的实现,它就可以被当作栈来使用。一个对象如果有 length 属性,也可以依照下标来存取属性(最好还要拥有 slicesplice 等方法),这个对象就可以被当作数组来使用;

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 封装

封装的目的是将信息隐藏,封装包括封装数据、封装实现、封装类型和封装变化;

  1. 封装数据:JavaScript 并没有提供对这些关键字的支持,只能依赖变量的作用域来实现封装特性;ECMAScript 6 中可以使用letSymbol实现;
// 使用函数来创建作用域
var myObject = (function(){
  var __name = 'sven'; // 私有( private)变量
  return {
    getName: function(){ // 公开( public)方法
      return __name;
    }
  }
})();
console.log( myObject.getName() ); // 输出: sven
console.log( myObject.__name ) // 输出: undefined
  1. 封装实现细节:封装使得对象内部的变化对其他对象而言是透明的,不可见的。对象对它自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能;

如迭代器,其作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。如编写了一个 each 函数,它的作用就是遍历一个聚合对象,使用这个 each 函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确便可以。即使 each 函数修改了内部源代码,只要对外的接口或者调用方式没有变化,用户就不用关心它内部实现的改变;

  1. 封装类型:是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。比如工厂方法模式、组合模式等;

在 JavaScript 中,并没有对抽象类和接口的支持。 JavaScript 本身也是一门类型模糊的语言。在封装类型方面, JavaScript 没有能力,也没有必要做得更多。对于 JavaScript 的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。

  1. 封装变化:封装变化是设计模式角度出发的更重要的层面体现;

《设计模式》一书中共归纳总结了 23种设计模式。从意图上区分, 这 23 种设计模式分别被划分为创建型模式、结构型模式和行为型模式。拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。

通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。

1.1.4 原型模式和基于原型继承的JavaScript对象系统

JavaScript 也同样遵守这些原型编程的基本规则:

  • 所有的数据都是对象:
    • JavaScript根对象是 Object.prototype 对象,可以利用 ECMAScript 5 提供的 Object.getPrototypeOf 来查看这两个对象的原型:console.log( Object.getPrototypeOf( {} ) === Object.prototype ); // 输出: true
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它:
    • JavaScript中显式地调用 var obj1 = new Object()或者 var obj2 = {},引擎内部会从Object.prototype 上面克隆一个对象出来
  • 对象会记住它的原型:
    • JavaScript 的对象有一个名为proto的隐藏属性,该对象的__proto__属性默认会指向它的构造器的原型对象,即{Constructor}.prototype;
  • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型;

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.callFunction.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

  1. 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 );
  1. 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
  1. 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 );
  1. 借用其他对象的方法:

函数的参数列表 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 闭包的作用

闭包的形成与变量的作用域以及变量的生存周期密切相关,因而需要了解变量的作用域变量的生存周期

  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. 延续局部变量的寿命:避免局部变量在函数调用后销毁;

1.3.2 高阶函数

满足可以作为参数被传递或可以作为返回值输出的函数称为高阶函数;

  1. 实例:判断数据的类型;
// 方式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
  1. 实例:单例模式中既把函数当作参数传递,又让函数执行后返回了另外一个函数;
var getSingle = function ( fn ) {
  var ret;
  return function () {
    return ret || ( ret = fn.apply( this, arguments ) );
  };
};
  1. 高阶函数的其他应用:
  • 函数柯里化(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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容