JavaScript 原型與繼承機制詳解

 引言

  初識 JavaScript 對象的時候,我覺得 JS 是沒有繼承這種說法的,雖然說 JS 是一門面向對象語言,但是面向對象的一些特性在 JS 中並不存在(好比多態,不過嚴格來講也沒有繼承)。這就困惑了我很長的時間,當我學習到 JS 原型的時候,我才發現了 JS 的新世界。本篇文章講解了 JavaScript new 操做符與對象的關係、原型和對象關聯(也就是俗稱的繼承)的原理,適合有必定基礎的同窗閱讀。javascript

 1、JavaScript 的類與對象

  許多書籍上都會說到如何在 JS 當中定義「類」,一般來說就是使用以下代碼:html

1 function foo () {
2     this.x = 1;
3     this.y = 2;
4 }
5 var obj = new foo();  //{x:1, y:2}

  實際上這一個很糟糕的語言機制,咱們首先要明確,在 JS 當中根本沒有「類」這種東西。在瞭解它以前,咱們要先來了解下 JS 的發展歷史。java

  JavaScript 隨着互聯網和瀏覽器而誕生,在早些年代,互聯網還比較貧乏,上網的成本也比較高,網速很是的慢,一般須要花很長的時間才能傳輸完一個純文本的 HTML 文件。因此那時候 Netscape 就提出,須要有一種解決方案,能使一些操做在客戶端進行而不須要經過服務器處理,好比用戶在填寫郵箱的時候少寫了一個「@」,在客戶端就能夠檢查出錯誤並提示用戶而不須要在服務器進行解析,這樣就能夠極大的下降通訊操做帶來了延遲和帶寬消耗。而那時候,正巧 JAVA 問世,火的那叫個一塌糊塗,因此 Netscape 決定和 SUN 合做,在瀏覽器當中植入 JAVA 小程序(後來稱Java applet)。不事後來就這一方案產生了爭議,由於瀏覽器原本只須要很小的操做,而 JAVA 語言自己太「重」了,用來處理什麼表單驗證的問題實在是大材小用,因此決定開發一門新的語言來支持客戶端的輕量級操做,而又要借鑑 JAVA 的語法。因而乎 Netscape 開發出了一門新的輕量級語言,在語法方面偏向於 C 和 JAVA,在數據結構方面偏向於 JAVA,這門語言最初叫作 Mocha,後來通過多年的演變,變成了如今的 JavaScript。程序員

  故事說道這裏,好像和本文並無什麼關係...別急,立刻就要說道點子上了。這個語言爲何要取名 JavaScript 呢,其實它和 JAVA 並無半毛錢的關係,只是由於在那點年代,面向對象方法問世纔不久,全部的程序員都推崇學習面向對象方法,再加上 JAVA 的橫空出世和大力宣傳,只要和 JAVA 沾邊的東西就像是往臉上貼了金同樣,自帶光環。因此便藉助了 JAVA 的名氣來進行宣傳,不過光是嘴皮子宣傳還不行,由於面向對象方法的推崇,你們都習慣於面向對象的語法,也就是 new Class() 的方法編寫代碼。不過 JavaScript 語言自己並無類的概念,其是多種語言的大雜燴,爲了更加貼合習慣了面向對象語法的程序員,因而 new 操做符誕生了。編程

  好了,說了這麼大一堆故事,就是想告訴同窗們,new 操做符在 JavaScript 當中自己就是一個充滿歧義的東西,它並不存在類的概念,只是貼合程序員習慣而已。那麼在 JavaScript 當中 new 操做符和對象究竟有什麼關係呢?思考下面這一段代碼:小程序

1 function foo () {
2     this.x = 1;
3     this.y = 2;
4     return {
5         z:3
6     }
7 }
8 var obj = new foo();  //{z:3}

  咦?發生了什麼奇怪的事情,x 和 y 哪裏去了?實際上 new 操做符並非傳統面嚮對象語言那樣,建立一個類的實例,new 操做符實際上只是在引擎內部幫咱們在函數的開始建立好了一個對象,而後將函數的上下文綁定到這個對象上面,並在函數的末尾返回這個對象。這裏須要注意的問題是,若是咱們手動的返回了一個對象,那麼按照函數執行機制,一旦返回了一個值,那麼該函數也就執行結束,後面的代碼將不會執行,因此說在剛纔的例子中咱們獲得的對象只是咱們手動定義的對象,並非引擎幫咱們建立的對象。 new 操做符實際上相似於如下操做:數組

