前言
如果你覺得JS的繼承寫起來特別費勁,特別艱澀,特別不倫不類,我想說,我也有同感。尤其是作為一個學(xué)過Java的人,看到JS的繼承簡直要崩潰。至于為什么JS的繼承讓人如此困惑,根源當(dāng)然在于JS本身的設(shè)計,即半函數(shù)式編程,半面向?qū)ο?。對歷史感興趣的人可以參考以下文章,雖然不會有恍然大悟——“原來繼承可以這么寫”的感覺,至少可以讓你對于自己的困惑找到一絲安慰。
http://www.ruanyifeng.com/blog/2011/06/designing_ideas_of_inheritance_mechanism_in_javascript.html
至于我寫這篇文章的目的,當(dāng)然是記錄一下自己的思考過程(有些東西是看了別人的代碼,在試圖理解其寫法的目的),方便以后回頭重溫,畢竟人的忘性是很大的,另一方面則是有緣人看到這篇文章,希望它多少能幫助你在思考繼承的問題上少走一點彎路。
另外免責(zé)聲明:本文寫的是自己的思考過程,雖然力求正確,不至于誤人子弟,但是難免有疏漏和錯誤,請諒解。如果能通過評論指正出來,十分感謝。
正文
1.關(guān)于原型和原型鏈
說到JS的繼承,當(dāng)然離不開原型和原型鏈,因為它們本身就是為了抽取構(gòu)造函數(shù)的共通部分而存在的。
1-1.困惑點
在原型和原型鏈的相關(guān)的問題中,很多人比較困惑的大概是以下幾個。
<1>constructor,prototype和__proto的關(guān)系
<2>Function instanceof Object 和Object instanceof Function的結(jié)果為什么都是true
<3>為什么所有的對象的原型最終都指向Object.prototype而不是Object
(這個問題可能不是大多數(shù)人都有的,但是我自己對理解這一點很是費了一番功夫。)
1-2.需要知道的點
接下來說一說關(guān)于原型和原型鏈需要知道的一些點
<1>通過構(gòu)造函數(shù)創(chuàng)建對象的內(nèi)部原理
如下面的代碼:
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = function () {
console.log(this.name);
}
}
var Tony = new Person('tony', 18);
其實通過new操作符創(chuàng)建對象的過程是這樣的。
function Person(name, age) {
//1.創(chuàng)建一個對象,用this指向它。 this = {};
//2.執(zhí)行以下方法
this.name = name;
this.age = age;
this.sayName = function () {
console.log(this.name);
}
//3.return this
}
var Tony = new Person('tony', 18);
<2>原型是函數(shù)的屬性prototype指向的內(nèi)容,原型本身是對象
首先必須要聲明一點,雖然在JS里一切皆對象,所以函數(shù)也是對象。但是方便起見,說函數(shù)的時候仍然指普通意義上的函數(shù),即通過function關(guān)鍵字聲明的類型,而說對象的時候指的是帶一對兒大括號的類型。
說到這里有必要提一下,函數(shù)本質(zhì)上是一個代碼塊,是一塊普通代碼的集合,所以函數(shù)內(nèi)部是一行行的執(zhí)行語句,語句之間用分號隔開。而對象本質(zhì)上是屬性名值對兒的集合,屬性名和屬性值之間用冒號連接在一起,不同的屬性名值對兒之間用逗號隔開。
函數(shù)身上才有prototype屬性,用以顯式地聲明一個函數(shù)的原型。而原型本身必須是一個對象。這一點可以通過簡單的代碼得以驗證,不再貼圖。
<3>聲明prototype的含義
如以下代碼:
Person.prototype.sayName = function () {
console.log(this.name);
}
function Person(name, age) {
this.name = name;
this.age = age;
}
var Tony = new Person('tony', 18);
這里給Person函數(shù)加了一個原型。當(dāng)通過new操作符創(chuàng)建Person對象的時候,會在構(gòu)造函數(shù)內(nèi)部創(chuàng)建空對象后,立刻在里面放了一個隱式屬性proto指向了Person的原型對象。
Person.prototype.sayName = function () {
console.log(this.name);
}
function Person(name, age) {
//this = {__proto__ : Person.prototype}
this.name = name;
this.age = age;
}
var Tony = new Person('tony', 18);
需要注意的是,這里雖然只是寫空對象里放了一個隱式屬性proto指向Person.prototype,從本質(zhì)上來說,Person.prototype這個詞本身并不是一個變量,也不是一個對象,并沒有夾在Person對象和Person的原型中間。當(dāng)創(chuàng)建Person對象后,Tony里面有一個proto直接指向一個對象,即:
{
sayName : function() {console.log(this.name);},
constructor : function Person(name, age) {this.name=name;this.age=age},
__proto__ : Object.prototype
}
使用prototype關(guān)鍵字把一個對象聲明為一個函數(shù)的原型,只是意味著通過該函數(shù)創(chuàng)建對象時,該對象內(nèi)部會有一個隱式屬性proto指向這個原型。這一點很重要。
<4>不顯式地聲明一個構(gòu)造函數(shù)的原型地話,系統(tǒng)會構(gòu)建一個隱式的原型
如以下代碼:
function Son(name, age, hobby) {
this.name = name;
this.age = age;
this.hobby = hobby;
this.getFullName = function () {
console.log(this.lastName + this.name);
}
}
var Datou = new Son('Datou', 18, 'scapegoat');
系統(tǒng)會隱式地為Son構(gòu)造函數(shù)創(chuàng)建一個原型對象
{
constructor : function Son() {...},
__proto__ : Object.prototype
}
這里還是想再強(qiáng)調(diào)一下,雖然我寫了Object.prototype,千萬不要把它當(dāng)成一個存在的變量或?qū)ο?,它甚至都算不上一個“指向”,而只是一個“指代”,指代的是Object的原型對象本身,那個帶大括號的對象。只不過由于它內(nèi)部的代碼很多,也沒有一個名字,所以我用prototype指代了一下。總而言之,我不想讓你誤以為存在一個叫Son.prototype的中間變量和對象,從而形成以下錯誤印象:
Datou.proto → Son.prototype → {(隱式創(chuàng)建的)原型對象} → Object.prototype → {一大堆系統(tǒng)提供方法的集合}
而真實的情況是:
Datou.proto → {(隱式創(chuàng)建的)原型對象} → {一大堆系統(tǒng)提供方法的集合}
<5>實例對象的constructor是從原型對象復(fù)制過來的
個人感覺,和prototype以及proto相比,constructor在繼承中的作用不是很大。
再來一個聲明,在本文討論的范圍內(nèi),對象分為三種,即實例對象,原型對象和Object.prototype。實例對象指的是通過new操作符創(chuàng)建出來的對象,原型對象指的是通過prototype關(guān)鍵字聲明的對象,而Object.prototype,指的是哪個一對兒大括號,里面有一堆系統(tǒng)自定義的方法的對象。
還以下面的代碼舉例:
function Son(name, age, hobby) {
this.name = name;
this.age = age;
this.hobby = hobby;
this.getFullName = function () {
console.log(this.lastName + this.name);
}
}
var Datou = new Son('Datou', 18, 'scapegoat');
Datou這個對象里有一個constructor指向系統(tǒng)隱式創(chuàng)建的Son的原型這一點我們再熟悉不過了,以至于我們可能會忽略其實Datou內(nèi)部并沒有一個叫constructor的屬性,它只是通過隱式屬性proto調(diào)用的原型上的constructor。這一點通過在控制臺打印Datou.hasOwnProperty('constructor')返回結(jié)果為false得以驗證。
結(jié)論就是只有原型對象上才有constructor屬性,把一個對象當(dāng)做是誰的原型,這個原型對象的constructor就指向誰。
<6>原型對象為什么最終都鏈接到Object.prototype上
以下面這個簡化版的代碼舉例:
Son.prototype.sayName = function () {
console.log(this.name);
}
function Son(name) {
this.name = name;
}
var Datou = new Son('Datou');
很顯然,系統(tǒng)會首先創(chuàng)建一個Son的原型對象,即:
{
sayName : function() {console.log(this.name)},
constructor : function Son(name) {this.name=name},
__proto__ : Object.prototype
}
里面有我們自定義的函數(shù)屬性sayName,同時會有一個constructor指向Son函數(shù),最后還有一個proto指向Object的原型。
在這里你會很自然的想到兩點。第一點是這個原型對象雖然看上去是我們手動通過字面量形式寫出來的,但其實一定是通過new出來的所以它內(nèi)部才有一個隱式屬性__proto。第二點是既然proto指向Object的原型,那Son的原型對象一定是通過Object函數(shù)new出來的。
事實確實如此。通過字面量的形式創(chuàng)建對象跟通過new Object()的方式創(chuàng)建對象本質(zhì)上是一樣的。所以通過以下代碼創(chuàng)建Datou的過程,以一種比較全面的角度來解讀是下面這樣的:
- 執(zhí)行var obj = new Object();
1-1)this = {};
1-2)this.proto = {一大堆系統(tǒng)提供方法的集合,由于當(dāng)前對象在Object函數(shù)中創(chuàng)建,所以該隱式屬性指向Object的原型對象};
1-3)this.sayName = function() {console.log(this.name)};
1-4)由于聲明了prototype所以this.constuctor = function Son(name) {this.name=name};
1-5)return this - 執(zhí)行var Datou = new Son('Datou');
2-1)this = {};
2-2)this.proto={由于當(dāng)前對象在Son函數(shù)中創(chuàng)建,所以該隱式屬性指向Son的原型對象,即obj指代的對象}
2-3)this.name = 'Datou';
2-4)return this;
最終,Datou就代表了如下一個對象
{
name : "Datou",
__proto__ : {包含sayName方法的那個原型對象}
}
看到這兒,應(yīng)該就能明白為什么所有的對象最終都會連接到Object的原型上了。
而這種通過隱式屬性proto不斷往上找原型的鏈條就是我們通常意義上所說的原型鏈。
2.JS繼承的實現(xiàn)方式
如果看這篇文章之前你已經(jīng)查詢過百度很多遍,想必一定看到過繼承實現(xiàn)方式的演變歷史。下面談?wù)勛约簩^承的理解,為此我準(zhǔn)備了一個例子,按照這個例子把代碼寫出來,基本上就能學(xué)會JS的繼承了。
頂部有一個Animal函數(shù),它里面有name,food和eat三個屬性,其中eat屬性是一個方法。Cat函數(shù)和Dog函數(shù)分別繼承自Animal,而Cat函數(shù)還有一個自己的屬性catMouse,最后通過Cat函數(shù)創(chuàng)建加菲貓,通過Dog函數(shù)創(chuàng)建歐弟。通過加菲貓調(diào)用eat方法,執(zhí)行的結(jié)果是加菲貓正在吃千層面,通過加菲貓調(diào)用catchMouse方法的話則打印我抓到了一只老鼠。通過歐弟調(diào)用eat方法,執(zhí)行的結(jié)果是歐弟正在啃骨頭。

