知其然,亦知其所以然——徹底搞懂JS原型繼承

ThinkMore發表於2017-10-13

曾經,我寫過下面一段程式碼,我滿心歡喜以為得到了JS物件導向程式設計和原型繼承的真諦。

var pets = {
    sound: '',
    makeSound: function() {
        console.log(this.sound);
    }
}

var cat = { sound: 'miao' };

cat.prototype = pets;
cat.makeSound();複製程式碼

然後,我將這段程式碼複製貼上到瀏覽器的console除錯工具下執行,竟然報出一個錯誤。我不得不承認,原來我根本就不懂JS的物件導向程式設計。

瀏覽器報錯
瀏覽器報錯

我的目的是,讓cat繼承pets的makeSound方法,雖然cat沒有makeSound方法,但是它可以沿著原型鏈查詢到pets的makeSound方法。但是,很明顯,這段程式碼有錯誤,無法達到我的預期。我認識到,我根本就沒有弄懂過prototype__proto__屬性的關係和作用。

如果你也不知道上面的程式碼確切的錯在哪裡,那你也需要補上這一課。如果你知道上面的程式碼錯在哪裡,但不知道為什麼是這樣的安排,這篇文章也能讓你有收穫,如標題所言,知其然,亦知其所以然。

為了講清楚這個問題,我們先拋開上面的錯誤,從構造一個簡單物件開始談起。

一個寵物製造工廠

我們將從一個簡單的工廠函式開始:

var Pets = function(sound) {
    var obj = { sound: sound };
    obj.makeSound = function() {
        console.log(obj.sound);
    }
    obj.bite = function() {
        console.log('bite');
    }
    return obj;
}

var dog = Pets('wang');
dog.makeSound(); // wang複製程式碼

上面定義了一個寵物製造工廠,它生成了一個擁有sound屬性的物件,並將接收的引數賦值sound屬性。然後在該物件上新增了兩個方法,最後將這個物件返回。有了這個函式,我們就可以製造出各種各樣的寵物了。(為了前後一致,請忽略函式首字母大寫的問題)

優化寵物製造工廠:統一管理方法函式

然而,上面的工廠函式有一個缺點。如果我們想給這個工廠函式新增更多的方法,或者刪除多餘的方法,那麼我們不得不改動這個函式本身的程式碼。當方法變得越來越多的時候,這個函式就變得難以維護。所以,我們進行如下優化:

var Pets = function(sound) {
    var obj = { sound: sound };
        extend(obj, Pets.methods); // 注意,這裡的extend函式是沒有實現的。
        return obj;
}

Pets.methods = {
    makeSound: function() {
        console.log(this.sound);
    },
    bite: function() {
        console.log('bite');
    }
}

var dog = Pets('wang');
dog.makeSound() // wang複製程式碼

可以看到,我們給Pets函式新增了一個methods屬性,用來統一儲存和維護該工廠函式的方法。當使用該函式生成obj物件時,通過一個extend函式將Pets.methods中的方法統統複製到obj中。這時,程式碼本質上沒有改變什麼,只是通過形式上的改變,使得程式碼更容易維護。如果我們想給Pets工廠函式新增新的方法,可以通過下面的方式實現,而不必修改函式:

Pets.methods.scratch = function() {/*...*/}複製程式碼

繼續優化:繼承而不是複製

上面的程式碼,每次呼叫工廠函式生成的新物件,都有一份對Pets.methods中方法的完全複製。這種建立物件的方式是低效的,既然Pets.methods中的方法是所有由工廠函式建立的物件都擁有的,我們其實並不希望每個物件都保留一份複製,而是希望通過某種方式,讓所有的物件共享方法,所以就有了繼承的概念。在JS中,Object.create函式可以實現繼承的目的,我們將程式碼改寫如下:

var Pets = function(sound) {
    var obj = Object.create(Pets.methods);
    obj.sound = sound;
    return obj;
}

Pets.methods = {
    makeSound: function() {
        console.log(this.sound);
    },
    bite: function() {
        console.log('bite');
    }
}

var dog = Pets('wang');
dog.makeSound(); // wang
dog.bite(); // bite複製程式碼

Object.create構建了一個繼承關係,即obj繼承了Pets.methods的方法。obj內部有一個[[Prototype]] 指標,指向了Pets.methods,Pets.methods也就成了該物件的原型物件。[[Prototype]]指標是一個內部屬性, 指令碼中沒有標準的方式訪問它,但是在Chrome、 Safari、Firefox中支援一個屬性__proto__,而在其他瀏覽器實現中,這個屬性都是完全不可見的。在Chrome的除錯視窗列印dog:

列印dog物件
列印dog物件

可以看到,dog並不擁有makeSound方法,但仍然可以使用該方法,因為它可以沿著__proto__指標指明的方向繼續查詢makeSound方法,一旦找到同名方法就返回該方法。(任何物件都繼承自Object物件,所以方法查詢的終點在Object處,假如查詢到達Object物件且Object物件也沒有該方法,則返回undefined)

