《JavaScript 闖關(guān)記》之原型及原型鏈

原型鏈?zhǔn)且环N機(jī)制,指的是 JavaScript 每個(gè)對(duì)象都有一個(gè)內(nèi)置的 __proto__ 屬性指向創(chuàng)建它的構(gòu)造函數(shù)的 prototype(原型)屬性。原型鏈的作用是為了實(shí)現(xiàn)對(duì)象的繼承,要理解原型鏈,需要先從函數(shù)對(duì)象constructornewprototype__proto__ 這五個(gè)概念入手。

函數(shù)對(duì)象

前面講過,在 JavaScript 里,函數(shù)即對(duì)象,程序可以隨意操控它們。比如,可以把函數(shù)賦值給變量,或者作為參數(shù)傳遞給其他函數(shù),也可以給它們?cè)O(shè)置屬性,甚至調(diào)用它們的方法。下面示例代碼對(duì)「普通對(duì)象」和「函數(shù)對(duì)象」進(jìn)行了區(qū)分。

普通對(duì)象:

var o1 = {};
var o2 = new Object();

函數(shù)對(duì)象:

function f1(){};
var f2 = function(){};
var f3 = new Function('str','console.log(str)');

簡(jiǎn)單的說,凡是使用 function 關(guān)鍵字或 Function 構(gòu)造函數(shù)創(chuàng)建的對(duì)象都是函數(shù)對(duì)象。而且,只有函數(shù)對(duì)象才擁有 prototype (原型)屬性。

constructor 構(gòu)造函數(shù)

函數(shù)還有一種用法,就是把它作為構(gòu)造函數(shù)使用。像 ObjectArray 這樣的原生構(gòu)造函數(shù),在運(yùn)行時(shí)會(huì)自動(dòng)出現(xiàn)在執(zhí)行環(huán)境中。此外,也可以創(chuàng)建自定義的構(gòu)造函數(shù),從而自定義對(duì)象類型的屬性和方法。如下代碼所示:

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("Stone", 28, "Software Engineer");
var person2 = new Person("Sophie", 29, "English Teacher");

在這個(gè)例子中,我們創(chuàng)建了一個(gè)自定義構(gòu)造函數(shù) Person(),并通過該構(gòu)造函數(shù)創(chuàng)建了兩個(gè)普通對(duì)象 person1person2,這兩個(gè)普通對(duì)象均包含3個(gè)屬性和1個(gè)方法。

你應(yīng)該注意到函數(shù)名 Person 使用的是大寫字母 P。按照慣例,構(gòu)造函數(shù)始終都應(yīng)該以一個(gè)大寫字母開頭,而非構(gòu)造函數(shù)則應(yīng)該以一個(gè)小寫字母開頭。這個(gè)做法借鑒自其他面向?qū)ο笳Z言,主要是為了區(qū)別于 JavaScript 中的其他函數(shù);因?yàn)闃?gòu)造函數(shù)本身也是函數(shù),只不過可以用來創(chuàng)建對(duì)象而已。

new 操作符

要?jiǎng)?chuàng)建 Person 的新實(shí)例,必須使用 new 操作符。以這種方式調(diào)用構(gòu)造函數(shù)實(shí)際上會(huì)經(jīng)歷以下4個(gè)步驟:

  1. 創(chuàng)建一個(gè)新對(duì)象;
  2. 將構(gòu)造函數(shù)的作用域賦給新對(duì)象(因此 this 就指向了這個(gè)新對(duì)象);
  3. 執(zhí)行構(gòu)造函數(shù)中的代碼(為這個(gè)新對(duì)象添加屬性);
  4. 返回新對(duì)象。

將構(gòu)造函數(shù)當(dāng)作函數(shù)

構(gòu)造函數(shù)與其他函數(shù)的唯一區(qū)別,就在于調(diào)用它們的方式不同。不過,構(gòu)造函數(shù)畢竟也是函數(shù),不存在定義構(gòu)造函數(shù)的特殊語法。任何函數(shù),只要通過 new 操作符來調(diào)用,那它就可以作為構(gòu)造函數(shù);而任何函數(shù),如果不通過 new 操作符來調(diào)用,那它跟普通函數(shù)也不會(huì)有什么兩樣。例如,前面例子中定義的 Person() 函數(shù)可以通過下列任何一種方式來調(diào)用。

