目录

第六章 - 面向对象的程序设计

本章内容

  • 理解对象属性
  • 理解并创建对象
  • 理解继承

ECMA-262 把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”严格来讲,这就相当于说对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射到一个值。

6.1 理解对象

上一章曾经介绍过,如何创建一个 Object 的实例:

var person = new Object(); 
person.name = "Nicholas"; 
person.age = 29;  
person.job = "Software Engineer"; 
 
person.sayName = function(){ 
    alert(this.name);  
}; 

后来,使用对象字面量语法来写:

var person = { 
    name: "Nicholas",  
    age: 29, 
    job: "Software Engineer", 
 
    sayName: function(){ 
        alert(this.name); 
    } 
}; 

6.1.1 属性类型

ECMAScript 中有两种属性:数据属性和访问器属性。

  • 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。每个数据属性有4个描述其行为的特性:

  • [[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
  • [[Enumerable]]:表示能否通过 for-in 循环返回属性。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
  • [[Writable]]:表示能否修改属性的值。像前面例子中那样直接在对象上定义的属性,它们的这个特性默认值为 true。
  • [[Value]]:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为 undefined。
var person = { 
 name: "Nicholas" 
};

这里创建了一个名为 name 的属性,[[Value]]特性将被设置为"Nicholas",其它三个属性默认为true。 要修改属性默认的特性,必须使用 ECMAScript 5 的 Object.defineProperty()方法。这个方法接收三个参数:属性所在的对象、属性的名字和一个描述符对象。

var person = {}; 
Object.defineProperty(person, "name", { 
    writable: false, // 不可写,只读
    value: "Nicholas"  
});

configurable 设置为 false,表示不能从对象中删除属性。而且,一旦把属性定义为不可配置的,就不能再把它变回可配置了。多数情况下,可能都没有必要利用 Object.defineProperty(),不过对于帮助理解JS对象是有用的。

  • 访问器属性

访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter 函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下 4 个特性:

  • [[Configurable]]:表示能否通过 delete 删除属性从而重新定义属性。默认ture
  • [[Enumerable]]:表示能否通过 for-in 循环返回属性。默认ture
  • [[Get]]:在读取属性时调用的函数。默认值为 undefined。
  • [[Set]]:在写入属性时调用的函数。默认值为 undefined。

访问器属性不能直接定义,必须使用 **Object.defineProperty()**来定义:

var book = { 
    _year: 2004,  
    edition: 1 
}; 
 
Object.defineProperty(book, "year", { 
    get: function(){ 
        return this._year; 
    }, 
    set: function(newValue){ 
 
        if (newValue > 2004) { 
            this._year = newValue; 
            this.edition += newValue - 2004; 
        } 
    } 
}); 
 
book.year = 2005; 
alert(book.edition);  //2

其中"year"就是访问器属性year,包含getter函数和setter函数。

6.1.2 定义多个属性

Object.defineProperties()可以通过描述符一次定义多个属性。

var book = {}; 
 
Object.defineProperties(book, { 
    _year: {  
        value: 2004 
    }, 
 
    edition: { 
        value: 1 
    }, 
 
    year: { 
        get: function(){ 
            return this._year; 
        }, 
 
        set: function(newValue){ 
            if (newValue > 2004) { 
                this._year = newValue; 
                this.edition += newValue - 2004; 
            } 
        } 
    } 
}); 

6.1.3 读取属性的特性

使用 ECMAScript 5 的 Object.getOwnPropertyDescriptor()方法,可以取得给定属性的描述符。这个方法接收两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,如果是访问器属性,这个对象的属性有 configurable、enumerable、get 和 set;如果是数据属性,这个对象的属性有 configurable、enumerable、writable 和 value。

// 接上一代码块
var descriptor = Object.getOwnPropertyDescriptor(book, "_year"); 
alert(descriptor.value);         //2004 
alert(descriptor.configurable); //false
alert(typeof descriptor.get);    //"undefined" 
 
var descriptor = Object.getOwnPropertyDescriptor(book, "year"); 
alert(descriptor.value);        //undefined 
alert(descriptor.enumerable);   //false 
alert(typeof descriptor.get);   //"function"

6.2 创建对象

使用同一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体。

6.2.1 工厂模式

工厂模式是软件工程领域一种广为人知的设计模式,这种模式抽象了创建具体对象的过程。考虑到在 ECMAScript 中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节:

function createPerson(name, age, job){ 
    var o = new Object(); 
    o.name = name; 
    o.age = age; 
    o.job = job; 
    o.sayName = function(){ 
        alert(this.name); 
    };     
    return o; 
} 
 
var person1 = createPerson("Nicholas", 29, "Software Engineer"); 
var person2 = createPerson("Greg", 27, "Doctor"); 

工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。随着 JavaScript的发展,又一个新模式出现了。

6.2.2 构造函数模式

创建自定义的构造函数,从而定义自定义对象类型的属性和方法。

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.sayName = function(){ 
        alert(this.name); 
    };     
} 
 
var person1 = new Person("Nicholas", 29, "Software Engineer"); 
var person2 = new Person("Greg", 27, "Doctor"); 

与工厂模式的不同之处:

  • 没有显式地创建对象;
  • 直接将属性和方法赋给了 this 对象;
  • 没有 return 语句。
  • 函数名 Person 使用的是大写字母 P。

person1 和 person2 分别保存着 Person 的一个不同的实例。这两个对象都有一个 constructor(构造函数)属性,该属性指向 Person。

  • 将构造函数当作函数

构造函数与其他函数的唯一区别,就在于调用它们的方式不同。任何函数,只要通过 new 操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过 new 操作符来调用,那它跟普通函数也不会有什么两样。

// 当作构造函数使用 
var person = new Person("Nicholas", 29, "Software Engineer"); 
person.sayName(); //"Nicholas" 
 
// 作为普通函数调用 
Person("Greg", 27, "Doctor"); // 添加到 window 
window.sayName(); //"Greg" 
 
// 在另一个对象的作用域中调用 
var o = new Object(); 
Person.call(o, "Kristen", 25, "Nurse"); 
o.sayName(); //"Kristen" 

不使用 new操作符调用 Person()会出现什么结果:属性和方法都被添加给 window对象了,因为当在全局作用域中调用一个函数时,this 对象总是指向 Global 对象。使用 call()(或者 apply())在某个特殊对象的作用域中调用 Person()函数。这里是在对象 o的作用域中调用的,因此调用后 o就拥有了所有属性和 sayName()方法。

  • 构造函数的问题

使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。ECMAScript 中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。从逻辑角度讲,此时的构造函数也可以这样定义:

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.sayName = new Function("alert(this.name)"); // 与声明函数在逻辑上是等价的 
} 

从这个角度上来看构造函数,更容易明白每个 Person 实例都包含一个不同的 Function 实例。 不同实例上的同名函数是不相等的:

alert(person1.sayName == person2.sayName);  //false   

对于这个问题,可以通过把函数定义转移到构造函数外部来解决这个问题:

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.sayName = sayName; 
} 
 
function sayName(){ 
    alert(this.name); 
} 
 
var person1 = new Person("Nicholas", 29, "Software Engineer"); 
var person2 = new Person("Greg", 27, "Doctor"); 
 

可是新问题又来了:像这样在全局作用域定义的函数只能被某个对象调用,这让全局作用域有点名不副实;而且若对象需要定义多个方法,则需要定义多个全局函数,那么这些自定义的引用类型就没有封装性可言。可以通过原型模式来解决。

6.2.3 原型模式

我们创建的每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象。而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。那么就不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下面的例子所示。

function Person(){ 
} 
 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function(){ 
    alert(this.name); 
}; 
 
var person1 = new Person(); 
person1.sayName();   //"Nicholas" 
 
var person2 = new Person(); 
person2.sayName();   //"Nicholas" 
 
alert(person1.sayName == person2.sayName);  //true 

但与构造函数模式不同的是,新对象的这些属性和方法是由所有实例共享的。要理解原型模式的工作原理,必须先理解 ECMAScript 中原型对象的性质。

  • 理解原型对象

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。就拿前面的例子来说,Person.prototype.constructor 指向 Person。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法:

function Person(){}
Person.prototype // 这个属性指向函数的原型对象
Person.prototype.constructor // 指针指向构造函数本身,即Person

创建了自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则都是从 Object 继承而来的(如下图所示)。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象:

let LiMing = new Person()
LiMing.__proto__ // 指向构造函数的原型对象,即prototype

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/constructor.png

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/Person_relation.png

图 6-1 展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用 person1.sayName()。这是通过查找对象属性的过程来实现的。 虽然在所有实现中都无法访问到[[Prototype]],但可以通过 **isPrototypeOf()**方法来确定对象之间是否存在这种关系。

alert(Person.prototype.isPrototypeOf(person1));  //true 

ECMAScript 5 增加了一个新方法,叫 Object.getPrototypeOf(),在所有支持的实现中,这个方法返回[[Prototype]]的值。这在利用原型实现继承(本章稍后会讨论)的情况下是非常重要的。

alert(Object.getPrototypeOf(person1) == Person.prototype); //true 

每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了这个属性,则返回该属性的值。 虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性

function Person(){ 
} 
 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function(){ 
    alert(this.name); 
}; 
 
var person1 = new Person(); 
var person2 = new Person(); 
 
person1.name = "Greg"; 
alert(person1.name);     //"Greg"——来自实例 
alert(person2.name);     //"Nicholas"——来自原型

不过,使用 delete 操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性:

// 接上
delete person1.name; 
alert(person1.name);     //"Nicholas"——来自原型

使用 **hasOwnProperty()**方法(继承自Object)可以检测一个属性是存在于实例中,还是存在于原型中。该方法只在给定属性存在于对象实例中时,才会返回 true。 图 6-2 展示了上面例子反映的关系:

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_2.png

  • 原型与 in 操作符

有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。 在单独使用时,in 操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例中还是原型中。

function Person(){} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
var person1 = new Person(); 
var person2 = new Person(); 
 
alert(person1.hasOwnProperty("name"));  //false 
alert("name" in person1);  //true 
 
person1.name = "Greg"; 
alert(person1.name);   //"Greg" ——来自实例 
alert(person1.hasOwnProperty("name"));  //true 
alert("name" in person1);  //true 
 
alert(person2.name);   //"Nicholas" ——来自原型 
alert(person2.hasOwnProperty("name"));  //false 
alert("name" in person2);  //true 
 
delete person1.name; 
alert(person1.name);   //"Nicholas" ——来自原型 
alert(person1.hasOwnProperty("name"));  //false 
alert("name" in person1);  //true 

由hasOwnProperty()和in操作符可定义hasPrototypeProperty()函数:

function hasPrototypeProperty(object, name){ 
    return !object.hasOwnProperty(name) && (name in object); 
}

在使用 for-in 循环时,返回的是所有能够通过对象访问的、可枚举的(enumerated)属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性(即将[[Enumerable]]标记为 false 的属性)的实例属性也会在 for-in 循环中返回。 要取得对象上所有可枚举的实例属性,可以使用 ECMAScript 5 的 **Object.keys()**方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。

function Person(){ 
} 
 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function(){ 
    alert(this.name);  
}; 
 
var keys = Object.keys(Person.prototype); 
alert(keys);       // ["name,age,job,sayName"] 
 
var p1 = new Person(); 
p1.name = "Rob"; 
p1.age = 31; 
var p1keys = Object.keys(p1); 
alert(p1keys);    // ["name,age"]

如果你想要得到所有实例属性,无论它是否可枚举,都可以使用 Object.getOwnPropertyNames()方法:

var keys = Object.getOwnPropertyNames(Person.prototype); 
alert(keys);    //"constructor,name,age,job,sayName"
  • 更简单的原型语法

前面例子中每添加一个属性和方法就要敲一遍 Person.prototype。为减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下面的例子所示:

function Person(){ 
} 
 
Person.prototype = { 
    name : "Nicholas", 
    age : 29, 
    job: "Software Engineer", 
    sayName : function () { 
        alert(this.name); 
    } 
};

但这造成了一个问题:constructor 属性不再指向 Person 了,因为这里使用的语法,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就变成了新对象的 constructor 属性(指向 Object 构造函数),不再指向 Person 函数。此时通过 constructor 已经无法确定对象的类型了。

var friend = new Person(); 
 
alert(friend instanceof Object); //true 
alert(friend instanceof Person); //true 
alert(friend.constructor == Person);    //false 
alert(friend.constructor == Object); //true 

可以像下面这样特意将它设置回适当的值:

function Person(){ 
} 
 
Person.prototype = { 
    constructor : Person, 
    name : "Nicholas", 
    age : 29, 
    job: "Software Engineer", 
    sayName : function () { 
        alert(this.name); 
    } 
}; 

注意,以这种方式重设 constructor 属性会导致它的[[Enumerable]]特性被设置为 true。可以试一试 Object.defineProperty()来解决此问题。

function Person(){ 
} 
 
Person.prototype = { 
    name : "Nicholas", 
    age : 29, 
    job : "Software Engineer", 
    sayName : function () { 
        alert(this.name); 
    } 
};  
//重设构造函数,只适用于 ECMAScript 5 兼容的浏览器 
Object.defineProperty(Person.prototype, "constructor", { 
    enumerable: false, 
    value: Person 
});
  • 原型的动态性

我们对原型对象所做的任何修改都能够立即从实例上反映出来——即使是先创建了实例后修改原型也照样如此。(因为实例与原型之间的连接只不过是一个指针,而非一个副本) 但如果是重写整个原型对象,即把原型修改为另外一个对象,就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针仅指向原型,而不指向构造函数。

function Person(){ 
} 
 
var friend = new Person(); 
         
Person.prototype = { // 重写了prototype
    constructor: Person, 
    name : "Nicholas", 
    age : 29, 
    job : "Software Engineer", 
    sayName : function () { 
        alert(this.name); 
    } 
}; 
 
friend.sayName();   //error

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_3.png

  • 原生对象的原型

原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。

String.prototype.startsWith = function (text) { 
    return this.indexOf(text) == 0; 
}; 
 
var msg = "Hello world!"; 
alert(msg.startsWith("Hello"));   //true 

尽管可以这样做,但我们不推荐在产品化的程序中修改原生对象的原型。

  • 原型对象的问题

首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。但还有一个,原型模式的最大问题是由其共享的本性所导致的。 对于包含引用类型值的属性来说,问题就比较突出了。来看下面的例子:

function Person(){ 
} 
 
Person.prototype = { 
    constructor: Person, 
    name : "Nicholas", 
    age : 29, 
    job : "Software Engineer", 
    friends : ["Shelby", "Court"], 
    sayName : function () { 
        alert(this.name); 
    } 
}; 
 
var person1 = new Person(); 
var person2 = new Person(); 
 
person1.friends.push("Van"); 
 
alert(person1.friends);    //"Shelby,Court,Van" 
alert(person2.friends);    //"Shelby,Court,Van" 
alert(person1.friends === person2.friends);  //true

修改了 person1.friends 引用的数组,向数组中添加了一个字符串。所以刚刚提到的修改也会通过person2.friends(与 person1.friends 指向同一个数组)反映出来。而这个问题正是我们很少看到有人单独使用原型模式的原因所在。

https://s2.loli.net/2022/07/12/hUF4gSlOzMvbxc5.png

6.2.4 组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。**构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。**结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混合模式还支持向构造函数传递参数;可谓是集两种模式之长:

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.friends = ["Shelby", "Court"]; 
} 
 
Person.prototype = { 
    constructor : Person, 
    sayName : function(){ 
        alert(this.name); 
    } 
} 
 
var person1 = new Person("Nicholas", 29, "Software Engineer"); 
var person2 = new Person("Greg", 27, "Doctor"); 
 
person1.friends.push("Van"); 
alert(person1.friends);    //"Shelby,Count,Van" 
alert(person2.friends);    //"Shelby,Count" 
alert(person1.friends === person2.friends);    //false 
alert(person1.sayName === person2.sayName);    //true

这种构造函数与原型混成的模式,是目前在 ECMAScript 中使用最广泛、认同度最高的一种创建自定义类型的方法。

6.2.5 动态原型模式

有其他 OO 语言经验的开发人员在看到独立的构造函数和原型时,很可能会感到非常困惑,于是就有了动态原型模式这个解决方案:它把所有信息都封装在了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。换句话说,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。来看一个例子:

function Person(name, age, job){ 
 
    //属性 
    this.name = name; 
    this.age = age; 
    this.job = job;
    //方法, 体现了动态添加=.=
    if (typeof this.sayName != "function"){ 
     
        Person.prototype.sayName = function(){ 
            alert(this.name); 
        }; 
         
    } 
} 
 
var friend = new Person("Nicholas", 29, "Software Engineer"); 
friend.sayName();

