JS高程笔记 —— 面向对象

面向对象的语言有一个标志,那就是它们都有的概念,而通过类可以创建任意多个具有相同属性和方法的对象。ECMAScript中没有类的概念,因此它的对象也和基于类的语言中的对象有所不同。

ECMA-262把对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。我们可以把ECMAScript的对象想象成散列表:无非就是一组key/value,其中value可以是数据或者函数。

理解对象

var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function() {
    console.log(this.name);
}

早期的JS开发者经常使用这个模式创建新对象。几年后,对象字面量成为创建这种对象的首选模式。

用对象字面量创建上例对象如下:

var person = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName: function () {
        console.log(this.name);
    }
};
属性类型

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

1. 数据属性

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

  • [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性特性,能否把属性修改为访问器属性。
    特性默认值:true

  • [[Enumerable]]:表示能否通过for-in循环返回属性。
    特性默认值:true

  • [[Writable]]:表示能否修改属性值(可写)。
    特性默认值:true

  • [[Value]]:表示这个属性的数据值。读取属性时,从这个位置读;写入属性值,把新值保存在这。
    特性默认值:true

直接使用对面字面量定义的属性,他们的数据属性全部为true

var person = {
    name: "Nicholas"
}

这里创建的名为name的属性,指定的值为"Nicholas"。也就是说,[[Value]]特性将被设置为"Nicholas",而对这个值任何修改都将反应在这个位置。

var person = {
    name: "Nicholas"
}

// Writable
person.name = "John";       
console.log(person.name);       // John
// Enumerable
for (var i in person) {
    console.log(person[i]);     // John
}
// Configurable
delete person.name;
console.log(person.name);       // undefined

当我们需要修改数据属性默认值时,必须使用ECMAScript的Object.defineProperty()方法。

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

Object.defineProperty(obj, prop, descriptor)

  • obj:要在其上定义属性的对象。
  • prop:要定义或修改的属性的名称。
  • descriptor:将被定义或修改的属性描述符

这个方法接受三个参数:属性所在对象、属性名字、一个描述符对象。
其中,描述符(descriptor)对象属性必须是:configurable,enumerable,writable和value。设置其中的一或多个值。

var person = {};
Object.defineProperty(person,"name",{
    writable: false,
    value: "Nicholas"
});

console.log(person.name);       // "Nicholas"
person.name = "John";
console.log(person.name);       // "Nicholas"

在这里,name属性被设置为只读(writable:false),如果尝试为它指定新值。
严格模式:抛出错误
非严格模式:赋值操作将被忽略。

类似规则也适用于不可配制的属性。例如:

var person = {};
Object.defineProperty(person,"name",{
    configurable: false,
    value: "Nicholas"
});

console.log(person.name);       // "Nicholas"
person.name = "John";
console.log(person.name);       // "Nicholas"

configurable设置为false,表示不能从对象中删除属性。
还记着之前的

  • [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性特性,能否把属性修改为访问器属性。
    特性默认值:true

如果对这个属性值调用delete,则在非严格模式下什么会不会发生,严格模式下会导致错误。

并且,一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时,再调用Object.defineProperty()方法修改除writable之外的特性,都会导致错误。

var person = {};
Object.defineProperty(person,"name",{
    configurable: false,
    value: "Nicholas"
});

// 抛出错误
Object.defineProperty(person,"name",{       // TypeError: Cannot redefine property: name
    configurable: true,
    value: "Nicholas"
});

也就是说,可以多次调用Object.defineProperty()方法修改同一个属性,但在把configurable设置为false之后就会有限制了。

另外,在configurable:false情况下,在当writable:false想要设置为true时,也会报错。

var person = {};
Object.defineProperty(person,"name",{
    configurable: false,
    writable: false,
    value: "Nicholas"
});

person.name = "John";
console.log(person.name);           // "Nicholas"

Object.defineProperty(person,"name",{   // TypeError: Cannot redefine property: name
    writable: true
});
var person = {};
Object.defineProperty(person,"name",{
    configurable: false,
    writable: true,
    value: "Nicholas"
});

person.name = "John";
console.log(person.name);               // "John"

Object.defineProperty(person,"name",{
    writable: false
});
Object.defineProperty(person,"name",{   // TypeError: Cannot redefine property: name
    writable: true
});

调用Object.defineProperty()创建一个新属性时,如果不指定,configurable,enumerable,writable默认值都是false。

var person = {};
Object.defineProperty(person,"name",{
    value: "Nicholas"
});

console.log(person.name);       // Nicholas
person.name = "John";
console.log(person.name);       // Nicholas    说明writable:false

for (var i in person) {         // enumerable:false
    console.log(i);
}

多数情况下,可能没有必要利用Object.defineProperty()方法提供了这些高级功能,不过,理解这些概念对理解JS对象非常有用。

1. 访问器属性

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

  • [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性特性,能否把属性修改为数据属性。
    特性默认值:true

  • [[Enumerable]]:表示能否通过for-in循环返回属性。
    特性默认值:true

  • [[Get]]:在读取属性时调用的函数。默认值:undefined

  • [[Set]]:在写入属性时调用的函数。默认值:undefined

var book = {
    _year: 2004,
    edition: 1
};

Object.defineProperty(book,"year",{     // year作为访问器属性,内部方法实现读取和写入
    get: function() {
        return this._year;
    },
    set: function(newValue) {
        if (newValue > 2004) {
            this._year = newValue;
            this.edition += newValue - 2004;
        }
    }
});

console.log(book.edition);  // 1
book.year = 2005;           
console.log(book.edition);  // 2

_year前面的下划线是一种常用记号,用户表示只能通过对象方法访问的属性。
访问器属性year则包含一个getter函数和一个setter函数。getter函数返回_year的值,setter函数通过计算来确定正确的版本。因此,把year属性修改为2005会导致_year变成2005,而edition变为2。这是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化。

PS:有同学看到这觉得疑问,为什么给book.year会调用内部的函数?还记得之前这句话吗?

在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;
在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。

所以,叫“访问器属性”啦23333。
相比之前的数据属性,从名字可以一窥全豹。
数据属性多用于存放数据,访问器属性则多了两个可以读写的getter函数与setter函数。

当然,不一定非要同时指定getter函数和setter函数。

  • 只指定getter意味着属性不能写,尝试写入属性会被忽略,严格模式下会报错。
  • 只指定setter意味着属性不能读,否则在非严格模式下会返回undefined,严格模式下会报错。

定义多个属性

Object.defineProperties() 方法直接在一个对象上定义新的属性或修改现有属性,并返回该对象。

Object.defineProperties(obj, props)

  • obj:在其上定义或修改属性的对象。
  • props:要定义其可枚举属性或修改的属性描述符的对象。对象中存在的属性描述符主要有两种:数据描述符和访问器描述符.
var book = {
    _year: 2004,
    edition: 1
};

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

上例代码在book对象上定义了两个数据属性(_year和edition)和一个访问器属性(year)。


读取属性的特性

Object.getOwnPropertyDescriptor(obj, prop)

  • obj:需要查找的目标对象
  • prop:目标对象内属性名称(String类型)
var book = {
    _year: 2004,
    edition: 1
};

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;
            }
        }
    }
});