// 當(dāng)作構(gòu)造函數(shù)使用
var person = new Person("Stone", 28, "Software Engineer");
person.sayName(); // "Stone"

// 作為普通函數(shù)調(diào)用
Person("Sophie", 29, "English Teacher"); // 添加到 window
window.sayName(); // "Sophie"

// 在另一個(gè)對(duì)象的作用域中調(diào)用
var o = new Object();
Person.call(o, "Tommy", 3, "Baby");
o.sayName(); // "Tommy"

這個(gè)例子中的前兩行代碼展示了構(gòu)造函數(shù)的典型用法,即使用 new 操作符來創(chuàng)建一個(gè)新對(duì)象。接下來的兩行代碼展示了不使用 new 操作符調(diào)用 Person() 會(huì)出現(xiàn)什么結(jié)果,屬性和方法都被添加給 window 對(duì)象了。當(dāng)在全局作用域中調(diào)用一個(gè)函數(shù)時(shí),this 對(duì)象總是指向 Global 對(duì)象(在瀏覽器中就是 window 對(duì)象)。因此,在調(diào)用完函數(shù)之后,可以通過 window 對(duì)象來調(diào)用 sayName() 方法,并且還返回了 "Sophie" 。最后,也可以使用 call()(或者 apply())在某個(gè)特殊對(duì)象的作用域中調(diào)用 Person() 函數(shù)。這里是在對(duì)象 o 的作用域中調(diào)用的,因此調(diào)用后 o 就擁有了所有屬性和 sayName() 方法。

構(gòu)造函數(shù)的問題

構(gòu)造函數(shù)模式雖然好用,但也并非沒有缺點(diǎn)。使用構(gòu)造函數(shù)的主要問題,就是每個(gè)方法都要在每個(gè)實(shí)例上重新創(chuàng)建一遍。在前面的例子中,person1person2 都有一個(gè)名為 sayName() 的方法,但那兩個(gè)方法不是同一個(gè) Function 的實(shí)例。因?yàn)?JavaScript 中的函數(shù)是對(duì)象,因此每定義一個(gè)函數(shù),也就是實(shí)例化了一個(gè)對(duì)象。從邏輯角度講,此時(shí)的構(gòu)造函數(shù)也可以這樣定義。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = new Function("console.log(this.name)"); // 與聲明函數(shù)在邏輯上是等價(jià)的
}

從這個(gè)角度上來看構(gòu)造函數(shù),更容易明白每個(gè) Person 實(shí)例都包含一個(gè)不同的 Function 實(shí)例(sayName() 方法)。說得明白些,以這種方式創(chuàng)建函數(shù),雖然創(chuàng)建 Function 新實(shí)例的機(jī)制仍然是相同的,但是不同實(shí)例上的同名函數(shù)是不相等的,以下代碼可以證明這一點(diǎn)。

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

然而,創(chuàng)建兩個(gè)完成同樣任務(wù)的 Function 實(shí)例的確沒有必要;況且有 this 對(duì)象在,根本不用在執(zhí)行代碼前就把函數(shù)綁定到特定對(duì)象上面。因此,大可像下面這樣,通過把函數(shù)定義轉(zhuǎn)移到構(gòu)造函數(shù)外部來解決這個(gè)問題。

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("Stone", 28, "Software Engineer");
var person2 = new Person("Sophie", 29, "English Teacher");

在這個(gè)例子中,我們把 sayName() 函數(shù)的定義轉(zhuǎn)移到了構(gòu)造函數(shù)外部。而在構(gòu)造函數(shù)內(nèi)部,我們將 sayName 屬性設(shè)置成等于全局的 sayName 函數(shù)。這樣一來,由于 sayName 包含的是一個(gè)指向函數(shù)的指針,因此 person1person2 對(duì)象就共享了在全局作用域中定義的同一個(gè) sayName() 函數(shù)。這樣做確實(shí)解決了兩個(gè)函數(shù)做同一件事的問題,可是新問題又來了,在全局作用域中定義的函數(shù)實(shí)際上只能被某個(gè)對(duì)象調(diào)用,這讓全局作用域有點(diǎn)名不副實(shí)。而更讓人無法接受的是,如果對(duì)象需要定義很多方法,那么就要定義很多個(gè)全局函數(shù),于是我們這個(gè)自定義的引用類型就絲毫沒有封裝性可言了。好在,這些問題可以通過使用原型來解決。

