【面試必備】javascript的原型和繼承

呂大豹發表於2014-01-03

  原型、閉包、作用域等知識可以說是js中面試必考的東西,通過你理解的深度也就能衡量出你基本功是否紮實。今天來複習一下javascript的原型和繼承,雖說是老生常談的話題,但對於這些知識,自己親手寫一遍能更加透徹的理解,能用自己的話說明白了,也就真正理解了。

原型是什麼?

  在javascript中,通過關鍵字new呼叫構造器函式或者使用字面量宣告,我們可以得到一個物件例項。每個物件例項內部都持有一個指標,指向一個普通的物件,這個普通的物件就是原型,這是天生的。為什麼說它是普通的物件呢?因為它確實沒什麼特別的地方,同樣也是某個構造器函式的一個例項,這個構造器可以是Object,可以是Array,也可以是其他你自己定義的構造器函式。在js中,物件例項的原型是不可訪問的,不過在chrome和Firefox瀏覽器中,我們可以用一個名為__proto__的屬性來訪問到,來看一下所謂的原型長什麼樣:

  我用string的包裝類來建立了一個物件s,可以看到s的原型是一個物件,該物件上包含了一系列方法,比如我們熟悉的charAt。這裡也就很明顯了,我們平時呼叫s.charAt(0),其實呼叫的是s的原型上的方法,也就是說,原型上的屬性可以被物件訪問到,就像是在訪問自身的屬性一樣。可以認為原型就像孕婦肚子裡的孩子一樣,孩子的胳膊也可以算是孕婦的胳膊,都在自己身上嘛。不過區別是這裡的原型只是一個引用,並不是真正的包含這個物件。注意不要被__proto__後面的那個String迷惑到,s的原型是一個Object的例項,而不是String的例項。下面的程式碼可以證明:

s.__proto__ instanceOf String; //false
s.__proto__ instanceOf Object; //true

s.hasOwnProperty('charAt'); //false
s.__proto__.hasOwnProperty('charAt'); //true

  要明白這個原型指標到底指向什麼,就需要明白物件是如何建立出來的,所以接下來有必要了解一下構造器函式。

  javascript中沒有類,但可以把函式當類使,被用來當做類構造器的函式就叫構造器函式,一般把首字母大寫來與普通函式進行區別,其實就是豬鼻子插根蔥而已——裝象。js中一切都是物件,所以函式也是物件,所以函式也有一個原型指標。與例項物件不同的是,函式這種特殊的物件,它的原型可以通過prototype屬性顯式的訪問到,來看看String類的原型是啥樣的:

  好像跟我們上面看到的s的原型是一模一樣的。。。是這樣嗎?驗證一下:

  這是什麼原因呢?我們就要細究一下var s = new String('s');在執行的時候到底發生了什麼,其實就是用new關鍵字呼叫函式String的時候發生了什麼:

  1. 建立一個空物件obj,即Object的一個例項
  2. 把這個空物件obj繫結到函式的上下文環境中,相當於把this指向了obj
  3. 執行函式,這個過程就把函式中的屬性、方法拷貝到了obj中
  4. 將obj的原型指向函式的prototype屬性
  5. 返回這個obj物件,s作為它的引用。

  到這裡就可以得出結論了:物件例項與它的構造器函式擁有同一個原型,這個原型指向的是構造器的父類的一個例項。

  我第一次提到了“父類”,在物件導向的語言中,如果B繼承自A,我們說A是B的父類。javascript是通過原型實現繼承的,所以我也可以說,我的原型指向誰,誰就是我的父類。通過上面的程式碼我們可以得出:

String.prototype === s.__proto__ //true
String.prototype instanceOf Object //true

  可以用面嚮物件語言的話說,Object就是String的父類。之所以這麼說是因為這樣容易記住,再來重複一遍結論:物件例項與它的構造器函式擁有同一個原型,這個原型指向的是構造器的父類的一個例項。這個結論是非常有用的,由於物件例項的原型是不可訪問的(__proto__只是瀏覽器提供的能力),我們可以通過constructor屬性得到它的構造器,然後用構造器的prototype屬性來訪問到原型,像這樣:

s.constructor.prototype

  理解的過程像是在做一道道證明題一樣。儘管有大師推薦在js中用構造器函式這個稱呼來代替類,但為了便於理解和記憶,我還是這麼叫吧~

原型的一些特性

  明白是原型是什麼東西,來看看原型都有哪些特性。其實也不能說是原型的特性,而是javascript語言的特性。

  首先要看的就是所謂的原型鏈。每個物件都有原型,而物件的原型也是一個普通物件,那麼就可以形成一個鏈,例如String物件的原型是Object類的一個例項,而Object物件的原型是一個空物件,空物件的原型是null。除去null不看的話,原型鏈的頂端是一個空物件{}

  當我們訪問物件的一個屬性時,會先從物件自身找,如過自身沒有,就會順著原型鏈一直往上找,直到找到為止。如果最後也沒找到,則返回undefined。這樣物件的內容就會很“豐富”,我的是我的,原型的也是我的。通過修改原型的指向,物件可以獲得相應原型上的屬性,js就是通過這種方式實現了繼承。

  有一點需要注意的是,屬性的讀操作會順著原型鏈來查詢,而寫操作卻不是。如果一個物件沒有屬性a,為該物件的a屬性賦值會直接寫在該物件上,而不是先在原型上找到該屬性然後修改值。舉個例子:

var s = new String('string');
s.charAt(0); //返回s
s.hasOwnProperty('charAt'); //返回false  說明charAt不是自身的方法,而是原型上的
s.charAt = function(){return 1;} //為s的charAt賦值
s.hasOwnProperty('charAt'); //返回true   說明自身有了charAt方法
s.charAt(0); //返回1   這時候呼叫charAt找到了自身的方法
s.constructor.prototype.charAt.call(s,0); //返回s  呼叫原型上的charAt方法結果與原來一樣

  上面的例子說明,為物件的屬性賦值是不會影響到原型的。這也是合理的,因為建立出來的物件s,它的原型是一個指標,指向了構造器的原型。如果原型被修改,那麼該類的其他例項也會跟著改變,這顯然是不願意看到的。

  我們願意看到的是,修改了一個構造器的原型,由它構造出的例項也跟著動態變化,這是符合邏輯的。比如我們建立一個Person類,然後修改其原型上的屬性,觀察它的例項的變化:

function Person(name){
    this.name = name;
}
Person.prototype.age = 10;
var p1 = new Person('p1');
console.log(p1.age); //10
Person.prototype.age = 11;
console.log(p1.age); //11

  這是因為age存在於原型上,p1只是擁有一個指標指向原型,原型發生改變後,用p1.age訪問該屬性必然也跟著變化。

 用原型實現繼承

  用原型實現繼承的思路非常簡單,令建構函式的原型指向其父類的一個例項,這樣父類中的屬性和方法也就相當於被引用到了,呼叫起來和呼叫自己的一樣。比如定義一個Programmer類繼承自Person:

function Person(name){
    this.name = name;
}
Person.prototype.age = 10;

function Programmer(name){
    this.name = name;
}
Programmer.prototype = new Person();
Programmer.prototype.constructor = Programmer;
var p1 = new Programmer('p1');
console.log(p1.age); //10

  可以看到Programmer的例項p1繼承了Person的屬性age。另外需要注意的就是constructor的修正。因為我們new一個Person物件出來,它的constructor指向自身的建構函式Person,所以在Programmer的原型中,這個constructor始終是Person,這與邏輯是不符的,所以必須顯式的“糾正”一下這個副作用,讓Programmer原型上的constructor指向自己。

  以上程式碼實現了一個基本的繼承。但其中還是有不少可以擴充套件的地方,如果面試的時候只答出上面的這些,只能算是及格吧。關於如何優化繼承的程式碼,有位大牛的文章分析的十分詳細,出於篇幅原因我在本篇就不再陳述。直接貼上鍊接地址:http://www.cnblogs.com/sanshi/archive/2009/07/08/1519036.html,共六篇系列部落格,非常詳細。

----------------補充於2014.01.07---------------------

  在上面的繼承實現方式中,有一個消耗記憶體的地方,就是為子類指定原型時需要new一個父類的物件,有人做了比較好的處理,今天看到了程式碼,據說是coffeescript中的,抄在這裡:

var _hasProp = {}.hasOwnProperty;
var extends = function(child,parent){
    for(var key in parent){
        if(_hasProp.call(parent,key)){
            child[key] = parent[key];
        }
    }
    function ctor(){
        this.constructor = child;
    }
    ctor.prototype = parent.prototype;
    child.prototype = new ctor();
    child._super_ = parnet.prototype;
    return child;
}

  是一個完整的實現繼承的方法。在內部建立了一個最小化的物件,減少記憶體消耗。

繼承的另一種實現方式

  除了用原型,還有一種方式也可以實現繼承,叫做類複製。怎麼個複製法呢,看下面的程式碼:

function People(name){
    this.name = name;
    this.age = 11;
    this.getName = function(){
        return this.name;
    }
}

function Worker(name){
    People.call(this,name);
}

var w1 = new Worker('w1');
console.log(w1.getName()); //w1
console.log(w1.age); //11

  在People構造器中所有的屬性和方法都用this關鍵字定義在了自身,而不是放在它的原型上。在子類Worker中,用call把People當作函式執行了一下,並傳入this作為上下文物件。這樣就相當於把People中的所有語句拿過來執行一次,所有屬性的定義也都被複制過來了。同樣可以實現繼承。完全與原型無關。

  那麼這種方式與原型繼承有何區別呢?最大的區別就在於原型是一個引用,所有例項都引用一個共享的物件,每次建立出一個例項時,並不會複製原型的內容,只是用一個指標指過去。而類複製的方法不存在共有的東西,每建立一個物件都把構造器中的程式碼執行一次,當構造器中的方法較多時,會消耗很多的記憶體。而原型繼承就不會了,只需一個指標指過去就完了。

  由這種工作方式產生的另一個區別就是動態修改,我們知道在原型繼承中,只要修改了構造器原型中的值,例項物件也跟著變化。但是類複製就不能了,每個物件都有自己的一份資料,已建立出來的物件不會再受構造器的影響了。

  另外還有一點,就是屬性的訪問速度。類複製的方式,物件的屬性都在自身,所以在查詢的時候可以立即找到,而原型繼承在查詢的時候還得順著原型鏈向上查詢,其訪問速度肯定不如類複製的快。

總結

  以上是我理解到的原型與繼承的知識點,可能理解還是沒有那麼透徹,只是從比較淺的層次梳理了一下。與原型相關的知識還有很多有深度的,還有待於繼續研究。這篇部落格寫完我也感覺到,寫一篇基礎知識分析的文章真是挺困難的,需要你對每一個細節都掌握清楚,生怕稍不注意就給別人誤導。可能自己的水平也有待提高吧,本篇就先分析到這個程度,不知這個程度能否達到初級前端工程師的門檻。後續收集到了面試題,我會結合分析。

相關文章