var descriptor = Object.getOwnPropertyDescriptor(book,"_year");
console.log(descriptor.value);          // 2004
console.log(descriptor.configurable);   // true
console.log(typeof descriptor.get);     // undefined

var descriptor = Object.getOwnPropertyDescriptor(book,"year");
console.log(descriptor.value);          // undefined
console.log(descriptor.enumerable);     // false
console.log(typeof descriptor.get);     // function

对于数据属性_year,value等于最初的值,configurable是false,而get等于undefined。对于访问器属性year,value等于undefined,enumerable是false,而get是一个指向getter函数的指针。

在JavaScript中,可以针对任何对象——包括DOM对象和DOM对象,使用Object.getOwnProperty-Descriptor()方法。


创建对象

工厂模式

旧时的开发人员在考虑到ES中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节。

function createPerson(name, age, job) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function () {
        console.log(this.name);
    }
    return o;
}

var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");

工厂模式的解决了创建多个相似对象的问题,但却没有对象识别的问题(即怎么样知道一个对象的类型)。

构造函数模式

ECMAScript中的构造函数可用来创建特定类型的对象。像Object和Array这样的原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数。从而定义自定义对象类型的属性和方法。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        console.log(this.name);
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

在这个例子中,Person()函数取代了createPerson()函数,Person()中的代码除了与createPerson()中相同的部分外,还存在以下特别之处。

  • 没有显式创建对象
  • 直接将属性和方法赋给了this对象;
  • 没有return语句

此外,函数名使用大写字母开头。按照惯例,构造函数始终都应该以一个大写字母开头,而非构造函数则应该以一个小写字母开头。这个做法借鉴了其他OO语言,主要是为了区别ES中的其他函数;因为构造函数本身也是函数,只不过可以用来创建对象而已。

创建Person的新实例,必须使用new操作符。以这种方式调用的构造函数会经历以下4个步骤:

  1. 创建一个新对象(隐式)
  2. 将构造函数的作用域赋值给新对象(this引用就指向这个新对象)
  3. 执行构造函数中 的代码(为这个新处理添加属性)
  4. 返回新对象

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