prototype 原型

我們創(chuàng)建的每個(gè)函數(shù)都有一個(gè) prototype(原型)屬性。使用原型的好處是可以讓所有對(duì)象實(shí)例共享它所包含的屬性和方法。換句話說,不必在構(gòu)造函數(shù)中定義對(duì)象實(shí)例的信息,而是可以將這些信息直接添加到原型中,如下面的例子所示。

function Person(){}

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

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

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

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

在此,我們將 sayName() 方法和所有屬性直接添加到了 Personprototype 屬性中,構(gòu)造函數(shù)變成了空函數(shù)。即使如此,也仍然可以通過調(diào)用構(gòu)造函數(shù)來創(chuàng)建新對(duì)象,而且新對(duì)象還會(huì)具有相同的屬性和方法。但與前面的例子不同的是,新對(duì)象的這些屬性和方法是由所有實(shí)例共享的。換句話說,person1person2 訪問的都是同一組屬性和同一個(gè) sayName() 函數(shù)。

理解原型對(duì)象

在默認(rèn)情況下,所有原型對(duì)象都會(huì)自動(dòng)獲得一個(gè) constructor(構(gòu)造函數(shù))屬性,這個(gè)屬性包含一個(gè)指向 prototype 屬性所在函數(shù)的指針。就拿前面的例子來說,Person.prototype.constructor 指向 Person。而通過這個(gè)構(gòu)造函數(shù),我們還可繼續(xù)為原型對(duì)象添加其他屬性和方法。

雖然可以通過對(duì)象實(shí)例訪問保存在原型中的值,但卻不能通過對(duì)象實(shí)例重寫原型中的值。如果我們?cè)趯?shí)例中添加了一個(gè)屬性,而該屬性與實(shí)例原型中的一個(gè)屬性同名,那我們就在實(shí)例中創(chuàng)建該屬性,該屬性將會(huì)屏蔽原型中的那個(gè)屬性。來看下面的例子。

function Person(){}

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

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

person1.name = "Sophie";
console.log(person1.name);     // "Sophie",來自實(shí)例
console.log(person2.name);     // "Stone",來自原型

在這個(gè)例子中,person1name 被一個(gè)新值給屏蔽了。但無論訪問 person1.name 還是訪問 person2.name 都能夠正常地返回值,即分別是 "Sophie"(來自對(duì)象實(shí)例)和 "Stone"(來自原型)。當(dāng)訪問 person1.name 時(shí),需要讀取它的值,因此就會(huì)在這個(gè)實(shí)例上搜索一個(gè)名為 name 的屬性。這個(gè)屬性確實(shí)存在,于是就返回它的值而不必再搜索原型了。當(dāng)訪問 person2. name 時(shí),并沒有在實(shí)例上發(fā)現(xiàn)該屬性,因此就會(huì)繼續(xù)搜索原型,結(jié)果在那里找到了 name 屬性。

當(dāng)為對(duì)象實(shí)例添加一個(gè)屬性時(shí),這個(gè)屬性就會(huì)屏蔽原型中保存的同名屬性;換句話說,添加這個(gè)屬性只會(huì)阻止我們?cè)L問原型中的那個(gè)屬性,但不會(huì)修改那個(gè)屬性。即使將這個(gè)屬性設(shè)置為 null ,也只會(huì)在實(shí)例中設(shè)置這個(gè)屬性,而不會(huì)恢復(fù)其指向原型的連接。不過,使用 delete 操作符則可以完全刪除實(shí)例屬性,從而讓我們能夠重新訪問原型中的屬性,如下所示。

function Person(){}

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

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

person1.name = "Sophie";
console.log(person1.name);     // "Sophie",來自實(shí)例
console.log(person2.name);     // "Stone",來自原型

delete person1.name;
console.log(person1.name);     // "Stone",來自原型

在這個(gè)修改后的例子中,我們使用 delete 操作符刪除了 person1.name,之前它保存的 "Sophie" 值屏蔽了同名的原型屬性。把它刪除以后,就恢復(fù)了對(duì)原型中 name 屬性的連接。因此,接下來再調(diào)用 person1.name 時(shí),返回的就是原型中 name 屬性的值了。

