淺談Javascript中的原型繼承

2018-06-09 16:18 更新

英文原文: Javascript: An Exploration of Prototypal Inheritance

在Javascript面向?qū)ο缶幊讨?,原型繼承不僅是一個重點也是一個不容易掌握的點。在本文中,我們將對Javascript中的原型繼承進(jìn)行一些探索。

基本形式

我們先來看下面一段代碼,


//構(gòu)造器函數(shù)
function Shape(){
    this.x = 0;
    this.y = 0;
}
//一個shape實例
var s = new Shape();

雖然這個例子非常簡單,但是有四個非常重要的點需要在此闡明:

  1. s是一個對象,并且默認(rèn)的它擁有訪問Shape.prototype(即每個由Shape構(gòu)造函數(shù)創(chuàng)建的對象擁有的原型)的權(quán)限;簡單來說,Shape.prototype就是一個監(jiān)視著所有Shape實例的對象。你可以將一個對象的原型想象成一個由許多屬性(變量/函數(shù))組成的后備集合,當(dāng)原型在它自己身上找不到東西時就會去原型中查找。
  2. 原型可以在所有的Shape實例中共享。例如,所有的原型都擁有(直接)訪問原型的權(quán)限。
  3. 當(dāng)你調(diào)用實例中的一個函數(shù)時,這個實例會在它自己身上查找這個函數(shù)的定義。如果找不到,那么原型將會查找這個函數(shù)的定義。
  4. 無論被調(diào)用的函數(shù)的定義在哪里找到(在實例本身中或者它的原型中),this的值都是指向用來調(diào)用函數(shù)的這個實例。因此如果我們調(diào)用了一個s中的函數(shù),如果這個函數(shù)并沒有在s中直接定義而是在s的原型中,this值依然指向s。

現(xiàn)在我們將上面強調(diào)的幾點運用到一個例子中。假設(shè)我們將一個函數(shù)getPosition()綁定到s上。我們可能會這樣做:


s.getPosition = function() {
    return [this.x, this.y];
};

這樣做沒有什么錯誤。你可以直接調(diào)用s.getPosition()然后你將獲得返回的數(shù)組。

但是如果我們創(chuàng)建了另一個Shape的實例s2怎么辦呢,它依然能夠調(diào)用getPosition()函數(shù)嗎?

答案顯然是不能。

getPosition函數(shù)直接在實例s中被創(chuàng)建。因此,這個函數(shù)并不會存在與s2中。

當(dāng)你調(diào)用s2.getPosition()時,下面的步驟會依次發(fā)生(注意第三步非常重要):

  1. 實例s2會檢查getPosition的定義;
  2. 這個函數(shù)不存在于s2中;
  3. s2的原型(和s一起共享的后備集合)檢查getPosition的定義;
  4. 這個函數(shù)不存在與原型中;
  5. 這個函數(shù)的定義沒有被找到;

一個簡單(但并不是最優(yōu))的解決方案是將getPosition在實例s2(以及后面每一個需要getPosition的實例)中再定義一次。這是一個很不好的做法因為你在做無意義的復(fù)制代碼的工作,而且在每個實例中定義一個函數(shù)會消耗更多的內(nèi)存(如果你關(guān)心這點的話)。

我們有更好的辦法。

在原型中定義屬性

我們完全可以達(dá)到所有實例共享getPosition函數(shù)的目的,不是在每個實例中都定義getPosition,而是定義在構(gòu)造器函數(shù)的原型中。我們來看下面的代碼:


//構(gòu)造器函數(shù)
function Shape(){
    this.x = 0;
    this.y = 0 ;
}
Shape.prototype.getPosition = function(){
    return [this.x, this.y];
};
var s = new Shape(),
    s2 = new Shape();

由于原型在所有Shape的實例中共享,s和s2都能夠訪問到getPosition函數(shù)。

調(diào)用s2.getPosition()函數(shù)會經(jīng)歷下面的步驟:

  1. 實例s2檢查getPosition的定義;
  2. 函數(shù)不存在與s2中;
  3. 檢查原型;
  4. getPosition的定義存在于原型中;
  5. getPosition會連同指向s2this一起執(zhí)行;

綁定到原型的屬性非常適合于重用。你可以在所有的實例中重用同樣的函數(shù)。