这段代码只会在初次调用构造函数时才会执行。其中if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆 if 语句检查每个属性和每个方法;只要检查其中一个即可

使用动态原型模式时,不能使用对象字面量重写原型,因为会切断现有实例与新原型之间的联系。

6.2.6 寄生构造函数模式

通常,在前述的几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数。下面是一个例子(和前文看过的工厂模式很像,区别于是否有new):

function Person(name, age, job){ 
    var o = new Object(); 
    o.name = name; 
    o.age = age; 
    o.job = job; 
    o.sayName = function(){ 
        alert(this.name); 
    };     
    return o; 
} 
 
var friend = new Person("Nicholas", 29, "Software Engineer"); 
friend.sayName();  //"Nicholas"

除了使用 new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式是一模一样的。 构造函数在不返回值的情况下,默认会返回新对象实例(这也是为什么构造函数模式不用写return的原因:))。而通过在构造函数的末尾添加一个 return 语句,可以重写调用构造函数时返回的值。 这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改 Array 构造函数,因此可以使用这个模式。 关于寄生构造函数模式,有一点需要说明:首先,返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此,不能依赖 instanceof 操作符来确定对象类型。

6.2.7 稳妥构造函数模式

道格拉斯·克罗克福德(Douglas Crockford)发明了 JavaScript 中的稳妥对象(durable objects)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用 this 和 new),或者在防止数据被其他应用程序(如 Mashup程序)改动时使用。稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用 this;二是不使用 new 操作符调用构造函数。

function Person(name, age, job){ 
     
    //创建要返回的对象 
    var o = new Object(); 
    //可以在这里定义私有变量和函数 
 
    //添加方法 
    o.sayName = function(){ 
        alert(name); 
    };     
     
    //返回对象 
    return o; 
} 
var friend = Person("Nicholas", 29, "Software Engineer"); 
friend.sayName();  //"Nicholas" 

除了调用 sayName()方法外,没有别的方式可以访问其数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。

与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有什么关系,因此 instanceof 操作符对这种对象也没有意义。

6.3 继承

许多 OO 语言都支持两种继承方式:接口继承实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。ECMAScript 只支持实现继承,而且其实现继承主要是依靠原型链来实现的。

6.3.1 原型链

原型链基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。 假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?实现原型链有一种基本模式,其代码大致如下。

function SuperType(){ 
    this.property = true; 
} 
SuperType.prototype.getSuperValue = function(){ 
    return this.property; 
}; 
 
function SubType(){ 
    this.subproperty = false; 
} 
 
//继承了 SuperType 
SubType.prototype = new SuperType(); 
 
SubType.prototype.getSubValue = function (){ 
    return this.subproperty; 
}; 
 
var instance = new SubType(); 
alert(instance.getSuperValue());      //true

SubType 继承了 SuperType,而继承是通过创建 SuperType 的实例,并将该实例赋给SubType.prototype 实现的。实现的本质是重写原型对象,代之以一个新类型的实例。即原来存在于 SuperType 的实例中的所有属性和方法,现在也存在于 SubType.prototype 中了。 这个例子中的实例以及构造函数和原型之间的关系如图所示:

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_4.png

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_4_2.png

要注意 instance.constructor 现在指向的是 SuperType,这是因为原来 SubType.prototype 中的 constructor 被重写了的缘故。

  • 别忘记默认的原型

默认原型都会包含一个内部指针,指向 Object.prototype。这也正是所有自定义类型都会继承 toString()、valueOf()等默认方法的根本原因。下图展示完整的原型链。

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_5.png

  • 确定原型和实例的关系

第一种方式是使用 instanceof 操作符:

alert(instance instanceof Object); //true 
alert(instance instanceof SuperType); //true 
alert(instance instanceof SubType);         //true

第二种方式是使用 isPrototypeOf()方法:

alert(Object.prototype.isPrototypeOf(instance));         //true 
alert(SuperType.prototype.isPrototypeOf(instance));      //true 
alert(SubType.prototype.isPrototypeOf(instance));        //true 
  • 谨慎地定义方法

注意给原型添加方法的代码一定要放在替换原型的语句之后(因为是对SuperType实例的添加修改,根据前文所学,这样做本身就会屏蔽SuperType原有的相同属性、方法)。

function SuperType(){ 
    this.property = true; 
} 
 
