JavaScript的原型繼承

JavaScript是一門面向對象的語言。在JavaScript中有一句很經典的話,萬物皆對象。既然是面向對象的,那就有面向對象的三大特徵:封裝、繼承、多態。這裏講的是JavaScript的繼承,其餘兩個容後再講。javascript

JavaScript的繼承和C++的繼承不大同樣,C++的繼承是基於類的,而JavaScript的繼承是基於原型的。java

如今問題來了。瀏覽器

原型是什麼?

原型咱們能夠參照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的全部屬性、方法和其餘性質。
那麼,複製又是怎麼實現的呢?

方法一:構造複製

每構造一個實例,都從原型中複製出一個實例來,新的實例與原型佔用了相同的內存空間。這雖然使得obj一、obj2與它們的原型「徹底一致」,但也很是不經濟——內存空間的消耗會急速增長。如圖:

方法二:寫時複製

這種策略來自於一致欺騙系統的技術:寫時複製。這種欺騙的典型示例就是操做系統中的動態連接庫(DDL),它的內存區老是寫時複製的。如圖:

咱們只要在系統中指明obj1和obj2等同於它們的原型,這樣在讀取的時候,只須要順着指示去讀原型便可。當須要寫對象(例如obj2)的屬性時,咱們就複製一個原型的映像出來,並使之後的操做指向該映像便可。如圖:

這種方式的優勢是咱們在建立實例和讀屬性的時候不須要大量內存開銷,只在第一次寫的時候會用一些代碼來分配內存,並帶來一些代碼和內存上的開銷。但此後就再也不有這種開銷了,由於訪問映像和訪問原型的效率是一致的。不過,對於常常進行寫操做的系統來講,這種方法並不比上一種方法經濟。

方法三:讀遍歷

這種方法把複製的粒度從原型變成了成員。這種方法的特色是:僅當寫某個實例的成員,將成員的信息複製到實例映像中。當寫對象屬性時,例如(obj2.value=10)時,會產生一個名爲value的屬性值,放在obj2對象的成員列表中。看圖:

能夠發現,obj2仍然是一個指向原型的引用,在操做過程當中也沒有與原型相同大小的對象實例建立出來。這樣,寫操做並不致使大量的內存分配,所以內存的使用上就顯得經濟了。不一樣的是,obj2(以及全部的對象實例)須要維護一張成員列表。這個成員列表遵循兩條規則:
  1. 保證在讀取時首先被訪問到
  2. 若是在對象中沒有指定屬性,則嘗試遍歷對象的整個原型鏈,直到原型爲空或或找到該屬性。
原型鏈後面會講。
 
顯然,三種方法中,讀遍歷是性能最優的。因此, 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,尋找退出。
ide

原型鏈的維護

咱們在剛纔講原型繼承的時候提出了一個問題,使用方法一構造繼承時,子對象實例的constructor指向的是父對象。這樣的好處是咱們能夠經過constructor屬性來訪問原型鏈,壞處也是顯而易見的。一個對象,它產生的實例應該指向它自己,也便是
(new obj()).prototype.constructor === obj; 
而後,當咱們重寫了原型屬性以後,子對象產生的實例的constructor不是指向自己!這樣就和構造器的初衷背道而馳了。
咱們在上面提到了一個解決方案:
Dog.prototype = new Animal("wangwang");
Dog.prototype.constructor = Dog;
看起來沒有什麼問題了。但實際上,這又帶來了一個新的問題,由於咱們會發現,咱們無法回溯原型鏈了,由於咱們無法尋找到父對象,而內部原型鏈的.proto屬性是沒法訪問的。
因而,SpiderMonkey提供了一個改良方案:在任何建立的對象上添加了一個名爲__proto__的屬性,該屬性老是指向構造器所用的原型。這樣,對任何constructor的修改,都不會影響__proto__的值,就方便維護constructor了。可是,這樣又兩個問題:
  1. __proto__是能夠重寫的,這意味着使用它時仍然有風險
  2. __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的瀏覽器中使用。爲了向後兼容,咱們仍是須要考慮上一種方法的。更合適的方法是將這兩種方法整合封裝起來,這個相信讀者們都很是擅長,這裏就不獻醜了。
相關文章
相關標籤/搜索