第一章 面向对象的 JavaScript

JavaScript 没有提供传统面向对象语言中的类继承,而是通过原型委托的方式来实现对象之间的继承。

正是因为存在跟传统面向对象语言不一致的地方,我们在学习设计模式之前,需要了解 JavaScript 在面向对象方面的知识。

1.1 动态类型

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

静态类型语言在编译时便已确定变量的类型,而动态类型语言的变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。

以下是静态类型语言的优缺点

优点

  • 在编译时就能发现类型不匹配的错误
  • 如果在程序中明确地规定了数据类型,编译器还可以针对这些信息对程序进行一些优化工作,提高程序执行速度

缺点

  • 迫使程序员按照契约来书写代码,为每个变量都规定数据类型,归根结底只是辅助我们编写可靠程序的手段,而不是目的。
  • 类型的声明也会增加更多的代码,让程序员的精力从思考业务逻辑上分散开来

相对于静态类型的语言,动态类型语言也存在以下优缺点:

优点

  • 编写代码少,看起来更简洁
  • 程序员可以把精力更多地放在业务逻辑上

缺点

  • 无法保证变量的类型,从而导致运行时可能发生类型错误

动态语言对变量类型的宽容给实际编码带来了很大的灵活性。由于无需类型检测,所以我们可以更关注对象的行为而非对象本身。

对于静态类型语言来说,想要实现基于鸭子类型(duck typing)的面向接口编程,需要对抽象类进行向上转型,形成一个超类。

而动态语言则不必借助超类型的帮助。

鸭子类型通俗解释:如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。

1.2 多态

多态的含义是,同一操作作用于不同的对象时,可以产生不同的解释和不同的效果。

比如说,Number 和 Object 的原型上都有toString方法,这就是多态的。

用一个实际的例子举例,比如有一只鸭子和一只狗,它们都能够叫,但是他们的叫声并不相同。下面用代码来说明

js
1function Dog() {}
2function Duck() {}
3function sound(animal) {
4  if (animal instanceof Dog) {
5    alert('汪汪汪')
6  }
7  if (animal instanceof Duck) {
8    alert('嘎嘎嘎')
9  }
10}
11sound(new Dog())
12sound(new Duck())

上面的代码就体现了多态性,即相同的操作作用于不同对象,可以产生不同的效果。

但是这样并不能令人满意,因为如果多加几种动物,那么 sound 方法就需要写更多的兼容代码,显得非常冗余。

多态背后的思想是将做什么和谁去做以及怎样做隔离开来,具体做法就是将不变的隔离起来,可变的就封装起来,给予程序扩展的能力

首先我们把不变的部分隔离出来,那就是所有的动物都会发出叫声:

js
1function Dog() {}
2
3function Duck() {}
4
5function sound(animal) {
6  animal.sound()
7}

然后把可变的部分各自封装起来

js
1Dog.prototype.sound = function () {
2  alert('汪汪汪')
3}
4Duck.prototype.sound = function () {
5  alert('嘎嘎嘎')
6}

现在这些动物的原型上都有sound方法,我们直接这样调用即可:

js
1sound(new Duck())
2sound(new Dog())

未来如果需要更多动物,直接增加可变部分的代码就可以了,这段代码可扩展性强,而且非常安全。

1.3 封装

封装的目的是将信息隐藏。

封装不仅包括封装实现、封装数据,还包括封装类型和封装变化。

封装数据

js
1var myObject = (function () {
2  var __name = 'sven' //私有(private)变量
3  return {
4    getName: function () {
5      //公开(public)方法
6      return __name
7    },
8  }
9})()
10
11console.log(myObject.getName()) //输出:sven
12console.log(myObject.__name) //输出:undefined

上面的代码通过函数来将创建作用域,并封装一个私有变量的数据。这是数据层面的封装

封装实现

封装应该被视为任何形式的封装,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。

封装使得对象内部的变化对其他对象而言是透明的,也就是不可见的。对象对他自己的行为负责。其他对象或者用户都不关心它的内部实现。

封装使得对象之间的耦合变得松散,对象之间只通过暴露的 API 来通信。当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。

比如:我们写了一个each函数,它的作用时遍历一个聚合对象,使用这个each函数的人不用关心它的内部是如何实现的,只要它提供的功能正确就可以了。即使修改了内部源代码,只要对外的接口或者调用方式没有变化,用户就不用关心它的内部实现的改变。