更簡(jiǎn)單的原型語法

前面例子中每添加一個(gè)屬性和方法就要敲一遍 Person.prototype。為減少不必要的輸入,也為了從視覺上更好地封裝原型的功能,更常見的做法是用一個(gè)包含所有屬性和方法的對(duì)象字面量來重寫整個(gè)原型對(duì)象,如下面的例子所示。

function Person(){}

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

在上面的代碼中,我們將 Person.prototype 設(shè)置為等于一個(gè)以對(duì)象字面量形式創(chuàng)建的新對(duì)象。最終結(jié)果相同,但有一個(gè)例外:constructor 屬性不再指向 Person 了。前面曾經(jīng)介紹過,每創(chuàng)建一個(gè)函數(shù),就會(huì)同時(shí)創(chuàng)建它的 prototype 對(duì)象,這個(gè)對(duì)象也會(huì)自動(dòng)獲得 constructor 屬性。而我們?cè)谶@里使用的語法,本質(zhì)上完全重寫了默認(rèn)的 prototype 對(duì)象,因此 constructor 屬性也就變成了新對(duì)象的 constructor 屬性(指向 Object 構(gòu)造函數(shù)),不再指向 Person 函數(shù)。此時(shí),盡管 instanceof 操作符還能返回正確的結(jié)果,但通過 constructor 已經(jīng)無法確定對(duì)象的類型了,如下所示。

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 操作符測(cè)試 ObjectPerson 仍然返回 true,但 constructor 屬性則等于 Object 而不等于 Person 了。如果 constructor 的值真的很重要,可以像下面這樣特意將它設(shè)置回適當(dāng)?shù)闹怠?/p>

function Person(){}

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

以上代碼特意包含了一個(gè) constructor 屬性,并將它的值設(shè)置為 Person ,從而確保了通過該屬性能夠訪問到適當(dāng)?shù)闹怠?/p>

注意,以這種方式重設(shè) constructor 屬性會(huì)導(dǎo)致它的 [[Enumerable]] 特性被設(shè)置為 true。默認(rèn)情況下,原生的 constructor 屬性是不可枚舉的,因此如果你使用兼容 ECMAScript 5 的 JavaScript 引擎,可以試一試 Object.defineProperty()

function Person(){}

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

// 重設(shè)構(gòu)造函數(shù),只適用于 ECMAScript 5 兼容的瀏覽器
Object.defineProperty(Person.prototype, "constructor", {
    enumerable: false,
    value: Person
});

原型的動(dòng)態(tài)性

由于在原型中查找值的過程是一次搜索,因此我們對(duì)原型對(duì)象所做的任何修改都能夠立即從實(shí)例上反映出來,即使是先創(chuàng)建了實(shí)例后修改原型也照樣如此。請(qǐng)看下面的例子。

var friend = new Person();

Person.prototype.sayHi = function(){
    console.log("hi");
};

friend.sayHi();   // "hi"(沒有問題!)

以上代碼先創(chuàng)建了 Person 的一個(gè)實(shí)例,并將其保存在 friend 中。然后,下一條語句在 Person.prototype 中添加了一個(gè)方法 sayHi()。即使 person 實(shí)例是在添加新方法之前創(chuàng)建的,但它仍然可以訪問這個(gè)新方法。其原因可以歸結(jié)為實(shí)例與原型之間的松散連接關(guān)系。當(dāng)我們調(diào)用 friend.sayHi() 時(shí),首先會(huì)在實(shí)例中搜索名為 sayHi 的屬性,在沒找到的情況下,會(huì)繼續(xù)搜索原型。因?yàn)閷?shí)例與原型之間的連接只不過是一個(gè)指針,而非一個(gè)副本,因此就可以在原型中找到新的 sayHi 屬性并返回保存在那里的函數(shù)。

盡管可以隨時(shí)為原型添加屬性和方法,并且修改能夠立即在所有對(duì)象實(shí)例中反映出來,但如果是重寫整個(gè)原型對(duì)象,那么情況就不一樣了。我們知道,調(diào)用構(gòu)造函數(shù)時(shí)會(huì)為實(shí)例添加一個(gè)指向最初原型的 [[Prototype]] 指針,而把原型修改為另外一個(gè)對(duì)象就等于切斷了構(gòu)造函數(shù)與最初原型之間的聯(lián)系。請(qǐng)記住:實(shí)例中的指針僅指向原型,而不指向構(gòu)造函數(shù)。看下面的例子。