console.log(person1.constructor === Person);        // true
console.log(person2.constructor === Person);        // true

对象的constructor属性最初用来标识对象类型的。但是,提到检测对象类型,还是instanceof操作符可靠一些。我们这里创建的所有对象既是Object的实例,也是Person的实例,这一点通过instanceof操作符可以验证。

console.log(person1 instanceof Object);     // true
console.log(person1 instanceof Person);     // true
console.log(person2 instanceof Object);     // true
console.log(person2 instanceof Person);     // true

1. 将构造函数当做函数

构造函数与其他函数的唯一区别,就在于调用方式不同,不过,构造函数也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不经过new操作符,那它和普通函数也没有什么两样。

// constructor function
var person = new Person("Nicholas",29,"Software Engineer");
person.sayName();       // "Nicholas"

// normal function
Person("Greg",27,"Doctor");     // this引用指向window
window.sayName();       // "Greg"

// 在另一个对象的作用域调用
var o = new Object();
Person.call(o,"Kristen",25,"Nurse");
o.sayName();            // "Kristen"

2. 构造函数的问题

构造函数模式虽然好用,但也并非没有缺点。使用构造函数的主要问题,就是每个方法都要在每个实例上重新写一遍。在前面的例子中,person1和person2都有一个名为sayName()的方法,但那两个方法不是同一个Function的实例。他们不是同一个函数。ES中的函数是对象,因此没定义一个函数,也就是实例化了一个对象。从逻辑角度讲,相当于这样:

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

以这种方式创建函数,会导致不同的作用域链和标识符解析。

console.log(person1.sayName === person2.sayName);       // false

然而,创建两个完成同样任务的Function实例的确没有必要;况且有this对象在,根本不用在执行代码前就把函数绑定到特定对象上面。可以通过把函数定义转移到构造函数外来解决这个问题。

