浓缩解读《JavaScript 设计模式与开发实践》①

2,367 阅读18分钟
原文链接: www.jianshu.com

f2deb48f8c5494ee6870cfaf2af5e0fe99257e32.jpg

面向对象的JavaScript

1.1 动态类型语言和鸭子类型

  • 按照数据类型,编程语言可以分为两大类:静态类型语言动态类型语言
  • 静态类型语言在编译时就已确定变量的类型,而动态类型语言要在程序运行时,等变量被赋值后,才确定数据类型。
  • 静态类型语言的优点:
    1. 可以帮助开发者在编译时检查类型错误;
    2. 在运行前明确了数据类型,编译器可以针对程序进行优化,提升性能;
  • 静态类型语言的缺点:强迫开发者依照契约编写程序,繁杂的类型声明会增加更多的代码,分散开发者的精力。
  • 动态类型语言的优点: 编写的代码量更少,利于阅读,让开发者更专注于业务逻辑。
  • 动态类型语言的缺点:
    1. 不区分数据类型的情况下,可能会让程序难以理解;
    2. 由于无法保证变量的数据类型,运行期间可能会发生于类型相关的错误;
  • JavaScript是典型的动态类型语言,在对变量赋值时不需要考虑它的类型。动态类型语言对变量类型的宽容给实际编码带来了很大的灵活性,这一切构建在鸭子类型(duck typing)的概念上。
  • 鸭子类型有这样一个故事:国王要组建一个100只鸭子组成的合唱团,找到99只鸭子了,还差一只。最后发现有一只非常特别的鸡,叫声跟鸭子一模一样,最后把这只鸡加入了合唱团。通俗的说法是指,“如果它走起来像鸭子,叫起来也是鸭子,那么它就是鸭子”。鸭子类型指导思想是说“应该关注对象的行为,而不是对象的本身。也就是说要关注HAS-A,而不是IS-A”。(注意单词has 和 is)
  • 鸭子类型的概念在动态类型语言的面相对象设计中至关重要,通过鸭子类型可以实现一个原则“面向接口编程,而不是面向实现编程”。例如,一个对象如果拥有push和po方法,它可以被当做栈来使用;一个对象如果有length属性,且可以通过下标对属性进行赠删改查,那这个对象就可以当做数组来使用。(如果A拥有某个对象的接口方法,那就可以认为A是对象的实例)
  • 由于JavaScript中“面向接口编程”的过程与主流的静态类型语言不一样,导致JavaScript在实现设计模式的过程也与主流的静态类型语言大相径庭。

1.2 多态