function Person(){}

var friend = new Person();

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

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

在這個(gè)例子中,我們先創(chuàng)建了 Person 的一個(gè)實(shí)例,然后又重寫了其原型對(duì)象。然后在調(diào)用 friend.sayName() 時(shí)發(fā)生了錯(cuò)誤,因?yàn)?friend 指向的是重寫前的原型對(duì)象,其中并不包含以該名字命名的屬性。

原生對(duì)象的原型

原型的重要性不僅體現(xiàn)在創(chuàng)建自定義類型方面,就連所有原生的引用類型,都是采用這種模式創(chuàng)建的。所有原生引用類型(ObjectArrayString,等等)都在其構(gòu)造函數(shù)的原型上定義了方法。例如,在 Array.prototype 中可以找到 sort() 方法,而在 String.prototype 中可以找到 substring() 方法,如下所示。

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

通過原生對(duì)象的原型,不僅可以取得所有默認(rèn)方法的引用,而且也可以定義新方法。可以像修改自定義對(duì)象的原型一樣修改原生對(duì)象的原型,因此可以隨時(shí)添加方法。下面的代碼就給基本包裝類型 String 添加了一個(gè)名為 startsWith() 的方法。

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

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

這里新定義的 startsWith() 方法會(huì)在傳入的文本位于一個(gè)字符串開始時(shí)返回 true。既然方法被添加給了 String.prototype ,那么當(dāng)前環(huán)境中的所有字符串就都可以調(diào)用它。由于 msg 是字符串,而且后臺(tái)會(huì)調(diào)用 String 基本包裝函數(shù)創(chuàng)建這個(gè)字符串,因此通過 msg 就可以調(diào)用 startsWith() 方法。

盡管可以這樣做,但我們不推薦在產(chǎn)品化的程序中修改原生對(duì)象的原型。如果因某個(gè)實(shí)現(xiàn)中缺少某個(gè)方法,就在原生對(duì)象的原型中添加這個(gè)方法,那么當(dāng)在另一個(gè)支持該方法的實(shí)現(xiàn)中運(yùn)行代碼時(shí),就可能會(huì)導(dǎo)致命名沖突。而且,這樣做也可能會(huì)意外地重寫原生方法。

原型對(duì)象的問題

原型模式也不是沒有缺點(diǎn)。首先,它省略了為構(gòu)造函數(shù)傳遞初始化參數(shù)這一環(huán)節(jié),結(jié)果所有實(shí)例在默認(rèn)情況下都將取得相同的屬性值。雖然這會(huì)在某種程度上帶來一些不方便,但還不是原型的最大問題。原型模式的最大問題是由其共享的本性所導(dǎo)致的。

原型中所有屬性是被很多實(shí)例共享的,這種共享對(duì)于函數(shù)非常合適。對(duì)于那些包含基本值的屬性倒也說得過去,畢竟(如前面的例子所示),通過在實(shí)例上添加一個(gè)同名屬性,可以隱藏原型中的對(duì)應(yīng)屬性。然而,對(duì)于包含引用類型值的屬性來說,問題就比較突出了。來看下面的例子。

function Person(){}

Person.prototype = {
    constructor: Person,
    name : "Stone",
    age : 28,
    job : "Software Engineer",
    friends : ["ZhangSan", "LiSi"],
    sayName : function () {
        console.log(this.name);
    }
};

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

person1.friends.push("WangWu");

console.log(person1.friends);    // "ZhangSan,LiSi,WangWu"
console.log(person2.friends);    // "ZhangSan,LiSi,WangWu"
console.log(person1.friends === person2.friends);  // true

在此,Person.prototype 對(duì)象有一個(gè)名為 friends 的屬性,該屬性包含一個(gè)字符串?dāng)?shù)組。然后,創(chuàng)建了 Person 的兩個(gè)實(shí)例。接著,修改了 person1.friends 引用的數(shù)組,向數(shù)組中添加了一個(gè)字符串。由于 friends 數(shù)組存在于 Person.prototype 而非 person1 中,所以剛剛提到的修改也會(huì)通過 person2.friends(與 person1.friends 指向同一個(gè)數(shù)組)反映出來。假如我們的初衷就是像這樣在所有實(shí)例中共享一個(gè)數(shù)組,那么對(duì)這個(gè)結(jié)果我沒有話可說。可是,實(shí)例一般都是要有屬于自己的全部屬性的。

