首頁  >  文章  >  web前端  >  JavaScript的原型繼承詳解_javascript技巧

JavaScript的原型繼承詳解_javascript技巧

WBOY
WBOY原創
2016-05-16 16:14:051086瀏覽

JavaScript是一門物件導向的語言。在JavaScript中有一句很經典的話,萬物皆物件。既然是物件導向的,那就有物件導向的三大特徵:封裝、繼承、多型。這裡講的是JavaScript的繼承,其他兩個容後再說。

JavaScript的繼承和C 的繼承不大一樣,C 的繼承是基於類別的,而JavaScript的繼承是基於原型的。

現在問題來了。

原型是什麼?原型我們可以參考C 裡的類,同樣的保存了物件的屬性和方法。例如我們寫一個簡單的物件

複製程式碼 程式碼如下:

function Animal(name) {
    this.name = name;
}
Animal.prototype.setName = function(name) {
    this.name = name;
}
var animal = new Animal("wangwang");

我們可以看到,這就是一個物件Animal,該物件有個屬性name,有個方法setName。要注意,一旦修改prototype,例如增加某個方法,則該物件所有實例將同享這個方法。例如

複製程式碼 程式碼如下:

function Animal(name) {
    this.name = name;
}
var animal = new Animal("wangwang");

這時animal只有name屬性。如果我們加上一句,

複製程式碼 程式碼如下:

Animal.prototype.setName = function(name) {
    this.name = name;
}

這時animal也會有setName方法。

繼承本複製-從空的物件開始我們知道,JS的基本型別中,有一種叫做object,而它最基本的實例就是空的對象,也就是直接呼叫new Object()產生的實例,或者是用字面量{ }來宣告。空的對像是“乾淨的對象”,只有預先定義的屬性和方法,而其他所有對像都是繼承自空對象,因此所有的對像都擁有這些預定義的 屬性與方法。原型其實也是一個物件實例。原型的意義是指:如果建構器有一個原型物件A,則由該建構器所建立的實例都必然複製自A。由於實例複製自物件A,因此實例必然繼承了A的所有屬性、方法和其他性質。那麼,複製又是怎麼實現的呢?方法一:建構複製每構造一個實例,都從原型複製出一個實例來,新的實例與原型佔用了相同的記憶體空間。這雖然使得obj1、obj2與它們的原型“完全一致”,但也非常不經濟——內存空間的消耗會急速增加。如圖:


方法二:寫時複製這種策略來自於一致欺騙系統的技術:寫時複製。這種欺騙的典型範例就是作業系統中的動態連結庫(DDL),它的記憶體區總是寫時複製的。如圖:


我們只要在系統中指明obj1和obj2等同於它們的原型,這樣在讀取的時候,只需要順著指示去讀原型即可。當需要寫入物件(例如obj2)的屬性時,我們就會複製一個原型的映像出來,並使以後的操作指向該映像即可。如圖:


這種方式的優點是我們在創建實例和讀取屬性的時候不需要大量內存開銷,只在第一次寫的時候會用一些代碼來分配內存,並帶來一些代碼和內存上的開銷。但此後就不再有這種開銷了,因為存取映像和存取原型的效率是一致的。不過,對於經常進行寫入操作的系統來說,這種方法並不比上一種方法更經濟。方法三:讀遍歷這種方法把複製的粒度從原型變成了成員。這種方法的特點是:僅當寫某個實例的成員,將成員的資訊複製到實例映像中。當寫入物件屬性時,例如(obj2.value=10)時,會產生一個名為value的屬性值,放在obj2物件的成員清單中。看圖:

可以發現,obj2仍然是一個指向原型的引用,在操作過程中也沒有與原型相同大小的物件實例創建出來。這樣,寫入操作並不導致大量的記憶體分配,因此記憶體的使用就顯得經濟了。不同的是,obj2(以及所有的物件實例)需要維護一張成員清單。這個成員清單遵循兩條規則:保證在讀取時首先被存取到如果在物件中沒有指定屬性,則嘗試遍歷物件的整個原型鏈,直到原型為空或或找到該屬性。原型鏈後面會講。顯然,三種方法中,讀遍歷是效能最優的。所以,JavaScript的原型繼承是讀遍歷的。 constructor熟悉C 的人看完最上面的物件的程式碼,一定會懷疑。沒有class關鍵字還好理解,畢竟有function關鍵字,關鍵字不一樣而已。但是,構造函數呢?實際上,JavaScript也是有類似的建構函數的,只不過叫做構造器。在使用new運算子的時候,其實已經呼叫了建構器,並將this綁定為物件。例如,我們用以下的程式碼