SuperType.prototype.getSuperValue = function(){ 
    return this.property; 
}; 
 
function SubType(){ 
    this.subproperty = false; 
} 
 
// SuperType 的实例替换SubType原型,继承了 SuperType 
SubType.prototype = new SuperType(); 
 
//添加新方法 
SubType.prototype.getSubValue = function (){ 
    return this.subproperty; 
}; 
 
//重写超类型中的方法 
SubType.prototype.getSuperValue = function (){ 
    return false; 
}; 

还有一点,在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链。(也是前文所学,会丢失constructor,变成指向Object对象)

//继承了 SuperType 
SubType.prototype = new SuperType(); 
 
//使用字面量添加新方法,会导致上一行代码无效 
SubType.prototype = { 
    getSubValue : function (){ 
        return this.subproperty; 
    }, 
 
    someOtherMethod : function (){ 
        return false; 
    } 
}; 
  • 原型链的问题

最主要的问题来自包含引用类型值的原型,这个在前文的“原型模式”中有谈到,这也是为什么要在构造函数中定义属性,而不是在原型对象上定义的原因。

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_5_0.png

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_5_1.png

原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。

6.3.2 借用构造函数

在解决原型中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数(constructor stealing)的技术。即在子类型构造函数的内部调用超类型构造函数。(类似于工厂模式那种?)函数只不过是在特定环境中执行代码的对象,因此通过使用 apply()和 call()方法也可以在(将来)新创建的对象上执行构造函数。

function SuperType(){ 
    this.colors = ["red", "blue", "green"]; 
} 
 
function SubType(){   
 //继承了 SuperType 
    SuperType.call(this); 
} 
 
var instance1 = new SubType(); 
instance1.colors.push("black"); 
alert(instance1.colors);    //"red,blue,green,black" 
 
var instance2 = new SubType(); 
alert(instance2.colors);    //"red,blue,green" 

这样一来,就会在新 SubType 对象上执行 SuperType()函数中定义的所有对象初始化代码。结果,SubType 的每个实例就都会具有自己的 colors 属性的副本了。

  • 传递参数

借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数

function SuperType(name){ 
    this.name = name; 
} 
 
function SubType(){   
 //继承了 SuperType,同时还传递了参数 
    SuperType.call(this, "Nicholas"); 
     
    //实例属性 
    this.age = 29; 
} 
 
var instance = new SubType(); 
alert(instance.name);    //"Nicholas"; 
alert(instance.age);     //29 
  • 借用构造函数的问题

方法都在构造函数中定义,因此函数复用就无从谈起了。而且,在超类型的原型中定义的方法,对子类型而言也是不可见的。所以,借用构造函数的技术也是很少单独使用的。于是就有了下面的组合继承。

6.3.3 组合继承

组合继承(combination inheritance),指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。(即6.2.4的内容的延伸,所以这6.3的内容和6.2很大关系啊)

function SuperType(name){ 
    this.name = name; 
    this.colors = ["red", "blue", "green"]; 
} 
 
SuperType.prototype.sayName = function(){ 
    alert(this.name);
}; 
 
function SubType(name, age){   
 
    //继承属性 
    SuperType.call(this, name); 
     
    this.age = age; 
} 
 
//继承方法 
SubType.prototype = new SuperType(); 
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function(){ 
    alert(this.age); 
}; 
 
var instance1 = new SubType("Nicholas", 29); 
instance1.colors.push("black"); 
alert(instance1.colors);      //"red,blue,green,black" 
instance1.sayName();          //"Nicholas"; 
instance1.sayAge();           //29 
 
var instance2 = new SubType("Greg", 27); 
alert(instance2.colors);      //"red,blue,green" 
instance2.sayName();          //"Greg"; 
instance2.sayAge();           //27 

这是目前JS中最常用的继承模式。

6.3.4 原型式继承

道格拉斯·克罗克福德在06年写了篇文章,介绍了JS中的原型式继承。 他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型:

function object(o){ 
    function F(){} 
    F.prototype = o; 
    return new F(); 
} 

在 object()函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。从本质上讲,object()对传入其中的对象执行了一次浅复制。

var person = { 
    name: "Nicholas", 
    friends: ["Shelby", "Court", "Van"] 
}; 
 