構(gòu)造函數(shù)和原型結(jié)合

所以,構(gòu)造函數(shù)用于定義實(shí)例屬性,而原型用于定義方法和共享的屬性。結(jié)果,每個(gè)實(shí)例都會(huì)有自己的一份實(shí)例屬性的副本,但同時(shí)又共享著對(duì)方法的引用,最大限度地節(jié)省了內(nèi)存。下面的代碼重寫了前面的例子。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["ZhangSan", "LiSi"];
}

Person.prototype = {
    constructor : Person,
    sayName : function(){
        console.log(this.name);
    }
}

var person1 = new Person("Stone", 28, "Software Engineer");
var person2 = new Person("Sophie", 29, "English Teacher");

person1.friends.push("WangWu");
console.log(person1.friends);    // "ZhangSan,LiSi,WangWu"
console.log(person2.friends);    // "ZhangSan,LiSi"
console.log(person1.friends === person2.friends);    // false
console.log(person1.sayName === person2.sayName);    // true

在這個(gè)例子中,實(shí)例屬性都是在構(gòu)造函數(shù)中定義的,而由所有實(shí)例共享的屬性 constructor 和方法 sayName() 則是在原型中定義的。而修改了 person1.friends(向其中添加一個(gè)新字符串),并不會(huì)影響到 person2.friends,因?yàn)樗鼈兎謩e引用了不同的數(shù)組。

這種構(gòu)造函數(shù)與原型混成的模式,是目前在 JavaScript 中使用最廣泛、認(rèn)同度最高的一種創(chuàng)建自定義類型的方法。可以說,這是用來定義引用類型的一種默認(rèn)模式。

__proto__

為什么在構(gòu)造函數(shù)的 prototype 中定義了屬性和方法,它的實(shí)例中就能訪問呢?

那是因?yàn)楫?dāng)調(diào)用構(gòu)造函數(shù)創(chuàng)建一個(gè)新實(shí)例后,該實(shí)例的內(nèi)部將包含一個(gè)指針 __proto__,指向構(gòu)造函數(shù)的原型。Firefox、Safari 和 Chrome 的每個(gè)對(duì)象上都有這個(gè)屬性 ,而在其他瀏覽器中是完全不可見的(為了確保瀏覽器兼容性問題,不要直接使用 __proto__ 屬性,此處只為解釋原型鏈而演示)。讓我們來看下面代碼和圖片:

圖中展示了 Person 構(gòu)造函數(shù)、Person 的原型屬性以及 Person 現(xiàn)有的兩個(gè)實(shí)例之間的關(guān)系。在此,Person.prototype.constructor 指回了 PersonPerson.prototype 中除了包含 constructor 屬性之外,還包括后來添加的其他屬性。此外,要格外注意的是,雖然這兩個(gè)實(shí)例都不包含屬性和方法,但我們卻可以調(diào)用 person1.sayName()。這是因?yàn)閮?nèi)部指針 __proto__ 指向 Person.prototype,而在 Person.prototype 中能找到 sayName() 方法。

我們來證實(shí)一下,__proto__ 是不是真的指向 Person.prototype 的?如下代碼所示:

function Person(){}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

既然,__proto__ 確實(shí)是指向 Person.prototype,那么使用 new 操作符創(chuàng)建對(duì)象的過程可以演變?yōu)椋瑸閷?shí)例對(duì)象的 __proto__ 賦值的過程。如下代碼所示:

function Person(){}

// var person = new Person(); 
// 上一行代碼等同于以下過程 ==> 
var person = {};
person.__proto__ = Person.prototype;
Person.call(person);

這個(gè)例子中,我先創(chuàng)建了一個(gè)空對(duì)象 person,然后把 person.__proto__ 指向了 Person 的原型對(duì)象,便繼承了 Person 原型對(duì)象中的所有屬性和方法,最后又以 person 為作用域執(zhí)行了 Person 函數(shù),person 便就擁有了 Person 的所有屬性和方法。這個(gè)過程和 var person = new Person(); 完全一樣。