複製程式碼 程式碼如下:

var animal = Animal("wangwang");

animal將是undefined。有人會說,沒有回傳值當然是undefined。那如果將Animal的物件定義改一下:

複製程式碼 程式碼如下:

function Animal(name) {
    this.name = name;
    return this;
}

猜猜現在animal是什麼?
此時的animal變成window了,不同之處在於擴展了window,使得window有了name屬性。這是因為this在沒有指定的情況下,預設指向window,也也就是最頂層變數。只有呼叫new關鍵字,才能正確呼叫建構器。那麼,該如何避免用的人漏掉new關鍵字呢?我們可以做點小修改:

複製程式碼 程式碼如下:

function Animal(name) {
    if(!(this instanceof Animal)) {
        return new Animal(name);
    }
    this.name = name;
}

這樣就萬無一失了。建構器還有一個用處,標示實例是屬於哪個物件的。我們可以用instanceof來判斷,但instanceof在繼承的時候對祖先物件跟真正物件都會回傳true,所以不太適合。 constructor在new呼叫時,預設指向目前物件。

複製程式碼 程式碼如下:

console.log(Animal.prototype.constructor === Animal); // true

我們可以換種思維:prototype在函數初始時根本是無值的,實作上可能是下面的邏輯

// 設定__proto__是函數內建的成員,get_prototyoe()是它的方法

複製程式碼 程式碼如下:

var __proto__ = null;
function get_prototype() {
    if(!__proto__) {
        __proto__ = new Object();
        __proto__.constructor = this;
    }
    return __proto__;
}

這樣的好處是避免了每聲明一個函數都建立一個物件實例,節省了開銷。 constructor是可以修改的,後面會講到。基於原型的繼承繼承是什麼相信大家都差不多知道,就不秀智商下限了。

JS的繼承有好幾種,這裡講兩種

1. 方法一這種方法最常用,安全性也比較好。我們先定義兩個物件

複製程式碼 程式碼如下:

function Animal(name) {
    this.name = name;
}
function Dog(age) {
    this.age = age;
}
var dog = new Dog(2);

要建構繼承很簡單,將子物件的原型指向父物件的實例(注意是實例,不是物件)

複製程式碼 程式碼如下:

Dog.prototype = new Animal("wangwang");

這時,dog就將有兩個屬性,name和age。而如果對dog使用instanceof操作符

複製程式碼 程式碼如下:

console.log(dog instanceof Animal); // true
console.log(dog instanceof Dog); // false

這樣就實現了繼承,但是有個小問題

複製程式碼 程式碼如下:

console.log(Dog.prototype.constructor === Animal); // true
console.log(Dog.prototype.constructor === Dog); // false

可以看到建構器指向的物件改變了,這樣就不符合我們的目的了,我們無法判斷我們new出來的實例屬於誰。因此,我們可以加一句話:

複製程式碼 程式碼如下:

Dog.prototype.constructor = Dog;

再來看一下:

複製程式碼 程式碼如下:

console.log(dog instanceof Animal); // false
console.log(dog instanceof Dog); // true

done。這種方法是屬於原型鏈的維護中的一環,下文將詳細闡述。 2. 方法二這種方法有它的好處,也有它的弊端,但弊大於利。先看程式碼

複製程式碼 程式碼如下:

function Animal(name) {<br>
    this.name = name;<br>
}<br>
Animal.prototype.setName = function(name) {<br>
    this.name = name;<br>
}<br>
function Dog(age) {<br>
    this.age = age;<br>
}<br>
Dog.prototype = Animal.prototype;<br>

這樣就實作了prototype的拷貝。

這種方法的好處就是不需要實例化物件(和方法一相比),節省了資源。弊端也是明顯,除了和上文一樣的問題,即constructor指向了父對象,還只能複製父對像用prototype宣告的屬性和方法。也即是說,在上述程式碼中,Animal物件的name屬性得不到複製,但能複製setName方法。最致命的是,子物件的prototype的任何修改,都會影響父物件的prototype,也就是兩個物件宣告出來的實例都會受到影響。所以,不推薦這種方法。

原型鏈