理解:因为this对象的存在,我么只需要定义一个方法(函数)就够了,通过指定this来解决这个问题。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;
}
function sayName() {
    console.log(this.name);
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
console.log(person1.sayName === person2.sayName);       // true

person1和person2就共享了一个全局作用域sayName()函数,但是新问题又来了。
在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。并且,如果需要定义很多方法,那么就要定义很多个全局函数,那么这个自定义的引用类型就没有丝毫封装性可言。

原型模式

每一个函数都有一个prototype属性,这个属性是一个指针,指向一个对象,而这个对象的用途包括可以由特定类型的所有实例共享的属性和方法。

理解:在函数上的prototype对象上的属性和方法,可以共享给函数的所有实例。

function Person() {};

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

var person1 = new Person();
person1.sayName();      // "Nicholas"

var person2 = new Person();
person2.sayName();      // "Nicholas"

console.log(person1.sayName === person2.sayName);       // true

1. 理解原型对象

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype属性,这个属性指向函数的原型对象。默认情况下,所有原型对象都会自动获得一个constructor(构造函数)属性,这个属性指向一个prototype属性所在函数的指针。
创建了自定义的构造函数之后,其原型对象默认只会取得constructor属性;至于其他方法,则都是从Object继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版管这个指针叫[[Prototype]]。虽然在脚本中没有标准的方式访问[[Prototype]],但Firefox,Safari,Chrome在每个对象上都支持一个属性__proto__;而在其他实现中,这个属性对脚本则是完全不可见的。

虽然在所有实现都无法访问到[[Prototype]],但可以通过isPrototypeOf()方法来确定对象之间是否存在这种关系。从本质上将,如果[[Prototype]]指向调用Object.isPrototypeOf()方法的对象(Person.prototype),那么这个方法就会返回true。

console.log(Person.prototype.isPrototypeOf(person1));       // true
console.log(Person.prototype.isPrototypeOf(person2));       // true

ES5新增了一个新方法,叫Object.getPrototypeOf(),在所有支持的实现中,这个方法返回[[Prototype]]的值。

console.log(Object.getPrototypeOf(person1) === Person.prototype);   // true
console.log(Object.getPrototypeOf(person1).name);                   // "Nicholas"

这里的第一行代码只是确定Object.getPrototypeOf()返回的对象实际就是这个对象的原型。第二行代码取得了原型对象中name属性的值,Nicholas。使用Object.getPrototypeOf()可以方便的取得一个对象的原型,而这在利用原型实现继承的情况下是非常重要的。

每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例开始。如果在实例中找到了具有给定名字的属性,则返回属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性,有,则返回属性值。

虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例添加一个属性,而该属性与实例原型的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。

function Person() {};

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

var person1 = new Person();
var person2 = new Person();

person1.name = "Greg";
console.log(person1.name);      // Greg
console.log(person2.name);      // Nicholas

person1.name被一个新值给屏蔽了,因为实例上本身存在一个自有属性。

当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性;换句话说,添加这个属性会阻止我们访问原型中的那个属性,但不会修改属性。即使将这个属性设置为null,也只会在实例中设置这个属性,而不会恢复其指向原型的链接。
不过,使用delete操作符则可以完全删除实例属性,从而让我们重新访问到原型属性。

function Person() {};

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

var person1 = new Person();
var person2 = new Person();

person1.name = "Greg";
console.log(person1.name);      // "Greg"
console.log(person1.name);      // "Nicholas"

delete person1.name;
console.log(pesron1.name);      // "Nichoals"

使用hasOwnProperty()方法可以检测一个属性是存在于实例中,还是存在与原型中 。这个方法(不要忘了它也是从Object继承来的)只在给定属性存在于对象实例中时,才会返回true。

function Person() {};

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

var person1 = new Person();
var person2 = new Person();

console.log(person1.hasOwnProperty("name"));        // false
person1.name = "Greg";                              // "Greg"   自有属性
console.log(person1.name);                          // true

console.log(person2.name);                          // "Nicholas"   原型属性
console.log(person2.hasOwnProperty("name"));        // false

delete person1.name;
console.log(person1.name);                          // "Nichoals"   原型属性
console.log(person1.hasOwnProperty("name"));        // false

2. 原型与in操作符

有两种方式使用in操作符

  • 单独使用
  • 在for-in循环中使用

单独使用时,in操作符会在通过对象能够访问给定属性时返回true,无论自有属性还是原型属性。

function Person() {};

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

var person1 = new Person();
var person2 = new Person();

console.log(person1.hasOwnProperty("name"))     // false
console.log("name" in person1)                  // true

person1.name = "Greg";
console.log(person1.name);                      // "Greg"
console.log(person1.hasOwnProperty("name"));    // true
console.log("name" in person1);                 // true

console.log(person2.name);                      // "Nicholas"
console.log(person2.hasOwnProperty("name"));    // false
console.log("name" in person2);                 // true

delete person1.name;
console.log(person1.name);                      // "Nicholas"
console.log(person1.hasOwnProperty("name"));    // false
console.log("name" in person1);                 // true

我们可以根据这两个方法写一个判断属性是否在原型上的函数:
hasPrototypeProperty()

function Person() {};

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

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

var person = new Person();
console.log(hasPrototypeProperty(person,"name"));           // true

person.name = "Greg";
console.log(hasPrototypeProperty(person,"name"));           // false

在使用for-in循环时,返回的是所有能够通过对象访问、可枚举(enumerable)的属性,其中既包括存在实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性(即将[[Enumerable]]设置为false)的实例属性也会在for-in循环中返回,因为根据规定,所有开发人员定义的属性都是可枚举的。

要取得对象上所有可枚举的实例属性,可以使用ES5的Object.keys()方法。这个方法接受一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。

function Person() {};

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


var keys = Object.keys(Person.prototype);
console.log(keys);              // ["name","age","job","sayName"];

var p1 = new Person();
p1.name = "Rob";
p1.age = 31;
var p1keys = Object.keys(p1);
console.log(p1keys);            // ["name","age"]

Object.getOwnPropertyNames()

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

function Person() {};

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


var keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys);  
/*
0:"constructor"
1:"name"
2:"age"
3:"job"
4:"sayName"
*/

3. 更简单的原型语法

没添加一个属性和方法都要敲一遍Person.prototype。为减少不必要的输入,也为了从视觉上优化封装原型功能,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象。

function Person() {};

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

Person.prototype设置为等于一个对象字面量形式创建的新对象。最终结果相同,但有一个例外,constructor属性不再指向Person了。
原来,每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获得constructor属性。而我们在这里完全重写了默认的prototype对象,因此constructor属性也就变成了新对象的constructor属性(指向Object构造函数),不在指向Person函数。此时,尽管instanceo操作符还能返回正确的结果,但通过constructor已经无法确定对象类型了。

var friend = new Person();

console.log(friend instanceof Object);          // true
console.log(friend instanceof Person);          // true
console.log(friend.constructor == Person);      // false
console.log(friend.constructor == Object);      // true

在此,用instanceof操作符测试Object和Person仍然返回true,但constructor属性则等于Object而不等于Person了。如果constructor的值真的很重要,可以将它设置回适当的值。

function Person() {};

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

以上代码特意包含了一个constructor属性,并将它的值设置为Person,从而确保了通过该属性能够访问到适当的值。
注意,以这种方式重设constructor属性会导致它的[[Enumerable]]特性被设置为true。默认情况下,原生的constructor属性是不可枚举的,因此如果使用兼容ES5的JS引擎,可以试一试Object.defineProperty()。

Object.defineProperty(Person.prototype, "constrcutor", {
    enumerable: false,
    value: Person
});

4. 原型的动态性
由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能立刻从实例上反映出来——即使是先创建实例后修改原型也是如此。

var friend = new Person();
Person.prototype.sayHi = function () {
    console.log("Hi");
};

friend.sayHi();     // "Hi"     

实例与原型之间的松散链接关系,当我们调用friend.sayHi(),首先会在实例中搜索名为sayHi的属性,没找到则像原型中查找,因为实例与原型之间的链接不过是一个指针,而非一个副本,因此就可以在原型中找到新的sayHi属性并返回引用在那里的函数。

尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。
调用构造函数会为实例添加一个指向最初原型的[[Prototype]]指针,而把原型修改为另一个对象就等于切断了这个联系。

function Person() {};

var friend = new Person();

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

friend.sayName();       // TypeError: sayName is not a function

在这个例子中,先创建Person一个实例,然后又重写了原型对象。然后调用sayName函数发生了错误,因为friend指向的原型中不包含以该名字命名的属性。

重写原型对象,切断了现有原型和任何之前已经存在的对象实例之间的联系;它们引用的仍然是最初的原型。

5. 原生对象的原型
原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。例如,在Array.prototype中可以找到sort()方法,String.prototype中可以找到substring()方法。

console.log(typeof Array.prototype.sort);           // funciton
console.log(typeof String.prototype.substring);     // function

通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以像修改自定义对象的原型一样修改原生对象的原型。


String.prototype.startsWidth = function(text) {
    return this.indexOf(text) === 0;
};

var msg = "Hello World";
console.log(msg.startsWith("Hello"));   // true

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

6. 原型对象的问题

原型模式省略了为构造函数传递初始化参数这一环节,所有实例在默认情况下都将取得相同的属性值。但还不是最大的问题。
原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本类型值的属性倒也说得过去,可以在实例上添加同名属性,屏蔽原型对应属性。
但是,对于引用类型值的属性来说,就有问题了:

function Person() {};

Person.prototype = {
    constructor: Person,
    name: "Nichoals",
    age: 29,
    job: "Software Engineer",
    friends: ["friend.1","friend.2"],
    sayName: function() {
        console.log(this.name);
    }
};

var person1 = new Person();
var person2 = new Person();

person1.friends.push("friend.3");

console.log(person1.friends);   // Array(3) ["friend.1", "friend.2", "friend.3"]
console.log(person2.friends);   // // Array(3) ["friend.1", "friend.2", "friend.3"]
console.log(person1.friends === person1.friends);   // true

假如初衷是为了在所有实例中共享一个数组,那没什么问题,但是,实例一般都是要有属于自己的全部属性的。


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

构造函数用于定义实例属性,原型模式用于定义方法和共享的属性。
另外,这种混成模式还支持像构造函数传递参数。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["friend.1","friend.2"];
}
Person.prototype = {
    constructor: Person,
    sayName: function() {
        console.log(this.name);
    }
};

var person1 = new Person("Nicholas",29,"Software Engineer");
var person2 = new Person("Greg","27",'Doctor');

person1.friends.push("friend.3");

console.log(person1.friends);           // Array(3) ["friend.1", "friend.2", "friend.3"]
console.log(person2.friends);           // Array(2) ["friend.1", "friend.2"]
console.log(person1.friends === person2.friends);   // false
console.log(person1.sayName === person2.sayName);   // true

这种构造函数与原型混成的模式,是ES中使用最广泛、认同度最高的一种创建自定义类型的方法。可以说,用来定义引用类型的一种默认模式。


动态原型模式
function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    if (typeof this.sayName != "function") {
        Person.prototype.sayName = function() {
            console.log(this.name);
        }
    }
}

var friend = new Person("Nicholas",29,"Software Engineer");
friend.sayName();      // "Nicholas"

在if语句中,只在sayName()方法不存在的情况下,才会将它添加到原型中。这段代码只会在初次调用构造函数时才会执行。此后,原型完成初始化,不需要再做什么修改了。
不过要记住,这里对原型做的修改,会立即在所有实例中得到反映 。其中,if语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆if语句检查每个属性和方法;只要检查其中一个即可。

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


稳妥构造函数模式

