JavaScript的原型繼承詳解_javascript技巧

來源:互聯網
上載者:User

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()產生的執行個體,或者是用字面量{ }來聲明。空的對象是“乾淨的對象”,只有預定義的屬性和方法,而其他所有對象都是繼承自Null 物件,因此所有的對象都擁有這些預定義的 屬性與方法。原型其實也是一個對象執行個體。原型的含義是指:如果構造器有一個原型對象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的瀏覽器中使用。為了向後相容,我們還是需要考慮上一種方法的。更合適的方法是將這兩種方法整合封裝起來,這個相信讀者們都非常擅長,這裡就不獻醜了。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.