原型中的陷阱

當(dāng)你把對象或者數(shù)組綁定到原型中的時候要非常小心。所有的實例將會共享這些被綁定的對象/數(shù)組的引用。如果一個實例操縱了對象或數(shù)組,那么所有的實例都會受到影響。


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.types = ['round', 'flat'];
s = new Shape();
s2 = new Shape();
s.types.push('bumpy');
console.log(s.types); // ['round', 'flat', 'bumpy']
console.log(s2.types); // ['round', 'flat', 'bumpy']

當(dāng)s.types.push(’bumpy’)這行代碼運行時,實例s將會檢查一個叫做types的數(shù)組。它不存在與實例s中,于是原型檢查這個數(shù)組。這個數(shù)組,types,存在于原型中,因此我們?yōu)樗砑右粋€元素bumpy

結(jié)果,由于s2也共享原型,它也能通過非直接的方式發(fā)現(xiàn)types數(shù)組發(fā)生了變化。

一個真實的例子就是當(dāng)你使用Backbone.js時也會發(fā)生類似的事情。當(dāng)你定義了一個視圖/模型/集合,Backbone會把你通過extend函數(shù)(例如:Backbone.View.extend({}))傳遞的屬性添加到你定義的實體的原型中。

這意味著如果你在定義實體時添加了一個對象或者數(shù)組,所有的實例將會共享這些對象或者數(shù)組,很有可能你的一個實例會毀掉另外一個實例。為了避免這樣的情況,你經(jīng)常會看到人們將這些對象/數(shù)組包含在一個函數(shù)中,每次返回一個對象/數(shù)組的實例。

注意:Backbonemodel defaults的部分中談到了這一點:

記住在JavaScript中,對象是以引用的方式被傳遞的,因此如果你包含了一個對象作為默認(rèn)值,它將在所有實例中被共享。因此,我們將defaults定義為一個函數(shù)。

另一種類型的Shape

假設(shè)現(xiàn)在我們想要創(chuàng)建一種特定類型的Shape,比如說一個圓。如果它能繼承Shape的所有功能并且還能在它的原型中定義自定義函數(shù)那該多好:


function Shape() {
    this.x = 0;
    this.y = 0;
}
function Circle() {
    this.radius = 0;
}

那么我們怎么形容一個circle是一個shape呢?有以下幾種方法:

借用構(gòu)造函數(shù)并且賦值給原型

當(dāng)我們創(chuàng)建一個圓時,我們想要讓實例擁有一個半徑(來源于Circle構(gòu)造函數(shù)),以及一個x位置,一個y位置(來源于Shape構(gòu)造函數(shù))。

我們我們僅僅聲明c = new Circle(),那么c僅僅只有半徑。Shape構(gòu)造函數(shù)對xy進(jìn)行了初始化。我們想要這個功能。因此我們來借用這個功能。


function Circle() {
    this.radius = 0;
    Shape.call(this);
}

最后一行代碼Shape.call(this)調(diào)用了Shape構(gòu)造函數(shù)并改變了當(dāng)Circle構(gòu)造函數(shù)被調(diào)用時指向this的值。

好吧,這到底是在說些什么呢?

現(xiàn)在我們來使用上面的構(gòu)造函數(shù)創(chuàng)建一個新的圓然后看看發(fā)生了什么:


var c = new Circle();

這行代碼調(diào)用了Circle構(gòu)造函數(shù),它首先在c上綁定了一個變量radius。記住,此時的this指向的是c。我們接著調(diào)用Shape構(gòu)造函數(shù),然后將Shape中的this值指向當(dāng)前在Circle中的this值,也就是cShape構(gòu)造函數(shù)將xy綁定到了當(dāng)前的this上,也就是說,c現(xiàn)在擁有值為0的xy屬性。

另外,你在這個例子中放置Shape.call(this)的位置并不重要。如果你想在初始化之后重載x和y(也就是將圓心放在一個另外的地方),你可以在調(diào)用Shape函數(shù)之后完成這件事。

問題是現(xiàn)在我們實例化的圓雖然擁有了變量xyradius,但是它并不能從Shape的原型中獲取任何東西。我們需要設(shè)置Circle構(gòu)造函數(shù)來將Shape的原型重置為它的原型 – 以便所有的圓都能獲取作為shape的福利。