Douglas Crockford 发明了JavaScript中的稳妥对象(durable objects)这个慨念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this对象。稳妥对象最适合在一些安全的环境(这些环境会禁止使用this和new),或者在防止数据被其他应用程序改动时使用。

function Person(name,age,job) {
    // 创建要返回的对象
    var o = new Object();
    // 可以在这里定义私有变量和函数

    // 添加方法
    o.sayName = function() {
        console.log(name);
    };

    // 返回对象
    return o;
}

在以这种模式创建的对象中,除了使用sayName()方法之外,没有其他方法访问name的值。
可以这样使用稳妥的Person构造函数。

var friend = Person("Nicholas",29,"Software Engineer");
friend.sayName();       // "Nicholas"

这样,变量friend中保存的是一个稳妥对象,而除了对象sayName()方法外,没有别的方法可以访问数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的方法访问转入到构造函数中的原始数据,很安全。


继承

继承是OO语言中的一个最为人津津乐道的概念。许多OO语言都支持两种继承方式:接口继承和实现继承。接口继承值继承方法签名,实现继承则继承实际的方法。由于函数没有签名,在ES中无法实现接口继承。ES只支持实现继承,而且其实现继承主要依靠原型链完成。


原型链

ES中描述了原型链的概念,并将原型链作为主要方法。基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么假如我们让一些对象等于另一个类型的实例,结果会怎样?显然,此时的一些对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。

实现原型链的一中基本模式:

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();
console.log(instance.getSuperValue());      // true

上例中,定义了两个类型:SuperType和SubType。每个类型分别有一个属性和一个方法。它们的主要区别是SubType继承了SuperType,而继承是通过创建SuperType的实例,并将该实例赋给SubType.prototype实现的。
实现的本质时重写原型对象,代之以一个新类型的实例。换句话说,原来存在于SuperType实例中的所有属性和方法,现在也存在于SubType.prototype中了。

上面代码中,我们没有使用SubType默认提供的原型,而是给它换了一个新原型;这个新原型就是SuperType的一个实例。于是,新原型不仅具有作为一个SuperType的实例所拥有的全部属性和方法,而且其内部还有一个指针,指向了SuperType的原型。
要注意instance.constructor现在指向的是SuperType,这是因为原来SubType.prototype中的constructor被重写的 缘故。

通过实现原型链,本质上扩展了原型搜索机制

调用instance.getSuperValue(),1)搜索实例;2)搜索SubType.prototype;3)搜索SuperType.prototype。

1. 别忘记默认的原型
事实上,前面例子的原型链还少一环。引用类型默认继承Object,而这个继承也是通过原型链实现的。函数的默认原型都是Object的实例,因此默认原型都会包含一个内部指针,指向Object.prototype。这也是所有自定义类型都会继承toString(),valueOf()等默认方法的原因。

完整的原型链所示:


一句话:SubType继承了SuperType,而SuperType继承了Object。


2. 确定原型和实例的关系

可以通过两种方式确定原型和实例的关系。第一种是使用instanceof操作符。只要用这个操作符来测试实例与原型链中出现过的构造函数。

console.log(instance instanceof SubType);
console.log(instance instanceof SuperType);
console.log(instance instanceof Object);

由于原型链的关系,测试这三个构造函数的结果都返回了true;

第二种方式使用isPrototypeOf()。同样,只要是原型链中出现过的原型,都可以说是原型链所派生的实例的原型。因此也会返回true。

console.log(Object.prototype.isPrototypeOf(instance));      // true
console.log(SubType.prototype.isPrototypeOf(instance));     // true
console.log(SuperType.prototype.isPrototypeOf(instance));   // true

3. 谨慎的定义方法

子类型有时候需要覆盖超类型中的某个方法,或者需要添加超类型中不存在的某个方法。但不管怎么样,给原型添加方法的代码一定要放在替换原型的语句之后。

function SuperType() {
    this.prototype = true;
}
SuperType.prototype.getSuperValue = function() {
    console.log(this.prototype);
};

function SubType() {
    this.subprototype = false;
}

// inherit
SubType.prototype = new SuperType();

// Add new method
SubType.prototype.getSubValue = function() {
    console.log()
}

// Rewrite SuperType inside method
SubType.prototype.getSuperValue = function() {
    console.log(false);
}

var instance = new SubType();
instance.getSuperValue();               // false

在 SubType的实例链接到SuperType后定义的两个方法,第一个方法getSubValue()被添加到了SubType中。第二个方法getSuperValue()是原型链已经存在的一个方法,但重写这个方法将会屏蔽原来的那个方法。胡那句话说,SubType的实例调用getSuperValue()时,调用的就是这个重新定义的方法;通过SuperType的实例调用getSuperValue()时,还会继续调用原来的那个方法。

还有一点需要注意,即在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做会重写原型链。

function SuperType() {
    this.property = true;
}
SuperType.prototype.getSuperValue = function() {
    console.log(this.property);
};

function SubType() {
    this.subproperty = false;
}

// 继承SuperType的原型
SubType.prototype = new SuperType();

SubType.prototype = {
    getSubValue: function() {
        console.log(this.subproperty);
    },
    someOhterMethod: function() {
        console.log(false);
    }
}

var instance = new SubType();
instance.getsuper();            // Error!

以上代码展示了刚刚SuperType的实例赋值给原型,紧接着又将原型替换成一个对象字面量而导致的问题。由于现在的原型链包含的是一个Object的实例,而非SuperType的实例,因此我们设想中的原型链已经被切断——SuperType和SubType之间已经没有关系了。

4. 原型链的问题
原型链虽然很强大,可以用它来实现继承,但它也存在一些问题。其中,最主要问题来自包含引用类型值的原型。

function SuperType() {
    this.colors = ["red","blue","green"];
}
function SubType() {};

// 继承了SuperType
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors);      // Array(4) ["red", "blue", "green", "black"]

var instance2 = new SubType();
console.log(instance2.colors);      // Array(4) ["red", "blue", "green", "black"]

这个例子中的SuperType构造函数定义了一个colors属性,该属性包含一个数组(引用类型)。SuperType的每个实例都会包含自己数组的colors属性。当SubType通过原型链继承了SuperType之后,SubType.prototype就变成了SuperType的一个实例,因此它拥有了一个它自己的colors属性。
但是,SubType的所有实例都会共享这一个colors属性。而我们对instanceof1.colors的修改能够通过instance2.colors反映出来
原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。或者应该说:是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数
有鉴于此,实践中很少会单独使用原型链。


借用构造函数

在解决原型中包含引用类型值所带来问题的过程中,开发者开始使用一种叫做借用构造函数的技术(也叫伪造对象或者经典继承)。这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数,别忘了,函数只不过是在特定环境中执行代码的对象,因此通过apply和call方法也可以在新创建的对象上执行构造函数。

function SuperType() {
    this.colors = ["red","blue","green"];
}
function SubType() {
    // 继承了SuperType
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors);      // ["red","blue","green","black"];

var instance2 = new SubType();
console.log(instance2.colors);      // ["red","blue","green"];

代码中加粗的那一行代码"借调"了超类型的构造函数,通过call()方法,我们实际上是在(未来将要)新创建的SubType实例的环境下调用了SuperType或者函数。这样一来,就会在新SubType对象上执行SuperType()函数定义的所有对象初始化代码。结果,SubType的每个实例就都会具有自己的colors属性的副本了。

这里可能有一些反直觉,以下改写帮助理解:

function SuperType() {
    this.colors = ["red","blue","green"];
}
function SubType() {
    // 继承了SuperType
    SuperType.call(this);
}

SuperType.call(window);
console.log(window.colors);     // Array(3) ["red", "blue", "green"]

SuperType.call(SubType);
console.log(SubType.colors);    // Array(3) ["red", "blue", "green"]

console.log(window.hasOwnProperty("colors"));     // true
console.log(SubType.hasOwnProperty("colors"));    // true

1. 传递参数
相对于原型链而言,借用构造函数有一个很大优势,即可以在子类型构造函数中超类型构造函数传参。

function SuperType(name) {
    this.name = name;
}

function SubType() {
    // 继承SuperType,同时传递参数
    SuperType.call(this,"Nicholas");
    
    // 实例属性
    this.age = 29;
}

var instance = new SubType();
console.log(instance.name);     // "Nicholas"
console.log(instance.age);      // 29

2.借用构造函数的问题
如果仅仅是借用构造函数,那么也将无法避免构造函数模式存在的问题——方法都在构造函数中定义,因此函数复用就无从谈起了。而且,超类中原型定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。


组合继承

组合继承,有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是原型链实现对象原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,即通过在原型上定义方法实现了函数复用,又能够保证每个实例都有自己的属性。

function SuperType(name) {
    this.name = name;
    this.colors = ["red","blue","green"];
}
SuperType.prototype.sayName = function() {
    console.log(this.name);
}

function SubType(name,age) {
    // inherit
    SuperType.call(this,name);

    this.age = age;
}

// inherit method
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
    console.log(this.age);
}


var instance1 = new SubType("Nicholas",29);
instance1.colors.push("black");

console.log(instance1.colors);  // Array(4) ["red", "blue", "green", "black"]
instance1.sayName();            // "Nicholas"
instance1.sayAge();             // 29