1 function foo () {
2     //新建立一個對象,將 this 綁定到該對象上
3     
4     //在這裏編寫咱們想要的代碼
5 
6     //return this;
7 }

  不過須要注意的是,new 操做符只接受 Object 類型的值,若是咱們手動返回的是基本類型,則仍是會返回 this :瀏覽器

1 function foo () {
2     this.x = 1;
3     this.y = 2;
4     return 0;
5 }
6 var obj = new foo();  //{x:1, y:2}

  如今咱們如今能夠將 new 操做符定義成如下方法:服務器

 1 function newOpertor (cls, ...args) {
 2     var obj = {};
 3     cls.apply(obj, args);
 4     return obj;
 5 }
 6 
 7 function foo (x, y) {
 8     this.x = x;
 9     this.y = y;
10 }
11 
12 var obj = newOpertor(foo, 1, 2);  //{x:1, y:2}

 2、對象的原型

   JavaScript 中存在相似繼承的機制,可是又不是標準面向對象的繼承,在 JS 中使用的是原型的機制。要記住,在 JS 中只有對象,沒有類,對象的繼承是由原型來實現,籠統的來講能夠這樣理解,一個對象是另外一個對象的原型,那麼即可以把它比做父類,子類既然也就繼承了父類的屬性和方法。數據結構

1 function foo () {
2     this.x = 1;
3     this.y = 2;
4 }
5 
6 foo.prototype.z = 3
7 
8 var obj = new foo();
9 console.log(obj.z);  //3

  [[prototype]] 是函數的一個屬性,這個屬性的值是一個對象,該對象是全部以該函數爲構造器創造的對象的原型。能夠把它近似的理解爲父類對象,那麼相應的,子類天然會繼承父類的屬性和方法。不過爲何要區分原型繼承和類繼承的概念呢?標準的面向對象方法,類是不具備實際內存空間,只是一個事物的抽象,對象纔是事物的實體,而經過繼承獲得的屬性和方法,同屬於該對象,不一樣的對象各自都擁有獨立的繼承而來的屬性。不過在 JavaScript 當中,因爲沒有類的概念,一直都是對象,因此咱們「繼承」的,是一個具備實際內存空間的對象,也是實體,也就是說,全部新建立的子對象,他們共享一個父對象(後面我統稱爲原型),不會擁有獨立的屬性:

 1 function foo () {
 2     this.x = 1;
 3     this.y = 2;
 4 }
 5 
 6 foo.prototype.z = 3
 7 
 8 var obj1 = new foo();
 9 
10 console.log(obj1.z);  //3
11 
12 foo.prototype.z = 2
13 
14 console.log(obj1.z);  //2

  還記得咱們以前所說的 new 操做符的原理嗎?new 操做符的本質不是實例化一個類,而是引擎貼合習慣了面向對象編程方法的程序員,因此說 [[prototype]] 屬性本質上也是 new 操做符的一個副產物。這個屬性只在函數上面有意義,該屬性定義了 new 操做符產生的對象的原型。除了 [[prototype]] 能夠訪問到對象原型之外,還有一個非標準的方法,在每個對象中都有一個 __proto__ 屬性,這個屬性直接關聯到了該對象的原型。這種方法沒有寫入 W3C 的標準規範,可是卻獲得了瀏覽器的普遍支持,許多瀏覽器都提供了該方法以供訪問對象的原型。(我的以爲 __proto__ 比 [[prototype]] 更能體現原型鏈的本質)

 1 function foo () {
 2     this.x = 1;
 3     this.y = 2;
 4 }
 5 
 6 foo.prototype.z = 3
 7 
 8 var obj1 = new foo();
 9 
10 console.log(obj1.__proto__);  //{z:3}

  除了使用 new 操做符和函數的 [[prototype]] 屬性定義對象的原型以外,咱們還能夠直接在對象上顯示的經過 __proto_ 來定義,這種定義對象原型的方式更可以體現出 JavaScript 語言的本質,更可以使初學者理解原型鏈繼承的機制。

1 var father = {x:1};
2 
3 var child = {
4     y:2,
5     __proto__:father
6 };
7 
8 console.log(child.x);  //1

  如今咱們來完成以前那個自定義 new 操做(若是你還不能理解這個函數,沒有關係,跳過它,這並不影響你接下來的學習):

 1 function newOpertor (cls, ...args) {
 2     var obj = Object.create(cls.prototype);
 3     cls.apply(obj, args);
 4     return obj;
 5 }
 6 
 7 function foo (x, y) {
 8     this.x = x;
 9     this.y = y;
10 }
11 
12 foo.prototype.z = 3
13 
14 var obj1 = newOpertor(foo, 1, 2)
15 
16 console.log(obj1.z);  //3

 3、原型鏈

  介紹完原型以後,同窗們須要明確如下幾個概念:

  •   JavaScript 採用原型的機制實現繼承;
  •   原型是一個具備實際空間的對象,全部關聯的子對象共享一個原型;

  那麼 JavaScript 當中的原型是如何實現相互關聯的呢?JS 引擎又是如何查找這些關聯的屬性呢?如何實現多個對象的關聯造成一條原型鏈呢?

 1 var obj1 = {
 2     x:1
 3 }
 4 
 5 var obj2 = {
 6     y:2,
 7     __proto__:obj1
 8 }
 9 