簡(jiǎn)單來說,當(dāng)我們?cè)L問一個(gè)對(duì)象的屬性時(shí),如果這個(gè)屬性不存在,那么就會(huì)去 __proto__ 里找,這個(gè) __proto__ 又會(huì)有自己的 __proto__,于是就這樣一直找下去,直到找到為止。在找不到的情況下,搜索過程總是要一環(huán)一環(huán)地前行到原型鏈末端才會(huì)停下來。

原型鏈

JavaScript 中描述了原型鏈的概念,并將原型鏈作為實(shí)現(xiàn)繼承的主要方法。其基本思想是利用原型讓一個(gè)引用類型繼承另一個(gè)引用類型的屬性和方法。簡(jiǎn)單回顧一下構(gòu)造函數(shù)、原型和實(shí)例的關(guān)系:每個(gè)構(gòu)造函數(shù)都有一個(gè)原型對(duì)象,原型對(duì)象都包含一個(gè)指向構(gòu)造函數(shù)的指針,而實(shí)例都包含一個(gè)指向原型對(duì)象的內(nèi)部指針。如下圖所示:(圖源:segmentfault.com,作者:manxisuo

那么,假如我們讓原型對(duì)象等于另一個(gè)類型的實(shí)例,結(jié)果會(huì)怎么樣呢?顯然,此時(shí)的原型對(duì)象將包含一個(gè)指向另一個(gè)原型的指針,相應(yīng)地,另一個(gè)原型中也包含著一個(gè)指向另一個(gè)構(gòu)造函數(shù)的指針。假如另一個(gè)原型又是另一個(gè)類型的實(shí)例,那么上述關(guān)系依然成立,如此層層遞進(jìn),就構(gòu)成了實(shí)例與原型的鏈條。這就是所謂原型鏈的基本概念。

上面這段話比較繞口,代碼更容易理解,讓我們來看看實(shí)現(xiàn)原型鏈的基本模式。如下代碼所示:

function Father(){
    this.value = true;
}
Father.prototype.getValue = function(){
    return this.value;
};

function Son(){
    this.value2 = false;
}

// 繼承了 Father
Son.prototype = new Father();

Son.prototype.getValue2 = function (){
    return this.value2;
};

var son = new Son();
console.log(son.getValue());  // true

以上代碼定義了兩個(gè)類型:FatherSon。每個(gè)類型分別有一個(gè)屬性和一個(gè)方法。它們的主要區(qū)別是 Son 繼承了 Father,而繼承是通過創(chuàng)建 Father 的實(shí)例,并將該實(shí)例賦給 Son.prototype 實(shí)現(xiàn)的。實(shí)現(xiàn)的本質(zhì)是重寫原型對(duì)象,代之以一個(gè)新類型的實(shí)例。換句話說,原來存在于 Father 的實(shí)例中的所有屬性和方法,現(xiàn)在也存在于 Son.prototype 中了。在確立了繼承關(guān)系之后,我們給 Son.prototype 添加了一個(gè)方法,這樣就在繼承了 Father 的屬性和方法的基礎(chǔ)上又添加了一個(gè)新方法。

我們?cè)儆?__proto__ 重寫上面代碼,更便于大家的理解:

function Father(){
    this.value = true;
}
Father.prototype.getValue = function(){
    return this.value;
};

function Son(){
    this.value2 = false;
}

// 繼承了 Father
// Son.prototype = new Father(); ==>
Son.prototype = {};
Son.prototype.__proto__ = Father.prototype;
Father.call(Son.prototype);

Son.prototype.getValue2 = function (){
    return this.value2;
};

// var son = new Son(); ==>
var son = {};
son.__proto__ = Son.prototype;
Son.call(son);

console.log(son.getValue()); // true
console.log(son.getValue === son.__proto__.__proto__.getValue); // true 

從以上代碼可以看出,實(shí)例 son 調(diào)用 getValue() 方法,實(shí)際是經(jīng)過了 son.__proto__.__proto__.getValue 的過程的,其中 son.__proto__ 等于 Son.prototype,而 Son.prototype.__proto__ 又等于 Father.prototype,所以 son.__proto__.__proto__.getValue 其實(shí)就是 Father.prototype.getValue