1.2.1 什么是多态?

  • polymorphism [ˌpɒlɪ'mɔ:fɪzəm](多态)一词源于希腊文,拆解开来是poly(复数)和morph(形态)两个单词的。它实际含义是指“同样一个操作,作用于不同的对象,可以产生不同的解释,并返回不同的执行结果”
  • 用多态来举例:主人有一只猫和一只狗,当主任向它们发出“叫”的命令时,不同的动物会以自己的方式来发出叫声,狗会汪汪叫,猫会喵喵叫。
    //定义一个发声的方法,传入一个animal参数
    var makeSound = function(animal){
       //判断animal的实例是什么动物,就发出什么叫声
       if(animal instanceof Gog){
         console.info("汪汪汪");
       }else if(animal instanceof Cat){
         console.info("汪汪汪");
       }
    }
    //定义两个动物
    var Dog = function(){};
    var Cat = function(){};
    //调用方法
    makeSound(new Dog());
    makeSound(new Cat());
    但示例1存在这样的问题:如果要增加一个动物(比如牛),则必须要改动makeSound函数了。要知道修改代码总是危险的,修改的地方越多,程序出错的可能性就越大。并且当动物种类越来越多,makeSound函数变得非常巨将大。
  • 而多态背后的核心思想是“将不变的事物和可能变化的事物分离开来”。例子中,动物都会叫是不变的,但不同类型的动物具体怎么叫是可变的,我们可以将不变的部分隔离出来,把可变的部分封装起来,让程序符合开放-封闭原则。
    //统一的makeSound函数调用入口
    var markSound = function(animal){
        //将具体怎么叫,封装成动物的方法
       animal.sound();    
    };
    //鸭子
    var Duck = function(){ };
    Duck.prototype.sound = function(){
       console.info("嘎嘎嘎");
    };
    //小鸡
    var Chicken = function(){ };
    Chicken.prototype.sound = function(){
       console.info("咯咯咯");
    };
    makeSound(new Duck());  //嘎嘎嘎
    makeSound(new Chicken());    //咯咯咯

1.2.2 多态引发的类型检查问题

  • 谈到多态,类型检查是绕不开的话题,但JavaScript是一门不比进行类型检查的动态类型语言,不像静态类型语言。
  • 以Java为例,代码编译时会进行严格的类型检查,不能给变量赋予不同类型的值。
    String str;  //定义一个String类型的变量
    str = "abc";  //赋值成功
    str = 123;  //报错!
    通过Java实现鸭子类型:
    //声明一个鸭子类 
    public class Duck{
     public void makeSound(){
    System.out.println("嘎嘎嘎");
     }
    }
    //声明一个小鸡类
    public class Chicken{
     public void makeSound(){
    System.out.println("咯咯咯");
     }
    }
    //呼叫动物类
    public class AnimalSound{
     public void makeSound(Duck duck){
    duck.makeSound();
     }
    }
    //测试类
    public class Test{
     public static void main(String args[]){
    AnimalSound animalSound = new AnimalSound();
    animalsound.makeSound(new Duck());  //输出:嘎嘎嘎
     }
    }
    虽然顺利让鸭子发出叫声,但如果想让小鸡叫唤,发现几乎是不可能实现,因为Animal.makeSound()方法规定了只接受Duck类型的参数。
    针对这种情况,静态类型的编程语言通常被设计成可以向上转型:当给一个类变量赋值时,既可以使用类本身,也可以使用这个类的超类。好比我们描述天上的一直麻雀时,既可以说“有一只麻雀在天上飞”,也可以说“有一只鸟在天上飞”。
  • 通过继承实现多态的效果是最常用的手段。继承通常包含实现继承接口继承,这里通过实现继承重新调整鸭子类型的代码。

    //定义动物抽象类
    public abstract class Animal{
     abstract void makeSound(); //makeSound抽象方法
    }
    //小鸡实现类
    public class Chicken extends Animal{
     public void makeSound(){
    System.out.println("咯咯咯");
     }
    }
    //小鸭实现类
    public class Duck extends Animal{
     public void makeSound(){
    System.out.println("嘎嘎嘎");
     }
    }
    //呼叫动物类
    public class AnimalSound{
        public void makeSound(Animal animal){
           animal.makeSound();
        }
    }
    //测试类
    public class Test{
        public static void main(String args[]){
         AnimalSound animalSound = new AnimalSound();
         animalsound.makeSound(new Duck());  //输出:嘎嘎嘎
         animalsound.makeSound(new Chicken());  //输出:咯咯咯
        }
    }

1.2.3 JavaScript的多态

  • 多态之所以要把“不变的事物”和“可能改变的事物”分离,是为了消除类型之间的耦合,Java通过向上转型来实现。而由于JavaScript的变量类型在运行期是可变的,意味着JavaScript对象的多态性是与生俱来的 。判断动物是否能发出叫声,不需要判断对象是某种类型的动物,只取决于它有没有makeSound方法,不存在任何程度的“类型耦合”。

1.2.4 多态在面向对象程序设计中的作用

  • 多态最根本的作用是消除条件分之语句,将过程化的条件分之语句转化为对象的多态性。
  • Martin Fowler在《重构:改善既有代码的设计》书中以拍电影作为多态的比喻。
    • 电影在拍摄时,当导演喊出“action”后,主演门开始讲台词,灯光师负责打灯,群众演员假装中枪倒地,道具师往镜头撒雪花。在等到导演的指令后,每个对象都知道自己应该做什么,这就是多态性。
    • 如果不利用对象的多态性,而是用面向过程的方式上来编写代码,那么就相当于:每次电影开始拍摄后,导演要逐个走到每个人的面前,确认它们的职业分工(类型),然后再告诉他们要做什么。映射到程序当中,那么程序中将充斥着大量条件分之语句。
  • 每个对象应该做什么,封装在成对象内部的一个方法,每个对象负责自己的行为。所以这些对象可以根据同一个指令,有条不紊地分别进行各自的工作,这正是面向对象的优点。

1.2.5 多态与设计模式

  • GoF的《设计模式》一书从面向对象设计的角度出发,通过对封装、继承、多态、组合等多种技术的反复使用,提炼出可重复使用的面向对象设计技巧。多态是当中的重中之重,绝大多数设计模式的实现都离不开多态性的思想。
  • 比如命令模式,请求被封装在一些命令对象中,这使得命令的调用和命令的接受者可以完全解耦开来,当调用execute方法时,不同的命令做不同的事情,从而产生不同的执行结果。
  • 在组合模式中,对组合对象和叶节点对象发出同一个指令时,它们会各自做自己应该做的事情,组合对象把消息继续传递给下面的叶节点对象,叶节点再对指令做出响应。
  • 在策略模式中,Context并没有执行算法的能力,而是把职责委托给具体的策略对象。每个策略对象负责的算法被封装在各自对象的内部。当对这些策略对象发出计算的指令时,它们会个各自执行并响应不同的计算结果。

1.3 封装

1.3.1 封装数据

  • 很多编程语言是通过语法解析来实现封装数据的,比如Java提供private、public、protected等关键字来限定访问权限。
  • 可JavaScript缺乏这些关键字的支持,只能依赖变量的作用域来实现public和private的封装特性。
    var myObject = (function(){
      var _name = 'sven';    //私有变量
      return {
          //公开方法
          getName : function(){
              return _name;
          }
      };
    })();
  • (ECMAScripte6标准,提供了let关键字来创建块级作用域)

1.3.2 封装实现

  • 很多人喜欢把封装理解成封装数据,这是一种狭义的定义。其实封装不仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
  • 封装实现细节指的是,使得对象内部的变化对于其他对象而言是不可见的,对象只对自己的行为负责。对象之间的耦合变松散,对象之间只通过暴露的API接口来通讯。这样一来,即便当我们需要修改对象时,可以任意修改它的内部实现,而由于对外接口没有变化,则不会影响程序的其他功能。
  • 比如迭代器each()函数,不用关心它的内部是怎么实现的,只需要知道它的作用是遍历集合对象。及时each函数修改了内部源代码,主要调用方式没有变化,就不会对调用each()函数的代码造影响。

1.3.3 封装类型

  • 封装类型是类似Java等静态类型语言中一种重要的封装方式。一般通过抽象类和接口来进行。把对象的真正类型隐藏在抽象类或者接口背后,这样对于调用者来说,就看看呀只关心对象的行为,而不是对象的类型。
  • 由于静态语言需要想方设法的隐藏对象的类型,也促使了比如工厂方法模式、组合模式等设计模式的诞生。
  • JavaScript本身是一门类型模糊的语言。对于JavaScript的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。

1.3.4 封装变化

  • 从设计模式的角度出发,封装的更高层面体现为封装变化。
  • 《设计模式》提到“找到变化,并封装之”,《设计模式》一书中总共归纳总结了23中设计模式,这23种设计模式又可以从意图上区分为创建型模式、结构型模式和行为型模式。
  • 拿创建型模式来说,具体创建什么对象是变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。通过封装变化,可以最大程度的保证程序的稳定性和可拓展性。

1.4 原型模式和JavaScript

  • Brendan Eich设计JavaScript时,之所以选择原型的面向对象系统,是因为从一开始就没有打算在JavaScript中加入类的概念。
  • 以类作为中心的面向对象编程语言当中,比如Java,类和对象的关系可以想象成铸模和铸件的关系,对象是从类中创建而来的。而在原型编程的思想当中,类不是必须的,对象是通过克隆另一个对象得到的。

1.4.1 使用克隆的原型模式

  • 原型模式是创建对象的一种模式。
  • 相比起Java,创建一个对象要先指定它的类型,然后通过类来创建这个对象。原型模式不在关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象,就好比游戏中的分身。
  • 说到克隆,原型模式的实现关键在于编程语言是否提供clone方法,比如ECMAScript5提供的Object.create()方法。

1.4.2 克隆是创建对象的手段

  • 原型模式的真正目的并不是为了复制一个一模一样的对象,而是提供一种便捷的方式去创建某个类型的对象,而克隆只是创建对象的手段。
  • 依赖倒置原则提醒开发者,编写像Java等静态型语言的程序时,创建对象要避免依赖具体的类型,比如new XXX创建对象的方式会使得类型之间的耦合度很高,代码很僵硬。需要通过工厂方法模式和抽象工厂模式解决此类问题,但这无可避免的,会增加很多额外的代码。
  • 原型模式则提供了另外一种方式,通过克隆对象,不需要再关心对象的具体类型名称,所以也就不存在类型耦合的问题。
  • JavaScript本身是一门基于原型的面型对象语言,它的面向对象系统通过原型模式来搭建,所以与其称为原型模式,不如称之为原型编程范例更为合适。

1.4.3 原型模式的Io语言

  • 事实上,使用原型模式来构建面向对象系统的编程语言,并非仅有JavaScript一家。还有比如Self语言、Smalltalk语言,以及另一个轻巧的Io语言。
  • Io中同样没有类的概念每个对象都是基于另外一个对象的克隆。既然每个对象都是由其他对象克隆而来,那么Io语言本身应该至少要提供一个根对象,其他对象都发源于这个根对象才对,就好像美剧吸血鬼的始祖一样。对的,Io语言根对象是Object。
  • 继续拿动物世界的例子讲解Io语言
    //通过克隆Object根对象得到Animal对象,所以Obejct称为Animal的原型
    Animal := Object clone;
    //给Animal对象添加makeSound方法
    Animal makeSound := method("animal makeSound" print);
    //接下来以Animal作为原型,继续创建Dog对象
    Dog := Animal clone;
    //然后给Dog对象添加eat方法
    Dog eat := method("dog eat" print);
    //最后测试Animal对象和Dog对象的功能
    Animal makeSound;    //输出"animal makeSound"
    Dog eat;   //输出"dog eat"

1.4.4 原型编程的特点

  • 从Io语言的使用当中可看出,跟使用“类”的语言不同的是,原型编程语言最初只有一个根对象(Object),其他对象都是克隆自另一个对象。
  • 在上一个例子当中,Object是Animal的原型,Animal的Dog的原型,它们串联起来形成了一条原型链。
  • 原型链是很有用处的,当尝试调用Dog对象的某个方法,而它本身又没有时,那么Dog对象会把调用的请求委托给它的原型Animal对象。如果Animal对象也没有的话,请求会继续顺着原型链,被委托给Object对象。这样一来,便能得到继承的效果,看起了就像是Animal是Dog的父类,Object是Animal的父类。这个机制并不复杂却非常强大,JavaScript和Io语言一样,原型继承的本质就是基于原型链的委托机制。
  • 最后我们观察发现,原型编程泛型包括以下的特点:
    • 所有的数据都是对象;
    • 不通过实例化创建对象,而是找到一个对象作为原型并克隆它;
    • 对象会记住个各自的原型;
    • 如果对象无法响应某个请求,会把这个请求委托给自己的原型;

1.4.5 原型模式的JavaScript语言

  • 接下来讲解JavaScript如何基于原型编程的规则来构建面向对象系统。
  • 所有的数据都是对象

    • JavaScript在设计的时候模仿了Java,数据类型分为基本类型和对象类型。

    • 基本数据类型有boolean、number、string、null、undefined。

    • 按照JavaScript设计者的本意,除了undefined之外,其他都是对象。而为了实现这一目标,基本数据类型也可以通过“包装类”的方式变成对象类型来处理。
  • 不通过实例化创建对象,而是找到一个对象作为原型并克隆它

    • 相比起Io语言,JavaScript中不需要关心克隆的细节,JavaScript引擎内部会处理。我们只需要显式的调用var obj1 = new Object();或者var obj2 = {};,JavaScript引擎就会从Object.prototype上克隆一个对象出来。
    • 演示用new运算符从构造器中得到一个对象

      function Person(name){
        this.name = name;
      };
      Person.prototype.getName = function(){
        return this.name;
      };
      var person1 = new Person('William');
      console.log(person1.name);   //输出”William“
      console.log(person1.getName());   //输出”William“
      console.log(Object.getPrototypeOf(person1) === Person.prototype);   //输出"true“
    • 这里的Person不是类,而是构造函数。JavaScript的函数既可以作为普通函数被调用,也可以作为构造函数被调用。当使用new关键字调用函数时,此函数就是一个构造器。

    • 当你使用new操作符调用构造函数时,会经历以下步骤:
      1. 创建一个空对象,作为将要返回的实例对象;
      2. 将空对象的原型指向构造函数的prototype属性,也就是Keith构造函数的prototype属性;
      3. 将空对象赋值给构造函数内部的this关键字,也就是this关键字会指向实例对象;
      4. 开始执行构造函数内部的代码;
  • 对象会记住个各自的原型
    • 要实现Io语言或者JavaScript语言中的原型链查找机制,每个对象至少应该先记住自己的原型对象。
    • 但就JavaScript真正的实现来说,其实说对象有原型并我准确,应该说对象的构造器有原型。对于“对象把请求委托给自己的原型”这句话,更好的说法应该是“对象把请求委托给它的构造器的原型”。
      person1.constructor指向Person,然后Person.prototype指向原型对象;
      或者
      person1.[[Prototype]]指向Person.Person.prototype原型对象([[Prototype]]属性和书中所写的__proto__属性一致)
  • 如果对象无法响应某个请求,会把这个请求委托给自己的原型
    • Io语言中每个对象都可以作为原型被克隆,Animal对象克隆自Object对象,Dog对象又克隆自Animal对象,形成了一条天然的原型链。但这样就只是单一的继承连,这样的面向对象系统显得非常受限。
    • 实际上JavaScript的对象最初都是由Object.prototype对象克隆而来,但不受限于Obejct.prototype,而是可以动态的指向其他对象。
    • 在原型链查找机制中,原型链并不是无限长的。当尝试访问对象的某个属性,请求会被委托给各自的原型对象,如果最终传递到Object.prototype对象也没有查找到。这次请求会就此打住,返回undefined。

1.4.6 原型继承的未来

  • 设计模式很多时候其实是在弥补语言的不足之处,就像Peter Norvig曾说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。
  • JavaScript中用Object.create()来完成原型继承,看起来更能体现原型模式的精髓。但效率却不高,比通过构造函数来创建对象要慢。而ECMAScript6带来了新的Class语法。
    class Animal{
      constructor(name){
          this.name = name;
      }
      getName(){
          return this.name;
      }
    }
    class Dog extends Animal{
      constructor(name){
         super(name);
      }
      speak(){
          return "woof";
      }
    }
    var dog = new Dog("Scamp");
    console.log(dog.getName() + ' says' + dog.speak());