封装类型

封装类型是静态类型语言中的一种重要封装方式。一般而言,封装类型是通过抽象类和接口来进行的。

JavaScript 目前没有能力,也没必要做得更多。

封装变化

从设计模式的角度出发,封装在更重要的层面体现为封装变化。

考虑你的设计中哪些地方可能变化,这种方式会与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。——设计模式

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

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

在以类为中心的面向对象编程语言中,只能从类创建一个对象。

但在原型编程思想中,类并不是必须的,对象也未必需要从类中创建而来,一个对象是通过克隆另外一个对象所得到的。

原型模式不单单是一种设计模式,也被称为编程泛型。

1.4.1 使用克隆的原型模式

从设计模式的角度讲,原型模式是用于创建对象的一种模式。创建一个对象,有两种方法:

  • 一先指定它的类型,然后通过类来创建这个对象。
  • 原型模式选择了另外一种方式,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。

原型模式的实现关键,在于语言本身是否提供了 clone 方法,ES5 提供了Object.create这个 API 来克隆对象,比如下面的代码

js
1var Plane = function () {
2  this.blood = 100
3  this.attackLevel = 1
4  this.defenseLevel = 1
5}
6
7var plane = new Plane()
8plane.blood = 500
9plane.attackLevel = 10
10plane.defenseLevel = 7
11
12var clonePlane = Object.create(plane)
13console.log(clonePlane)
14console.log(clonePlane.blood) //输出500
15console.log(clonePlane.attackLevel) //输出10
16console.log(clonePlane.defenseLevel) //输出7

Object.create(targetObject)会新创建一个对象,并把 targetObject 的属性和方法与新创建对象的__proto__做连接。

1.4.2 克隆是创建对象的手段

原型模式的真正目的并非在于要创建一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。

在 JavaScript 这种类型模糊的语言中,创建对象非常容易,也不像 java 存在类型耦合的问题。从设计模式的角度来讲,原型模式的意义并不算大。但 JavaScript 本身是基于原型的面向对象语言。它的对象系统就是使用原型模式来搭建的,在这里称之为原型编程泛型也许更合适。

1.4.4 原型编程范型的一些规则

基于原型链的委托机制就是原型继承的本质。

原型编程的重要特性:当某个对象无法响应某个请求时,会把该请求委托给它自己的原型。

原型编程至少包含以下规则:

  • 所有数据都是对象
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
    • 对象会记住它的原型
    • 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型

