DIV CSS 佈局教程網

 DIV+CSS佈局教程網 >> 網頁腳本 >> JavaScript入門知識 >> 關於JavaScript >> JavaScript的原型繼承詳解
JavaScript的原型繼承詳解
編輯:關於JavaScript     

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. 方法二這種方法有它的好處,也有它的弊端,但弊大於利。先看代碼

復制代碼 代碼如下:
<pre name="code" class="javascript">function Animal(name) {
    this.name = name;
}
Animal.prototype.setName = function(name) {
    this.name = name;
}
function Dog(age) {
    this.age = age;
}
Dog.prototype = Animal.prototype;

這樣就實現了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的浏覽器中使用。為了向後兼容,我們還是需要考慮上一種方法的。更合適的方法是將這兩種方法整合封裝起來,這個相信讀者們都非常擅長,這裡就不獻丑了。

XML學習教程| jQuery入門知識| AJAX入門| Dreamweaver教程| Fireworks入門知識| SEO技巧| SEO優化集錦|
Copyright © DIV+CSS佈局教程網 All Rights Reserved