事實(shí)上,前面例子中展示的原型鏈還少一環(huán)。我們知道,所有引用類型默然都繼承了 Obeject,而這個(gè)繼承也是通過原型鏈實(shí)現(xiàn)的。大家要記住,所有函數(shù)的默認(rèn)原型都是 Object 的實(shí)例,因此默認(rèn)原型都會(huì)包含一個(gè)內(nèi)部指針 __proto__,指向 Object.prototype。這也正是所有自定義類型都會(huì)繼承 toString()valueOf() 等默認(rèn)方法的根本原因。

下圖展示了原型鏈實(shí)現(xiàn)繼承的全部過程。(圖源:segmentfault.com,作者:manxisuo

上圖中,pprototype 屬性,[p]__proto__ 指對(duì)象的原型,[p] 形成的鏈(虛線部分)就是原型鏈。從圖中可以得出以下信息:

  • Object.prototype 是頂級(jí)對(duì)象,所有對(duì)象都繼承自它。
  • Object.prototype.__proto__ === null ,說明原型鏈到 Object.prototype 終止。
  • Function.__proto__ 指向 Function.prototype

關(guān)卡

根據(jù)描述寫出對(duì)應(yīng)的代碼。

// 挑戰(zhàn)一
// 1.定義一個(gè)構(gòu)造函數(shù) Animal,它有一個(gè) name 屬性,以及一個(gè) eat() 原型方法。
// 2.eat() 的方法體為:console.log(this.name + " is eating something.")。
// 3.new 一個(gè) Animal 的實(shí)例 tiger,然后調(diào)用 eat() 方法。
// 4.用 __proto__ 模擬 new Animal() 的過程,然后調(diào)用 eat() 方法。

var Animal = function(name){
    // 待補(bǔ)充的代碼
};

var tiger = new Animal("tiger");
// 待補(bǔ)充的代碼

var tiger2 = {};
// 待補(bǔ)充的代碼
// 挑戰(zhàn)二
// 1.定義一個(gè)構(gòu)造函數(shù) Bird,它繼承自 Animal,它有一個(gè) name 屬性,以及一個(gè) fly() 原型方法。
// 2.fly() 的方法體為:console.log(this.name + " want to fly higher.");。
// 3.new 一個(gè) Bird 的實(shí)例 pigeon,然后調(diào)用 eat() 和 fly() 方法。
// 4.用 __proto__ 模擬 new Bird() 的過程,然后用代碼解釋 pigeon2 為何能調(diào)用 eat() 方法。

var Bird = function(name){
    // 待補(bǔ)充的代碼
}

var pigeon = new Bird("pigeon");
// 待補(bǔ)充的代碼

var pigeon2 = {};
// 待補(bǔ)充的代碼
// 挑戰(zhàn)三
// 1.定義一個(gè)構(gòu)造函數(shù) Swallow,它繼承自 Bird,它有一個(gè) name 屬性,以及一個(gè) nesting() 原型方法。
// 2.nesting() 的方法體為:console.log(this.name + " is nesting now.");。
// 3.new 一個(gè) Swallow 的實(shí)例 yanzi,然后調(diào)用 eat()、fly() 和 nesting() 方法。
// 4.用 __proto__ 模擬 new Swallow() 的過程,然后用代碼解釋 yanzi2 為何能調(diào)用 eat() 方法。

var Swallow = function(name){
    // 待補(bǔ)充的代碼
}

var yanzi = new Swallow("yanzi");
// 待補(bǔ)充的代碼

var yanzi2 = {};
// 待補(bǔ)充的代碼

更多

關(guān)注微信公眾號(hào)「劼哥舍」回復(fù)「答案」,獲取關(guān)卡詳解。
關(guān)注 https://github.com/stone0090/javascript-lessons,獲取最新動(dòng)態(tài)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,763評(píng)論 6 539
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,238評(píng)論 3 428
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,823評(píng)論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,604評(píng)論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,339評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,713評(píng)論 1 328
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,712評(píng)論 3 445
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,893評(píng)論 0 289
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,448評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,201評(píng)論 3 357
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,397評(píng)論 1 372
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,944評(píng)論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,631評(píng)論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,033評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,321評(píng)論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,128評(píng)論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,347評(píng)論 2 377

推薦閱讀更多精彩內(nèi)容