1.4.5 JavaScript 中的原型继承

  1. 所有数据都是对象

    按照 JavaScript 设计者的本意,除了 undefined 之外,一切都应是对象。为了实现这一目标,number、boolean、string 这几种基本类型数据也可以通过“包装类”的方式变成对象类型数据来处理。

    JavaScript 绝大多数数据都是对象,JavaScript 中也有一个根对象Object.prototype,这些对象都来自于这个根对象。

    我们遇到的每个对象,实际上都是从Object.prototype中克隆而来。Object.prototype就是它们的原型。

    比如下面两个对象

    js
    1let obj = new Object()
    2let obj2 = {}
    3// 通过Object.getPrototypeOf()这个方法获取原型
    4Object.getPrototypeOf(obj) === Object.prototype // true
    5Object.getPrototypeOf(obj2) === Object.prototype // true
  1. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它

    在 JavaScript 中,我们并不需要关心克隆的细节,这是引擎内部实现的。比如下面的代码

    js
    1const obj = {}
    2const obj2 = new Object()

    我们只需要显式地调用它,引擎就会从object.prototype中克隆一个对象出来。

    下面我们使用 new 来构造一个对象

    js
    1function Person(name) {
    2  this.name = name
    3}
    4Person.prototype.getName = function () {
    5  return this.name
    6}
    7
    8var a = new Person('qiuyanxi')
    9console.log(a.getName()) // 'qiuyanxi'
    10console.log(Object.getPrototypeOf(a) === Person.prototype) // true

    在这里 Person 并不是类,而是一个构造器,JavaScript 的函数既可以作为普通函数调用,又可以用来当作构造器。当使用 new 运算符来调用函数时,此时函数是一个构造器。用 new 运算符来创建对象的过程,实际上也只是克隆Object.prototype,再进行一些额外的操作。

    下面是模拟一个 new 运算符

    js
    1function myNew(constructorFunc, ...rest) {
    2  const obj = {} // 从Object.prototype中克隆一个对象
    3  obj.__proto__ = constructorFunc.prototype //把克隆下的对象的__proto__与构造器的原型做连接
    4  const result = constructorFunc.apply(obj, rest) // 调用构造器函数,this与object绑定
    5  return typeof result === 'object' ? result : obj //需要确保返回出一个对象,这是规范
    6}
  1. 对象会记住它的原型

    如果请求可以在一个链条中依次往后传递,那么每个节点都必须知道它的下一个节点。

    就 JavaScript 真正的实现来说,并不能直接说对象有原型,而是它的构造器有原型。对于对象把请求委托给它自己的原型这句话,更好的说法是对象把请求委托给它的构造器的原型。

    对象如何把请求顺利地转交给它的构造器的原型呢?

    JavaScript 给对象提供了一个名为__prototype__的隐藏属性,这个属性会默认指向它的构造器的原型对象,即{Constructor}.prototype。

    __proto__就是对象跟“对象构造器的原型”联系起来的纽带。正因为对象要通过__proto__属性来记住它的构造器的原型。

  1. 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型

    这条规则是原型继承的精髓所在。

    虽然 JavaScript 的对象最初都是由Object.prototype克隆而来,但对象构造器的原型并不限于Object.prototype,而是动态地指向其他对象。这样一来,当对象 a 需要借用对象 b 的能力时,可以有选择地把对象 a 的构造器的原型指向对象 b,从而达到继承的效果。

    js
    1var obj = {
    2  name: 'qiuyanxi',
    3}
    4var A = function () {}
    5A.prototype = obj // 将函数的原型指向obj
    6var a = new A() // 构造器A构造对象a
    7console.log(a.name) // a通过.__proto__ 访问到A.prototype

    这段代码是这样做的:

    • 遍历对象 a 的所有属性,但是没用找到name属性
    • 查找name属性这个请求委托给对象 a 的构造器 A 的原型,也就是 A.prototype
    • A.prototype被设置成 obj,所以返回obj的 name

    当我们希望一个“类”(实际上是构造器)继承另一个类时,往往会用下面的代码来模拟

    js
    1var A = function () {}
    2A.prototype = { name: 'qiuyanxi' }
    3
    4var B = function () {}
    5B.prototype = new A()
    6// B.prototype =Object.create(A.prototype) 也可以用这个方法
    7var b = new B()
    8console.log(b.name) // "qiuyanxi"

    这段代码是这样做的:

    • 让 B 的原型等于 A 的实例,这样 B 的原型就可以通过__proto__访问到 A 的原型
    • 当 b 需要访问 name 时,遍历 b 身上的属性发现没有,于是请求委托顺着__proto__去访问构造器 B 的原型
    • 此时B.prototype是个空对象,于是继续顺着__proto__去访问构造器 A 的原型
    • A.prototype身上具有 name 属性,于是返回它的值

原型链并不是无限长的,根对象Object.prototype.__proto__是null,说明原型链后面已经没有节点了。

如果请求委托在根对象上依然找不到属性,最终会返回undefined

1.4.6 原型继承的未来

除了根对象Object.prototype外,任何对象都会有一个原型。而通过 Object.create( null )可以创建出没有原型的对象。

Object.create()是原型模式的天然实现。

ES6 带来了新的 class 语法,让 JavaScript 看起来像一门基于类的语言,但其背后依然是基于原型机制创建对象。

以下是简单的代码示例

js
1class Animal {
2  constructor(name) {
3    this.name = name
4  }
5  getName() {
6    return this.name
7  }
8}
9class Dog extends Animal {
10  constructor(name) {
11    super(name)
12  }
13  speak() {
14    return 'jimi'
15  }
16}
17var dog = new Dog('jimi')
18
19console.log(dog.getName() === dog.speak()) // true

1.4.7 小结

原型模式是一种设计模式,也是一种编程泛型。它构成了 JavaScript 这门语言的根本。

原型模式在于克隆,克隆是便捷创建对象的一种手段。

原始的克隆是直接按照根对象Object.prototype来克隆出一个新的对象,新对象的__proto__属性会访问到它的原型,即根对象。

JavaScript 中的原型还可以根据构造函数来指定原型,通过请求委托的方式来实现原型的继承,每个请求都会通过对象的__proto__属性委托给它的原型处理,形成一个原型链条。

原型链条并不是无限长的,它的终点指向 null,表示链表节点的结束。