10 var obj3 = {
11     z:3,
12     __proto__:obj2
13 }
14 
15 console.log(obj3.y);  //2
16 console.log(obj3.x);  //1

  在上面這段代碼,咱們能夠看出,對象的原型能夠實現多層級的關聯的操做,obj1 是 obj2 的原型, obj2 同時又是 obj3 的原型,這種多層級的原型關聯,就是咱們常說的原型鏈。在訪問一個處於原型鏈當中的對象的屬性,會沿着原型鏈對象一直向上查找,咱們能夠把這種原型遍歷操做當作是一個單向的鏈表,每個處於原型鏈的對象都是鏈表當中的一個節點,JS 引擎會沿着這條鏈表一層一層的向下查找屬性,若是找到了一個與之匹配的屬性名,則返回該屬性的值,若是在原型鏈的末端(也就是 Object.prototype)都沒有找到與之匹配的屬性,則返回 undefined。要注意這種查找方式只會返回第一個與之匹配的屬性,因此會發生屬性屏蔽:

 1 var obj1 = {
 2     x:1
 3 }
 4 
 5 var obj2 = {
 6     x:2,
 7     __proto__:obj1
 8 }
 9 
10 var obj3 = {
11     x:3,
12     __proto__:obj2
13 }
14 
15 console.log(obj3.x);  //3

  若要訪問原型的屬性,則須要一層的一層的先向上訪問原型對象:

1 console.log(obj3.__proto__.x);  //2
2 console.log(obj3.__proto__.__proto__.x);  //1

  要注意的一點是,原型鏈的遍歷只會發生在 [[getter]] 操做上,也就是取值操做,也能夠稱之右查找(RHS)。相反,如果進行 [[setter]] 操做,也就是賦值操做,也能夠稱做左查找(LHS),則不會遍歷原型鏈,這條原則保證了咱們在對對象進行操做的時候不會影響到原型鏈:

 1 var obj1 = {
 2     x:1
 3 }
 4 
 5 var obj2 = {
 6     __proto__:obj1
 7 }
 8 
 9 console.log(obj2.x);  //1
10 
11 obj2.x = 2;
12 
13 console.log(obj2.x);  //2
14 console.log(obj1.x);  //1(並無發生變化)

   在遍歷原型鏈中,若是訪問帶有 this 引用的方法,可能會發生令你意想不到的結果:

 1 var obj1 = {
 2     x:1,
 3     foo: function  () {
 4         console.log(this.x);
 5     }
 6 }
 7 
 8 var obj2 = {
 9     x:2,
10     __proto__:obj1
11 }
12 
13 obj2.foo();  //2

  在上面的內容中,咱們討論過,對象的原型至關於父類,咱們能夠繼承它所擁有的屬性和方法,因此在咱們訪問 foo() 函數的時候時候,實際上調用該方法的對象是 obj2 而不是 obj1。關於更詳細的內容,須要瞭解 this 和上下文綁定,這不在本篇文章的討論範圍以內。

  關於原型鏈的問題,你們須要理解的一點是,任何對象的原型鏈終點,都是 Object.prototype,能夠把 Object 理解爲全部對象的父類,相似於 JAVA 同樣,因此說全部對象均可以調用一些 Object.prototype 上面的方法,好比 Object.prototype.valueOf() 以及 Object.prototype.toString() 等等。全部的 string 類型,其原型爲 String.prototype ,String.prototype 是一個對象,因此其原型也就是 Object.prototype。這就是咱們爲何可以在一個 string 類型的值上調用一些方法,好比 String.prototype.concat() 等等。同理全部數組類型的值其原型是 Array.prototype,數字類型的值其原型是 Number.prototype:

1 console.log({}.__proto__ === Object.prototype);  //true
2 
3 console.log("hello".__proto__ === String.prototype);  //true
4 
5 console.log(1..__proto__ === Number.prototype);  //true
6 //注意用字面量訪問數字類型方法時,第一個點默認是小數標誌
7 
8 console.log([].__proto__ === Array.prototype);  //true

   理解了原型鏈的遍歷操做,咱們如今就能夠學習如何添加屬於本身的方法。咱們如今知道了全部字符串的原型都是 String.prototype ,那麼咱們能夠對其進行修改來設置咱們本身的內置方法:

1 String.prototype.foo = function () {
2     return this + " foo";
3 }
4 
5 console.log("bar".foo());  //bar foo

  因此說,在處理一些瀏覽器兼容性問題的時候,咱們能夠直接修改內置對象來兼容一些舊瀏覽器不支持的方法,好比 String.prototype.trim() :

1 if (!String.prototype.trim) {
2     String.prototype.trim = function() {
3         return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '');
4     };
5 }

  不過須要注意,切忌隨意修改內置對象的原型方法,一是由於這會帶來額外的內存消耗,二是這可能會在系統中形成一些隱患,通常只是用來作瀏覽器兼容的 polyfill 。

4、 有關原型的方法

   for ... in 語句會遍歷原型鏈上全部可枚舉的屬性(關於屬性的可枚舉性質,能夠參考 《JavaScript 常量定義》),有時咱們在操做的時候須要忽略掉原型鏈上的屬性,只訪問該對象上的屬性,這時候咱們可使用 Object.prototype.hasOwnProperty() 方法來判斷屬性是否屬於原型屬性:

 1 var obj1 = {
 2     x:1,
 3 }
 4 
 5 var obj2 = {
 6     y:2,
 7     __proto__:obj1
 8 }
 9 
10 for(var key in obj2){
11     console.log(obj2[key]);  //2, 1
12 }
13 
14 for(var key in obj2){
15     if(obj2.hasOwnProperty(key)){
16         console.log(obj2[key]);  //2
17     }
18 }

  咱們知道經過 new 操做符建立的對象能夠經過 instanceof 關鍵字來查看對象的「類」:

1 function foo () {}
2 
3 var obj = new foo();
4 
5 console.log(obj instanceof foo);  //true

  實際上這個操做也是不嚴謹的,咱們如今已經知道了 new 操做符在 JavaScript 當中本是一個具備歧義設計,instanceof 操做符自己也是一個會讓人誤解的操做符,它並無實例這種說法,實際上這個操做符只是判斷了對象與函數原型的關聯性,也就是說其返回的是表達式 object.__proto__ === function.prototype 的值。

 1 function foo () {}
 2 
 3 var bar = {
 4     x:1
 5 }
 6 
 7 foo.prototype = bar
 8 
 9 var obj = {
10     __proto__: bar
11 }
12 
13 console.log(obj instanceof foo);  //true

  在這一段代碼中,咱們能夠看出 obj 和 foo 並無任何關係,只是 obj 的原型和 foo.prototype 關聯到了同一個對象上面,因此其結果會返回 true。  

  不過對基本類型類型使用 instanceof 方法的話,可能會產生意外的結果:

1 console.log("1" instanceof String);  //false
2 
3 console.log(1 instanceof Number);  //false
4 
5 console.log(true instanceof Boolean);  //false

  可是咱們一樣可使用使用字面量調用原型的方法,這可能會讓人感到困惑,不過咱們不用擔憂它,並非原型鏈出現什麼毛病,而是在對基本類型進行字面量操做的時候,會涉及到隱式轉換的問題。JS 引擎會先將字面量轉換成內置對象,而後在調用上面的方法,隱式轉換問題不在本文的討論範圍之類,你們能夠參考 Kyle Simpson — 《你不知道的 JavaScript (中卷)》。

  實際對象的 Object.prototype.isPrototypeOf() 方法更能體現出對象原型鏈的關係,此方法判斷一個對象是不是另外一個對象的原型,不一樣於 instanceof 的是,此方法會遍歷原型鏈上全部的節點,此方法做用於對象,而 instanceof 方法做用於構造器,其都會遍歷原型鏈上全部的節點:

 1 var obj1 = {
 2 }
 3 
 4 var obj2 = {
 5     __proto__:obj1
 6 }
 7 
 8 var obj3 = {
 9     __proto__:obj2
10 }
11 
12 console.log(obj2.isPrototypeOf(obj3));  //true
13 console.log(obj1.isPrototypeOf(obj3));  //true
14 console.log(Object.prototype.isPrototypeOf(obj3));  //true

  在 ES5 當中擁有標準方法 Object.getPrototypeOf() 能夠供咱們得到一個對象的原型,在ES6 當中擁有新的方法 Object.setPrototypeOf() 能夠設置一個對象的原型,不過在使用以前請先查看瀏覽器兼容性。

 1 var obj1 = {
 2     x:1
 3 }
 4 
 5 var obj2 = {
 6     y:2
 7 }
 8 
 9 Object.setPrototypeOf(obj2, obj1);