var anotherPerson = object(person); 
anotherPerson.name = "Greg"; 
anotherPerson.friends.push("Rob"); 
 
var yetAnotherPerson = object(person); 
yetAnotherPerson.name = "Linda"; 
yetAnotherPerson.friends.push("Barbie"); 
 
alert(person.friends);   //"Shelby,Court,Van,Rob,Barbie"
// 所以才叫原型式继承嘛

ECMAScript 5 通过新增 **Object.create()**方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create()与 object()方法的行为相同。 不过别忘了,包含引用类型值的属性始终都会共享相应的值,就像使用原型模式一样。

6.3.5 寄生式继承

寄生式(parasitic)继承是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德推而广之的。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数:

function createAnother(original){ 
    var clone = object(original);  //通过调用函数创建一个新对象 
    clone.sayHi = function(){      //以某种方式来增强这个对象 
        alert("hi"); 
    }; 
 return clone; //返回这个对象 
}

可以像下面这样来使用 createAnother()函数:

var person = { 
    name: "Nicholas", 
    friends: ["Shelby", "Court", "Van"] 
}; 
 
var anotherPerson = createAnother(person); 
anotherPerson.sayHi(); //"hi" 

主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率;这一点与构造函数模式类似。

6.3.6 寄生组合式继承

组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。

function SuperType(name){ 
    this.name = name; 
    this.colors = ["red", "blue", "green"]; 
} 
 
SuperType.prototype.sayName = function(){ 
    alert(this.name); 
}; 
 
function SubType(name, age){   
 SuperType.call(this, name); //第二次调用 SuperType() 
     
    this.age = age; 
} 
 
SubType.prototype = new SuperType(); //第一次调用 SuperType() 
SubType.prototype.constructor = SubType; 
SubType.prototype.sayAge = function(){ 
    alert(this.age); 
}; 

如图 6-6 所示,有两组 name 和 colors 属性:一组在实例上(实例上的一组会覆盖在原型中的那组,所以才叫组合继承),一组在 SubType 原型中。这就是调用两次 SuperType 构造函数的结果。好在我们已经找到了解决这个问题方法——寄生组合式继承。 所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。寄生组合式继承的基本模式如下所示。

function inheritPrototype(subType, superType){ 
    var prototype = object(superType.prototype);     //创建对象 
 prototype.constructor = subType; //增强对象 
 subType.prototype = prototype; //指定对象 
} 

这样,我们就可以用调用 inheritPrototype()函数的语句,去替换前面例子中为子类型原型赋值的语句了:

function SuperType(name){ 
    this.name = name; 
    this.colors = ["red", "blue", "green"]; 
} 
 
SuperType.prototype.sayName = function(){ 
    alert(this.name); 
}; 
 
function SubType(name, age){   
    SuperType.call(this, name); 
     
    this.age = age; 
} 
 
inheritPrototype(SubType, SuperType); 
 
SubType.prototype.sayAge = function(){ 
    alert(this.age); 
};

/images/%E9%AB%98%E7%BA%A7%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/photo_6_6.png

这个例子的高效率体现在它只调用了一次 SuperType 构造函数。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

6.4 小结

ECMAScript 支持面向对象(OO)编程,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象。

  • 工厂模式,使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来被构造函数模式所取代。
  • 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样使用 new 操作符。不过,构造函数模式也有缺点,即它的每个成员都无法得到复用,包括函数。由于函数可以不局限于任何对象(即与对象具有松散耦合的特点),因此没有理由不在多个对象间共享函数。
  • 原型模式,使用构造函数的 prototype 属性来指定那些应该共享的属性和方法。组合使用构造函数模式和原型模式时,使用构造函数定义实例属性,而使用原型定义共享的属性和方法

JavaScript 主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就能够访问超类型的所有属性和方法,这一点与基于类的继承很相似。原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借用构造函数,即在子类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的属性,同时还能保证只使用构造函数模式来定义类型。使用最多的继承模式是组合继承,这种模式使用原型链继承共享的属性和方法,而通过借用构造函数继承实例属性。 此外,还存在下列可供选择的继承模式。

  • 原型式继承,可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以得到进一步改造。
  • 寄生式继承,与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这个模式与组合继承一起使用。
  • 寄生组合式继承,集寄生式继承和组合继承的优点与一身,是实现基于类型继承的最有效方式。