2-1.首先想到的寫法
通過百度或者加入一點自己的思考,最開始得出的寫法可能是這樣的。
function Animal(name, food) {
this.name = name;
this.food = food;
this.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
}
function Cat(name, food) {
Animal.call(this, name, food);
this.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
}
function Dog(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
var Odie = new Dog("Odie", "bone");
Garfield.eat();
Garfield.catchMouse();
Odie.eat();
這種寫法從功能實現(xiàn)的角度來說已經(jīng)沒有問題了,但是沒有用到原型很讓人不爽。怎么說呢,之所以出現(xiàn)原型,就是為了提取共通的部分,這也是繼承的題中之義。上面這種寫法,單純是使用Animal函數(shù)的call方法為自己初始化變量,其實本質(zhì)上是“借腹生子”。而且看起來是寫的代碼少了,但實際上執(zhí)行的步驟可是一步都不少。而且,函數(shù)類的屬性一般都要寫到原型里,不然要原型干嘛。像現(xiàn)在這種寫法的話,相當(dāng)于每個Cat對象里都會存放一份eat函數(shù)和catchMouse函數(shù),而每個Dod對象里都會放一份eat函數(shù),這根本沒有顯示出繼承的特點。
2-2.加上原型后的效果
接下來把共通的部分抽取出來放到原型里,并用原型鏈鏈接起來。
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
Cat.prototype.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
Cat.prototype = new Animal();
function Cat(name, food) {
Animal.call(this, name, food);
}
Dog.prototype = new Animal();
function Dog(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
var Odie = new Dog("Odie", "bone");
Garfield.eat();
Garfield.catchMouse();
Odie.eat();
這個時候就開始遇到一個很嚴(yán)重的問題。
假如沒有加菲貓,只有歐弟,即子函數(shù)的原型上沒有自己獨有的方法。
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
Dog.prototype = new Animal();
function Dog(name, food) {
Animal.call(this, name, food);
}
var Odie = new Dog("Odie", "bone");
Odie.eat();
這就沒問題,因為Dog的原型是Animal對象,即Odie里面有一個隱式屬性proto指向一個Animal對象(它沒有名字),而Animal的原型是一個包含eat函數(shù)的對象,所以Animal對象里面有一個隱式屬性proto指向那個包含eat函數(shù)的對象。這樣的話,Odie調(diào)用eat方法的話,自己沒有,通過自己的proto找到那個Animal對象,結(jié)果它也沒有,再通過Animal對象的proto找到那個包含eat函數(shù)的對象,執(zhí)行其中的eat方法。
然而換種想法,其實這種情況下還是有一點值得思考的,就是為什么不直接寫Dog.prototype = Animal.prototype呢。可以直接這么寫,而且直接寫其實比中間通過一個Animal對象要好。這么寫的話,Odie的proto直接指向包含eat方法的對象,正好就是自己想要的效果。剛才那種寫法反而每次都創(chuàng)建出一個多余的Animal對象,里面有兩個屬于自己的屬性(沒有通過call改變this指向,通過new創(chuàng)建的時候可以寫參數(shù)也可以不寫參數(shù),一般不寫,沒必要),這兩個參數(shù)的值最終都是undefined。實際上這個對象唯一的作用就是里面有一個proto指向自己的構(gòu)造函數(shù)的原型。
Dog.prototype = Animal.prototype其實有一個專業(yè)的叫法——共享原型。但是它不是總能奏效,比如加菲貓這頭,它還需要有一個專屬于貓科動物的特性——抓老鼠。
Cat既要有自己的一個原型(是一個對象),里面包含一個catchMouse方法,以便Garfield的proto指向這個原型,同時又要讓加菲貓的原型直接或間接地指向Animal的原型。以共享原型的方式直接指向的話是沒戲的,因為Cat.prototype=Animal.prototype的話就沒有中間的對象了,而我恰好需要一個中間的對象在這兒。以開始的那種先執(zhí)行new Animal()對象再間接指向最終目標(biāo)的話,會出現(xiàn)以下3中結(jié)果。
1)先寫Cat.prototype = new Animal();再寫Cat.prototype={catchMouse:function() {...}}的話,前者會被后者覆蓋掉,導(dǎo)致最終沒法指向Animal的eat方法。
2)先寫Cat.prototype = new Animal();再寫Cat.prototype.catchMouse=function() {...}的話,每個創(chuàng)建出來的Cat對象里仍然會有一份無用的Cat對象的屬性值這一點并沒有變。而且明明是Cat的方法,卻寫到了Animal對象里,語義上好像不好。
3)先寫Cat.prototype.catchMouse=function() {...}再寫Cat.prototype = new Animal();的話,前者會被后者覆蓋,導(dǎo)致實際上并沒有添加屬于自己的特有方法。
這個時候靜下心來想一想,自己到底想要達(dá)到什么效果?其實就是Cat函數(shù)的原型對象確實存在,它里面有一個Cat函數(shù)才有的catchMouse方法,這個原型對象里應(yīng)該有一個proto直接指向Animal的原型。但是就是辦不到。于是我手動地在創(chuàng)建Cat的原型對象后,給他顯式地添加一個proto屬性,讓它指向Animal的原型,效果如下:
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
Cat.prototype.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
Cat.prototype.__proto__ = Animal.prototype;
function Cat(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();
試了一下,結(jié)果居然是可以的。然而,IE瀏覽器下并不好使,其它瀏覽器倒是好使。
其實這個時候,退而求其次,既然看起來沒法同時做到這兩點,把Cat函數(shù)的catchMouse屬性不用原型實現(xiàn),而是老老實實地寫到函數(shù)內(nèi)部也還好。代碼重復(fù)就重復(fù)好了,不是徹底的繼承就不徹底好了,至少從功能上來講也實現(xiàn)了要求。
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
Cat.prototype = Animal.prototype;
function Cat(name, food) {
Animal.call(this, name, food);
this.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
}
var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();
退而求其次也行,但是這看起來是不可調(diào)和的矛盾,真的沒有辦法了么?答案是有的。
2-3.最后改進(jìn)后的結(jié)果
重新捋一下需求和思路。寫到這種地步,Animal的非函數(shù)屬性(name和food)采用了一種非本質(zhì)上繼承,但是也還好的方式實現(xiàn)了,現(xiàn)在主要是想鏈接到Animal的原型上,獲取到eat方法。Cat函數(shù)要有自己的方法,所以屬于自己的原型對象必須存在。即Cat.prototype.catchMouse=function(){...}是一定有的,這個對象必須實實在在地存在。然后這個對象需要有一個proto鏈接到Animal的原型,強(qiáng)制加proto是不現(xiàn)實的。其實仔細(xì)想行,為什么讓這個原型對象里有一個proto直接指向Animal的原型那么難(其實是不可能的),這又回到開頭的問題上,顯式聲明prototype和proto是怎么回事兒,以及什么關(guān)系上。
使用prototype關(guān)鍵字把一個對象聲明為一個函數(shù)的原型,只是意味著通過該函數(shù)創(chuàng)建對象時,該對象內(nèi)部會有一個隱式屬性proto指向這個原型。
所以Animal的原型只會在new Animal()的對象里才會被proto引用。而Cat的原型,本身已經(jīng)是一個對象(里面有一個catchMouse函數(shù)屬性)了,它就不可能是Animal的對象。
既然我沒法直接指向你,而我所需要的又只是你(Animal的原型),而不是Animal對象(指向Animal對象會有多余的屬性)。既然無論如何都只能是間接地指向你,現(xiàn)有的那個我又不喜歡,那我干脆創(chuàng)建一個新的空對象作為間接內(nèi)容指向你好了。于是可以創(chuàng)建一個新的函數(shù)(內(nèi)容為空),讓這個函數(shù)的原型也是你,這個函數(shù)構(gòu)造出的對象里其它什么都沒有,只有一個proto指向你,然后我自己加我特有的內(nèi)容時加到這個新的對象上了。雖然仍然有語義上不好的感覺,至少沒有多余的Animal對象的屬性了。
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
function F() {}
F.prototype = Animal.prototype;
Cat.prototype = new F();
Cat.prototype.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
function Cat(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();
其實這一塊代碼還可以設(shè)計成一個單獨的函數(shù):
function extend(son, father) {
function F() {};
F.prototype = father.prototype;
son.prototype = new F();
}
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
extend(Cat, Animal);
Cat.prototype.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
function Cat(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();
這樣的寫法已經(jīng)很好地完成了任務(wù)了。再錦上添花一下的話,需要思考一下constructor的事兒。從這里可以看出來,constructor對應(yīng)JS的原型以及原型鏈來講作用遠(yuǎn)不及prototype和proto重要。但是為了更符合JS原型和原型鏈的體系,有必要再加點東西。
上面的代碼中,通過Garfield訪問constructor屬性會打印出Animal函數(shù)。原因是Cat的prototype是新建的空對象,而那個空對象自己也沒有constructor,它的proto才有,它的proto是Animal的prototype,結(jié)果可想而知。所以為了,體現(xiàn)出Garfield是在Cat函數(shù)中創(chuàng)建出來地這一點,有必要加上以下代碼:
son.prototype.constructor = son;
結(jié)果代碼如下:
function extend(son, father) {
function F() {};
F.prototype = father.prototype;
son.prototype = new F();
son.prototype.constructor = son;
}
Animal.prototype.eat = function () {
console.log(this.name + " is eatting " + this.food);
}
function Animal(name, food) {
this.name = name;
this.food = food;
}
extend(Cat, Animal);
Cat.prototype.catchMouse = function () {
console.log("Hey, John! I got a big mouse!")
}
function Cat(name, food) {
Animal.call(this, name, food);
}
var Garfield = new Cat("Garfield", 'lasagne');
Garfield.eat();
Garfield.catchMouse();