10 
11 console.log(Object.getPrototypeOf(obj2) === obj1);  //true

  咱們如今知道,經過 new 操做符建立的對象,其原型會關聯到函數的 [[prototype]] 上面,實際上這是一個很糟糕的寫法,一味的貼合面向對象風格的編程模式,使得不少人沒法領域 JavaScript 當中的精髓。許多書籍都會寫到 JavaScript 中有許多奇怪的地方,而後教你如何避開這些地雷,實際上這不是一個好的作法,並非由於 JavaScript 是一門稀奇古怪的語言,而是咱們不肯意去面對它的特性,正確的理解這些特性,才能讓咱們寫出更加高效的程序。Object.create() 方法對於對象之間的關聯和原型鏈的機制更加清晰,比 new 操做符更加可以理解 JavaScript 的繼承機制。該方法建立一個新對象,並使新對象的原型關聯到參數對象當中:

1 var obj1 = {
2     x:1
3 }
4 
5 var obj2 = Object.create(obj1);
6 
7 console.log(obj1.isPrototypeOf(obj2));  //true

  不過使用的時候還須要注意瀏覽器的兼容性,下面給出 MDN 上面的 polyfill:

 1 (function() {
 2     if (typeof Object.create != 'function') {
 3         Object.create = (function() {
 4             function Temp() {}
 5             var hasOwn = Object.prototype.hasOwnProperty;
 6             return function(O) {
 7                 if (typeof O != 'object') {
 8                     throw TypeError('Object prototype may only be an Object or null');
 9                 }
10                 Temp.prototype = O;
11                 var obj = new Temp();
12                 Temp.prototype = null;
13                 if (arguments.length > 1) {
14                     var Properties = Object(arguments[1]);
15                     for (var prop in Properties) {
16                         if (hasOwn.call(Properties, prop)) {
17                             obj[prop] = Properties[prop];
18                         }
19                     }
20                 }
21                 return obj;
22             };
23         })();
24     }
25 })();

  關於 Object.create() 方法要注意的一點是,若是參數爲 null 那麼會建立一個空連接的對象,因爲這個對象沒有任何原型鏈,因此說它不具備任何原生的方法,也沒法進行原型的判斷操做,這種特殊的對象常被稱做「字典」,它徹底不會受原型鏈的干擾,因此說適合用來存儲數據:

 1 var obj = Object.create(null);
 2 obj.x = 1
 3 
 4 var bar = Object.create(obj);
 5 bar.y = 2;
 6 
 7 console.log(Object.getPrototypeOf(obj));  //null
 8 
 9 console.log(Object.prototype.isPrototypeOf(obj));  //false
10 
11 console.log(obj instanceof Object);  //false
12 
13 console.log(bar.x);  //1
14 
15 obj.isPrototypeOf(bar);  //TypeError: obj.isPrototypeOf is not a function
16 
17 /**
18  * 注意因爲對象沒有關聯到 Object.prototype 上面,因此沒法調用原生方法,但這並不影響此對象的關聯操做。
19  */

 總結

  原型鏈是 JavaScript 當中很是重要的一點,同時也是比較難理解的一點,由於其與傳統的面嚮對象語言有着很是大的區別,但這是正是 JavaScript 這門語言的精髓所在,關於原型與原型鏈,咱們須要知道如下這幾點:

  •   JavaScript 經過原型來實現繼承操做;
  •   幾乎全部對象都有原型鏈,其末端是 Object.prototype;
  •   原型鏈上的 [[getter]] 操做會遍歷整條原型鏈,[[setter]] 操做只會針對於當前對象;
  •   咱們能夠經過修改原型鏈上的方法來添加咱們想要的操做(最好不要這樣作);

  關於 JavaScript 原型鏈,在一開始人們都稱爲「繼承」,其實這是一種不嚴謹的說法,由於這不是標準的面向對象方法,不過初期人人經常這麼理解。如今我每每稱之爲關聯委託,關聯指的是一個對象關聯到另外一個對象上,而委託則指的是一個對象能夠調用另外一個對象的方法。

  本篇文章均爲我的理解,若有不足或紕漏,歡迎在評論區指出。

 參考文獻:

  Kyle Simpson — 《你不知道的 JavaScript (上卷)》

  MDN — Object - JavaScript | MDN

  阮一峯 — JavaScript 語言的歷史

相關文章
相關標籤/搜索