一種方式是我們將Circle.prototype的值設(shè)置為Shape.prototype:


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
    Shape.call(this);
}
Circle.prototype = Shape.prototype; // 注意看這里
var s = new Shape(),
    c = new Circle();

這樣做運行的很好,但是它并不是最優(yōu)選擇。實例c現(xiàn)在擁有訪問getPosition函數(shù)的權(quán)限,因為Circle構(gòu)造器函數(shù)和Shape構(gòu)造器函數(shù)共享了它的原型。

要是我們還想給所有的Cirecle定義一個getArea函數(shù)怎么辦?我們將把這個函數(shù)綁定到Circle構(gòu)造器函數(shù)的原型中以便它可以為所有圓的實例所用。

編寫下面的代碼:


function Shape() {
  this.x = 0;
  this.y = 0;
}
Shape.prototype.getPosition = function () {
  return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
    Shape.call(this);
}
Circle.prototype = Shape.prototype;
Circle.prototype.getArea = function () {
    return Math.PI * this.radius * this.radius;
};
var s = new Shape(),
    c = new Circle();

現(xiàn)在的情況是CircleShape共享同一個原型,我們在Circle.prototype中添加了一個函數(shù)其實也就相當(dāng)于在Shape.prototype中添加了一個函數(shù)。

怎么會這個樣子!!

一個Shape的實例并沒有radius變量,只有Circle實例擁有radius變量。但是現(xiàn)在,所有的Shape實例都可以訪問getArea函數(shù) – 這將導(dǎo)致一個錯誤,但是當(dāng)所有圓調(diào)用這個函數(shù)時則一切正常。

將所有的原型設(shè)置為同一個對象并不能滿足我們的需求。

Circle原型是一個Shape的實例


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
}
Circle.prototype = new Shape();
var c = new Circle();

這個方法非常的酷。我們并沒有借用構(gòu)造器函數(shù)但是Circle擁有了xy,同時也擁有了getPosition函數(shù)。它是怎么實現(xiàn)的呢?

Circle.prototype現(xiàn)在是一個Shape的實例。這意味著c有一個直接的變量radius(由Circle構(gòu)造器函數(shù)提供)。然而,在c的原型中,有一個xy。現(xiàn)在注意,有趣的東西要來了:在c的原型的原型中,有一個getPosition函數(shù)的定義。看起來其實是這樣的:

因此,如果你試圖獲取c.x,那么它將在c的原型中被找到。

這種方法的缺點是如果你想要重載xy,那么你必須在Circle構(gòu)造器或者Circle原型中做這件事。


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
}
Circle.prototype = new Shape();
Circle.prototype.x = 5;
Circle.prototype.y = 10;
var c = new Circle();
console.log(c.getPosition()); // [5, 10]

調(diào)用c.getPosition將會經(jīng)歷下列步驟:

  1. 該函數(shù)在c中沒有被找到;
  2. 該函數(shù)在c的原型(Shape的實例)中沒有被找到;
  3. 該函數(shù)在Shape實例的原型(c的原型的原型)中被找到;
  4. 該函數(shù)連同指向cthis一起被調(diào)用;
  5. getPosition函數(shù)的定義中,我們在this中尋找x;
  6. x沒有直接在c中被找到;
  7. 我們在c的原型(Shape實例)中查找x;
  8. 我們在c的原型中找到x
  9. 我們在c的原型中找到y;

除了有一層一層的原型鏈帶來的頭痛之外,這個方法還是很不錯的。

這個方法還可以使用Object.create()來替代。

借用構(gòu)造函數(shù)并使用Object.create


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
    Shape.call(this);
    this.x = 5;
    this.y = 10;
}
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.constructor = Circle; // 這一步是將Circle的原型的構(gòu)造函數(shù)重置為Circle
var c = new Circle();
console.log(c.getPosition()); // [5, 10]

這個方法的一大好處就是xy直接被綁定到了c上 – 這將使查詢速度大大提高(如果你的程序關(guān)心這件事情)因為你再也不需要向上查詢原型鏈了。

我們來看一看Object.create的替代方法(polyfill):