上面的改進,通過繼承,將物件的公用方法委託給原型物件,每次建立新的物件時,就免去了屬性的複製,提高了程式碼的效能和可維護性。下面,我們對程式碼進行一點小改動:

var Pets = function(sound) {
    var obj = Object.create(Pets.prototype);
    obj.sound = sound;
    return obj;
}

Pets.prototype.makeSound = function() {
    console.log(this.sound);
}

Pets.prototype.bite = function() {
    console.log('bite');
}

var dog = Pets('wang');
dog.makeSound(); // wang
dog.bite(); // bite複製程式碼

我們把作為原型物件的Pets.methods換了一個名稱,叫做Pets.prototype。是不是覺得哪裡不對?怎麼能這麼隨意的替換呢?prototype可是JS語言中很特殊的一個屬性,有著某種很特別的功能,怎麼可能和這裡的methods一樣呢?沒錯,這麼替換,而不是一開始就使用prototype,就是想說明,其實,prototype屬性並沒有什麼神祕的地方,它的作用和這裡的methods幾乎是一樣的。

廬山真面目:建構函式

上面的這種建立物件,並將物件方法委託到原型物件的方式,在JS程式設計中是如此的常見,所以語言本身提供了一個方法,將重複的部分自動處理,程式設計師只需要關注每個物件不相同的部分,這個方法就是,建構函式:

var Pets = function(sound) {
    this.sound = sound;
}

Pets.prototype.makeSound = function() {
    console.log(this.sound);
}

Pets.prototype.bite = function() {
    console.log('bite');
}
var dog = new Pets('wang');
dog.makeSound(); // wang
dog.bite(); // bite
var cat = new Pets('miao');
cat.makeSound(); // miao
cat.bite(); // bite複製程式碼

建構函式的new操作,自動處理了繼承和返回操作。可以這麼理解new的主要作用:

var Pets = function(sound) {
    /* this = Object.create(Pets.prototype); */
    this.sound = sound;
    /* return this; */
}複製程式碼

就好像在執行new操作的時候,語言自動處理了註釋部分的程式碼,只需要我們關注將要建立的物件的特殊部分即可。(當然,上面的程式碼去掉註釋是無法執行的,因為this是隻讀的,不能賦值,瀏覽器執行會報錯。但原理是正確的。)

prototype則為建構函式的一個屬性,也是由建構函式所建立物件的原型物件。如果一定要說prototype和前面例子中的methods有什麼不同,那就是,prototype有一個預設屬性constructor,該屬性指向建構函式本身。

console.log(Pets.prototype.constructor === Pets) // true複製程式碼

順便,你認為下面的表示式應該列印什麼?

console.log(dog.constructor)複製程式碼

應該是Pets,dog自身沒有constructor屬性,所以沿著原型鏈向上查詢,找到Pets.prototype,而Pets.prototype是有這個屬性的,返回這個屬性,該屬性指向建構函式Pets,所以列印Pets。

到這裡,關於原型繼承中涉及到的建構函式、prototypeconstructor[[Prototype]]以及建立出來的物件之間的關係已經全部呈現出來了。來做個總結:

  1. prototype是建構函式的一個屬性,並沒有什麼特殊和神祕的性質。
  2. prototype是由建構函式所建立物件的原型物件,物件的公共方法和屬性可以委託到prototype。
  3. 再次強調,建構函式(如Pets)和prototype並不存在繼承關係,繼承關係存在於建構函式建立的物件和prototype之間。(Object.create建立了物件和原型之間的繼承關係,和建構函式沒有關係)
  4. constructor是語言自動賦予prototype的一個屬性,其值為建構函式本身。
  5. [[Prototype]]是物件的一個內部屬性,是一個指標,指向物件的原型物件,在Safari、Chrome和Firefox下,可以通過__proto__屬性訪問。

還是使用上面的例子,我們將所有這些關鍵詞之間的相互關係使用一個圖示展示出來:

關係圖
關係圖

錯誤解析

現在回過頭去看開頭提到的那個錯誤例子,簡直就錯的離譜啊。這個錯誤明顯神化了prototype的作用,以為只要使用了prototype屬性,然後就如同黑魔法一般,在兩個完全不相關的物件之間架起了一座橋樑,也就是繼承關係,然後就可以隨意使用另外一個物件的方法了。天真!

我的問題在於,首先,神話了prototype的作用。prototype並沒有這種黑魔法,它只是一個屬性。
其次,沒有搞明白繼承關係到底存在與哪兩個物件之間。(被建立物件和prototype之間)

所以,在錯誤的程式碼中,dog物件沒有makeSound方法,dog物件繼承Object.prototype,而非pets,而Object.prototype上並沒有所謂的makeSound方法,返回undefined,所以報錯。

以上,希望對你有所幫助。

相關文章