var instance2 = new SubType("Greg",27);
console.log(instance2.colors);  // Array(3) ["red", "blue", "green"]
instance2.sayName();            // "Greg"
instance2.sayAge();             // 27

这个例子中,SuperType构造函数定义了两个属性:namecolorsSuperType的原型定义了一个方法sayName()SubType构造函数在调用SuperType构造函数时传入了name参数,紧接着又定义了方法sayAge()。这样一来,就可以让两个不同的SubType实例即分别拥有自己属性———包括colors属性,又可以使用相同的方法了。
组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为JS中最常用的继承模式。而且instanceofisPrototypeOf()也能用于识别基于组合继承创建的对象。


原型式继承

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

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

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

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");

console.log(person.friends);        // Array(5) ["Shelby", "Court", "Van", "Rob", "Barbie"]

原型式继承,要求必须有一个对象可以作为另一个对象的基础。如果有这么一个对象的话,可以把它传递给object()函数,然后在根据具体需求对得到的对象加以修改即可。在这个例子中,可以作为另一个对象基础的是person对象。于是将它传入object函数中,然后该函数返回一个新对象。这个新对象将person作为原型,所以它的原型中就包含一个基本类型值属性和一个引用类型值属性。这意味着person.friends不仅属于person所有,而且也会被anotherPerson以及yetAnotherPerson共享。
实际上,这相当于又创建了person对象的两个副本 。

ECMAScript5通过新增Object.create()方法规范化了原型式的继承。这个方法接受两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create()与object()方法的行为相同。

var person = {
    name: "Nicholas",
    friends: ["Shelby","Court","Van"]
};

var anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);   // Array(5) ["Shelby", "Court", "Van", "Rob", "Barbie"]

Object.create()方法的第二个参数与Object.defineProperties()方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式指定的任何属性都会覆盖原型对象上的同名属性,例如:

var person = {
    name: "Nicholas",
    friends: ["Shelby","Court","Van"]
};

var anotherPerson = Object.create(person,{
    name: {value: "Greg"}
});

console.log(anotherPerson.name);        // "Greg"

在没有必要兴师动众的创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式继承完全可以胜任的。
不过别忘了,包含引用类型值的属性始终都会共享相应的值,就像使用原型模式一样。


寄生式继承

寄生式继承是与原型式继承紧密相关的一种思路。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后在返回对象。

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

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

在这个例子中,createAnother()函数接收了一个参数,也就是将要作为新对象基础的对象。然后,把这个对象(original)传递给object()函数,将返回的结果赋值给clone。再为clone对象添加一个新方法sayHi(),最后返回clone对象。可以这样使用createAnother()函数:

var person = {
    name: "Nicholas",
    friends: ["Shelby","Court","Van"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi();      // hi


继承组合式继承

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

function SuperType(name) {
    this.name = name;
    this.colors = ["red","blue","green"];
}
SuperType.prototype.sayName = function() {console.log(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() {console.log(this.age)};

在第一次调用SuperType构造函数时,SubType.prototype会得到两个属性:name和colors;它们都是SuperType的实例属性,只不过现在位于SubType的原型中。
当调用SubType构造函数时,又会调用一次SuperType构造函数,这一次又在新对象上创建了实例属性name和colors。于是,这两个属性就屏蔽了原型中两个同名属性。

过程如下图:


有两组name和colors属性:一组在实例上,一组在SubType原型中,这就是调用两次SuperType构造函数的记过。解决这个问题的方法——寄生组合式继承。

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。

寄生组合式继承基本模式:

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

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

这个示例中的inheritPrototype()函数实现了寄生组合式继承的最简单形式。这个函数接受两个参数:子类型构造函数和超类型构造函数。在函数内部,第一步创建超类型原型的一个副本。第二步是为创建的副本添加constructor属性,从而弥补因重写原型而失去的默认的constructor属性。
最有一步,将新创建的对象(即副本)赋值给子类型的原型。这样,就可以调用inheritPrototype()函数的语句,去替换前面例子中为子类型原型赋值的语句了。

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

function SuperType(name) {
    this.name = name;
    this.colors = ["red","blue","green"];
}
SuperType.prototype.sayName = function() {console.log(this.name)};

function SubType(name,age) {
    SuperType.call(this,name);

    this.age = age;
}

inheritPrototype(SubType,SuperType);
SubType.prototype.sayAge = function() {
    console.log(this.age);
}

这个例子的高效率体现在它只调用了一次SuperType构造函数,并且因此避免了在SubType.prototype上面创建不必要、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用instanceof和isPrototypeof()。

推荐阅读更多精彩内容