Object.create = (function() {
    // 中間構(gòu)造函數(shù)
    function F() {}
    return function(o) {
        ...
        // 將中間構(gòu)造函數(shù)的原型設(shè)置為我們給它的對象o
        F.prototype = o;
        // 返回一個中間構(gòu)造函數(shù)的實例;
        // 它是一個空對象但是原型是我們給它的對象o
        return new F();
    };
})();

上說過程基本上是完成了Circle.prototype = new Shape();只是現(xiàn)在Circle.prototype是一個空對象(一個中間構(gòu)造函數(shù)F的實例),而它的原型是Shape.prototype。

你應(yīng)該使用哪個方法

非常重要的一點是,如果你在Shape構(gòu)造函數(shù)上綁定有對象/數(shù)組,那么所有的圓都可以修改這些共享的對象/數(shù)組。如果將Circle.prototype設(shè)置為一個Shape的實例時這個方法會有很大的缺陷。


function Shape() {
    this.x = 0;
    this.y = 0;
    this.types = ['flat', 'round'];
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
}
Circle.prototype = new Shape();
var c = new Circle(),
    c2 = new Circle();
c.types.push('bumpy');
console.log(c.types);  // ["flat", "round", "bumpy"]
console.log(c2.types); // ["flat", "round", "bumpy"]

為了避免這種情況的發(fā)生,你可以借用Shape的構(gòu)造函數(shù)并且使用Object.create以便每一個圓都能擁有它自己的types數(shù)組。


...
function Circle() {
    this.radius = 0;
    Shape.call(this);
}
Circle.prototype = Object.create(Shape.prototype);
var c = new Circle(),
    c2 = new Circle();
c.types.push('bumpy');
console.log(c.types);  // ["flat", "round", "bumpy"]
console.log(c2.types); // ["flat", "round"]

一個更高級的例子

我們現(xiàn)在在前面討論的基礎(chǔ)上更進(jìn)一步,創(chuàng)建一個新的Circle的類型,Sphere。一個橢圓,和圓差不多,只是在計算面積時有不同的公式。


function Shape() {
    this.x = 0;
    this.y = 0;
}
Shape.prototype.getPosition = function () {
    return [this.x, this.y];
};
function Circle() {
    this.radius = 0;
    Shape.call(this);
    this.x = 5;
    this.y = 10;
}
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.getArea = function () {
    return Math.PI * this.radius * this.radius;
};
function Sphere() {
}
// TODO: 在這里設(shè)置原型鏈
Sphere.prototype.getArea = function () {
    return 4 * Math.PI * this.radius * this.radius;
};
var sp = new Sphere();

我們應(yīng)該使用哪種方法來設(shè)置原型鏈?記住,我們并不想要毀掉我們關(guān)于圓的getArea的定義。我們只是想在橢圓中有另一種方式的實現(xiàn)。

我們不能夠借用構(gòu)造函數(shù)并為原型賦值,因為這樣做將會改變所有圓的getArea的定義。然而,我們可以使用Object.create或者將Sphere的原型設(shè)置為一個Circle的實例。我們來看看應(yīng)該怎么做:


...
function Circle() {
    this.radius = 0;
    Shape.call(this);
    this.x = 5;
    this.y = 10;
}
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.getArea = function () {
    return Math.PI * this.radius * this.radius;
};
function Sphere() {
    Circle.call(this);
}
Sphere.prototype = Object.create(Circle.prototype);
Sphere.prototype.getArea = function () {
    return 4 * Math.PI * this.radius * this.radius;
};
var sp = new Sphere();

調(diào)用sp.getArea()將會經(jīng)歷一下步驟:

  1. sp中查找getArea的定義;
  2. sp中沒有找到相關(guān)定義;
  3. Sphere的原型(一個中間對象,它的原型是Circle.prototype)中查找;
  4. 在這個中間對象中找到關(guān)于getArea的定義,由于我們在Sphere的原型中重新定義了getArea,這里采用新的定義;
  5. 連同指向spthis調(diào)用getArea方法;

我們注意到Circle.prototype也有一個getArea的定義。然而,由于Sphere.prototype已經(jīng)有了一個getArea的定義,我們永遠(yuǎn)不會使用到Circle.prototype中的的getArea – 這樣我們就成功的重載了這個函數(shù)(重載意味著在查詢鏈的前面定義了一個名字相同的函數(shù))。

End. All rights reserved @gejiawen.


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號