寫過繼承的人都知道,繼承可以多層繼承。而在JS中,這種就構成了原型鏈。上文也多次提到了原型鏈,那麼,原型鍊是什麼?一個實例,至少應該擁有指向原型的proto屬性,這是JavaScript中的物件系統的基礎。不過這個屬性是不可見的,我們稱之為“內部原型鏈”,以便和構造器的prototype所組成的“構造器原型鏈”(也即我們通常所說的“原型鏈”)區分開。我們先以上述程式碼建構一個簡單的繼承關係:

複製程式碼 程式碼如下:

function Animal(name) {
    this.name = name;
}
function Dog(age) {
    this.age = age;
}
var animal = new Animal("wangwang");
Dog.prototype = animal;
var dog = new Dog(2);

提醒一下,前文說過,所有物件都是繼承空的物件的。所以,我們就建構了一個原型鏈:


我們可以看到,子物件的prototype指向父物件的實例,構成了建構器原型鏈。子實例的內部proto物件也是指向父物件的實例,構成了內部原型鏈。當我們需要尋找某個屬性的時候,程式碼類似

複製程式碼 程式碼如下:

function getAttrFromObj(attr, obj) {
    if(typeof(obj) === "object") {
        var proto = obj;
        while(proto) {
            if(proto.hasOwnProperty(attr)) {
                return proto[attr];
            }
            proto = proto.__proto__;
        }
    }
    return undefined;
}

在這個例子中,我們如果在dog中查找name屬性,它將在dog中的成員列表中尋找,當然,會找不到,因為現在dog的成員列表只有age這一項。接著它會順著原型鏈,也就是.proto指向的實例繼續尋找,即animal中,找到了name屬性,並將之傳回。假如尋找的是一個不存在的屬性,在animal中尋找不到時,它會繼續順著.proto尋找,找到了空的對象,找不到之後繼續順著.proto尋找,而空的對象的. proto指向null,尋找退出。

原型鏈的維護我們在剛才講原型繼承的時候提出了一個問題,使用方法一構造繼承時,子物件實例的constructor指向的是父物件。這樣的好處是我們可以透過constructor屬性來存取原型鏈,壞處也是顯而易見的。一個對象,它所產生的實例應該指向它本身,也即是

複製程式碼 程式碼如下:

(new obj()).prototype.constructor === obj;

然後,當我們重寫了原型屬性之後,子物件產生的實例的constructor不是指向本身!這樣就和構造器的初衷背道而馳了。我們在上面提到了一個解決方案:

複製程式碼 程式碼如下:

Dog.prototype = new Animal("wangwang");
Dog.prototype.constructor = Dog;

看起來沒有什麼問題了。但實際上,這又帶來了一個新的問題,因為我們會發現,我們沒辦法回溯原型鏈了,因為我們沒法尋找到父對象,而內部原型鏈的.proto屬性是無法訪問的。於是,SpiderMonkey提供了一個改良方案:在任何已建立的物件上新增了一個名為__proto__的屬性,該屬性總是指向建構器所使用的原型。這樣,對任何constructor的修改,都不會影響__proto__的值,就方便維護constructor了。

但是,這樣又兩個問題:

__proto__是可以重寫的,這意味著使用它時仍然有風險

__proto__是spiderMonkey的特殊處理,在別的引擎(例如JScript)中是無法使用的。

我們還有一個辦法,那就是保持原型的建構器屬性,而在子類別建構子函式內初始化實例的建構器屬性。

程式碼如下:改寫子物件

複製程式碼 程式碼如下:

function Dog(age) {
    this.constructor = arguments.callee;
    this.age = age;
}
Dog.prototype = new Animal("wangwang");

這樣,所有子對象的實例的constructor都正確的指向該對象,而原型的constructor則指向父對象。雖然這種方法的效率比較低,因為每次構造實例都要重寫constructor屬性,但毫無疑問地這種方法能有效解決之前的矛盾。 ES5考慮到了這種情況,徹底的解決了這個問題:可以在任意時候使用Object.getPrototypeOf() 來獲得一個物件的真實原型,而無須存取構造器或維護外部的原型鏈。因此,像上一節所說的尋找物件屬性,我們可以如下改寫:

複製程式碼 程式碼如下:

function getAttrFromObj(attr, obj) {
    if(typeof(obj) === "object") {
        do {
            var proto = Object.getPrototypeOf(dog);
            if(proto[attr]) {
                return proto[attr];
            }
        }
        while(proto);
    }
    return undefined;
}

當然,這種方法只能在支援ES5的瀏覽器中使用。為了向後相容,我們還是需要考慮上一種方法的。更適合的方法是將這兩種方法整合封裝起來,這個相信讀者們都非常擅長,這裡就不獻醜了。

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn