前端入門13-JavaScript進階之原型

請叫我大蘇發表於2018-12-04

宣告

本系列文章內容全部梳理自以下幾個來源:

作為一個前端小白,入門跟著這幾個來源學習,感謝作者的分享,在其基礎上,通過自己的理解,梳理出的知識點,或許有遺漏,或許有些理解是錯誤的,如有發現,歡迎指點下。

PS:梳理的內容以《JavaScript權威指南》這本書中的內容為主,因此接下去跟 JavaScript 語法相關的系列文章基本只介紹 ES5 標準規範的內容、ES6 等這系列梳理完再單獨來講講。

正文-原型

JavaScript 中並沒有 Java 裡的類,但它有建構函式,也有繼承,只是它是動態的基於原型的繼承。所以,原型有點類似於 Java 中父類的概念。

但是,JavaScript 中的關於例項、繼承等這些跟 Java 還是有很大的區別。

先來說說在 Java 裡面:

類是靜態的,類是可繼承的,是物件的抽象模型的表現,每個具體的物件都是從類上例項化出來的,一個類中定義了這類物件的屬性和行為,一旦定義完了執行時就無法改變了。

但對於 JavaScript 來說,它並沒有類的存在,在 JavaScript 裡,除了原始型別外,其餘皆是物件。

它是動態的基於原型的繼承機制,原型本質上也是物件,也就是說物件是繼承自物件而來的。

而物件這個概念是例項化後的每一個具體個體代表,它是執行期動態生成的,再加上 JavaScript 裡物件的特性,如可動態新增屬性,這就讓 JavaScript 裡的繼承機制非常強大,因為這樣一來,它是可動態繼承的,原型物件上發生的變化能夠同步讓繼承它的子物件都跟隨著變化。

原型概念

函式和建構函式的區別就在於,所有的函式,當和 new 關鍵字一起使用時,此時稱它為建構函式。類似的關係,所有的物件,當它被設定為某個建構函式的 prototype 屬性值時,此時稱它為原型。

也就是說,任何物件都可以當做其他物件的原型。

在 Java 中,物件一般通過 super 關鍵字指向它的父類,而在 JavaScript 中,物件可通過 __proto__ 來指向它的原型物件,或者通過建構函式的 prototype 指向物件的原型。

prototype & __proto__

這兩個雖然指向的是同一個原型物件,但它們的宿主卻不一樣,需要區分一下,prototype 是建構函式的屬性,__proto__ 是通過建構函式建立出來的物件的屬性。

__proto__ 屬性並不在 ES5 標準規範中,但基本大部分瀏覽器都為引用型別實現了這麼一個屬性,用於檢視當前物件所繼承的原型,它的值等於該物件的建構函式的 prototype 屬性值。

prototype 是每個函式物件的一個屬性,其他物件並沒有這個屬性,因為基本所有的物件其實都是通過建構函式建立出來的,所以也只有函式才能來實現繼承的機制。這個屬性值表示著從這個建構函式建立的物件的原型是什麼。

物件一節學習過,建立一個物件的三種方式:

//物件直接量:
var a = {};//其實是 var a = new Object(); 的語法糖
var a = [];//其實是 var a = new Array(); 的語法糖

//建構函式
var a = new Array();

//Object.crate()
var a = Object.crate(null);

所以,物件直接量的方式本質上也是通過建構函式的方式建立物件。

這也是為什麼會在物件一節中說,所有通過直接量方式建立的物件都繼承自 Object.prototype 的理由。

而通過 Object.create() 方式建立的物件,其原型就是引數指定的物件,可手動傳入 null,表示建立的物件沒有原型。

所以,在 JavaScript 中,絕大部分的物件都有原型,即使不手動指定,也會有預設的內建原型物件。之所以說絕大部分,是因為原型鏈頂部的 Object.prototype 物件的原型是 null,或者通過 Object.create() 建立物件時手動指定 null。

預設的繼承結構

如果不手動指定繼承關係,預設的幾種引用型別的繼承關係(原型鏈)如下:

  •      宣告的每個函式 -> Function.prototype –> Object.prototype -> null
  •      陣列物件 -> Array.prototype -> Object.prototype -> null 
  •      物件直接量建立的物件 -> Object.prototype -> null
  •      自定義建構函式建立的物件 -> {} -> Object.prototype -> null

所有物件繼承的頂層原型是 Object.prototype。

這也是為什麼函式物件、陣列物件、普通物件都可以使用一些內建的方法,因為建立這些物件的時候,預設就會有一些繼承關係,跟 Java 中所有的類都繼承自 Object 的機制類似。

建構函式和原型的關係

建構函式本身是一個函式物件,它的屬性 prototype 指向的是另一個物件,所以這兩個概念本身就是兩個不同的東西。

通過一個建構函式建立一個新的物件,不能說,這個物件繼承自建構函式,而是應該說,這物件繼承自建構函式的屬性 prototype 指向的物件。

所以,可以通俗的理解,建構函式只是作為第三方類似於工具的角色,用來建立一個新物件,然後讓這個新物件繼承自 prototype 屬性指向的物件。

不過建構函式和原型之間是相互引用的關聯關係,建構函式有個屬性 prototype 指向原型,而原型也有一個屬性 constructor 指向建構函式。

所以,所有從這個建構函式建立的新物件,都繼承了原型的屬性,那麼這些新物件也就可以通過繼承而來的 constructor 的屬性訪問建構函式。

如果不手動破壞原型鏈,那麼通過建構函式建立新物件時,三者間的關係:

三者關係

而更多的時候,我們需要藉助原型來讓物件繼承一些公有行為,有兩種做法,一種是通過直接在原型物件上動態新增相關屬性,這種方式不破壞原型鏈,比較推薦。

還有一種,定義一個新的原型物件,然後重新賦值建構函式的 prototype 屬性值,將它指向新的原型物件。但這種方式會破壞預設的原型鏈,同時也會破壞建構函式、原型、例項化物件三者間的預設關聯關係。

舉個例子:

function A(){}   //定義建構函式A
A.prototype.c = 1;
var b = new A(); //通過建構函式建立物件b

通過建構函式建立一個新物件b,且在建構函式的 prototype 上手動新增新的屬性c,會被 b 繼承,由於這種方式是沒有破壞原型鏈的,所以三者間關係如下:

建構函式示例

b.__proto__ 表示 b 的原型,原型物件的 constructor 屬性指向建構函式 A,name 是函式物件的屬性,用於輸出函式名。

而且物件 b 由於繼承自原型 A.prototype,所以也繼承它的 constructor 屬性,所以也指向建構函式 A。

此時物件 b 的繼承關係:b -> {} -> Object.prototype

以上是預設的不破壞原型鏈下三者的關係,但如果手動破壞了原型鏈呢:

function A(){}   //定義建構函式A
A.prototype.c = 1;
var a = [];      //建立陣列物件a
a.c = 0;
A.prototype = a; //手動修改建構函式A的prototype,讓其指向 a
var b = new A(); //通過建構函式建立物件b,b繼承自原型a

上面的程式碼手動修改了 A.prototype 的屬性值,讓 b 是繼承自手動建立的物件 a,所以這裡就破壞了預設的原型鏈,同時,三者間的關係也被破壞了:

修改原型示例

首先,c 屬性驗證了 b 是繼承自物件 a了。

而我們說過,b.__proto__ 指向 b 的原型,在這裡,b 的原型就是物件 a 了。而物件 a 是手動建立的,所以它的 constructor 屬性是繼承自它的原型物件。陣列直接量建立的陣列物件,本質上是通過 new Array(),所以a的建構函式是 Array(),物件 a 繼承自 Array.prototype。

對於物件 a,我們建立它的方式並沒有手動去修改它的原型鏈,所以按預設的三者間的關係,Array.prototype 的 constructor 屬性指向建構函式 Array(),這就是為什麼 b.__proto__.constructor.name 的值會是 Array 了。

而,物件 b 繼承自物件 a,所以 b.constructor 的值也才會是 Array。

此時,物件 b 的繼承關係: b-> a -> Array.prototype -> Object.prototype

所以,在這個例子中,雖然物件 b 是從建構函式 A 建立的,但它的 constructor 其實並不指向 A,這點也可以稍微說明,建構函式的作用其實更類似於作為第三方協調原型和例項物件兩者的角色。

通常是不建議通過這種方式來實現繼承,因為這樣會破壞預設的三者間的聯絡,除非手動修復,手動對 a 的 constructor 屬性賦值為 A,這樣可以手動修復三者間預設的關聯。

來稍微小結一下

因為原型本質上也是物件,所以它也具有物件的特性,同時它也有自己的一些特性,總結下:

  • 所有的引用型別(陣列、物件、函式),都具有物件特性,都可以自由擴充套件屬性,null除外。
  • 所有的引用型別(陣列、物件、函式),都有一個 __proto__ 屬性,屬性值的資料型別是物件,含義是隱式原型,指向這個物件的原型。
  • 所有的函式(不包括陣列、物件),都有一個 prototype 屬性,屬性值的資料型別是物件,含義是顯式原型。因為函式都可以當做建構函式來使用,當被用於建構函式建立新物件時,新物件的原型就是指向建構函式的 prototype 值。
  • 所有的內建建構函式(Array、Function、Object…),它的 prototype 屬性值都是定義好的內建原型物件,所以從這些內建建構函式建立的物件都預設繼承自內建原型,可使用內建的屬性。
  • 所有的自定義函式,它的 prototype 屬性值都是 new Object(),所以所有從自定義建構函式建立的物件,預設的原型鏈為 (空物件){} —- Object.prototype。
  • 所有的引用型別(陣列、物件、函式),__proto__ 屬性指向它的建構函式的prototype值,不手動破壞建構函式、原型之間的預設關係時
  • 所有的引用型別(陣列、物件、函式),如果不手動破壞原型鏈,建構函式、原型、例項物件三者之間有預設的關聯。

物件的標識

在 Java 中,由於物件都是從對應的類例項化出來的,因此類本身就可以做為物件的標識,用於區分不同物件是否同屬一個類的例項。運算子是 instanceof。

在 JavaScript 中,雖然也有 instanceof 運算子,但由於並沒有類的概念,雖然有類似的建構函式、原型的概念存在,但由於這些本質上也都是物件,所以很難有某個唯一的標識可以來區分 JavaScript 的物件。

下面從多種思路著手,講解如何區分物件:

instanceof

在 Java 中,可以通過 instanceof 運算子來判斷某個物件是否是從指定類例項化出來的,也可以用於判斷一群物件是否屬於同一個類的例項。

在 JavaScript 中有些區別,但也有些類似:

var b = {}
function A() {}
A.prototype = b;
var a = new A();
if (a instanceof A) { //符合,因為 a 是從A例項化的,繼承自A.prototype即b
    console.log("true"); 
}

function B() {}
B.prototype = b;
var c = new B();
if (c instanceof A) {//符合,雖然c是從B例項化的,但c也同樣繼承自b,而A.prototype指向b,所以滿足
    console.log("true");
}
if (c instanceof Object) {//符合,雖然 c 是繼承自 b,但 b 繼承自 Object.prototype,所以c的原型鏈中有 Object.prototype
    console.log("true");
}

在 JavaScript 中,instanceof 運算子的左側是物件,右側是建構函式。但他們的判斷是,只要左側物件的原型鏈中包括右側建構函式的 prototype 指向的原型,那麼條件就滿足,即使左側物件不是從右側建構函式例項化的物件。

也就是說,在 JavaScript 中,判斷某些物件是否屬於同一個類的例項,不是根據他們是否是從同一個建構函式例項化的,而是根據他們的建構函式的 prototype 指向是不是相同的。

通過這種方式來區分物件有點侷限是:在瀏覽器中多個視窗裡,每個視窗的上下文都是相互獨立的,無法相互比較。

isPrototypeOf

instanceof 是判斷的物件和建構函式兩者間的關係,但本質上是判斷物件與原型的關係,只是剛好通過建構函式的 prototype 屬性值做中轉。

那麼,是否有可以直接判斷物件和原型兩者的操作呢?

這個就是 isPrototypeOf 的用法了:左邊是原型物件,右邊是例項物件,用於判斷左邊的原型是否在右邊例項物件的原型鏈當中:

Object.prototype.isPrototypeOf(b);

但它跟 instanceof 有個本質上的區別,instanceof 是運算子,而 isPrototypeOf 是 Object.prototype 中的方法,由於基本所有物件都繼承自這個,所以基本所有物件都可以使用這個方法。

instanceof 和 isPrototypeOf 更多使用的場景是用於判斷語句中,如果需要主動對某個物件獲取它的一些標識,可以使用接下來介紹的幾種方式:

typeof

在 JavaScript 中資料型別大體上分兩類:原始型別和引用型別。

原始型別對應的值是原始值,引用型別對應的值為物件。

對於原始值而言,使用 typeof 運算子可以獲取原始值所屬的原始型別。

對於函式物件,也可以使用 typeof 運算子來區分:

typeof

所以它的侷限也很大,基本只能用於區分原始值的標識,對於物件,自定義物件,它的結果都是 object,無法進行區分。

物件的類屬性

在物件一節中,介紹過,物件有一個類屬性,其實也就是通過 Object.prototype.toString() 方法可以獲取包含原始型別和引用型別名稱的字串,對其進行擷取可以獲取類屬性。

物件類屬性

相比於 typeof,它的好處在於可以區別所有的資料型別的本質,包括內建引用物件(陣列、函式、正則等),也可以區分 null。

侷限在於,需要自己封裝個工具方法獲取類屬性,但這不是難點,問題在於,對於自定義的建構函式,都是返回 Function,而很多物件其實是通過建構函式建立出來的,所以無法區分不同的建構函式所建立的物件。

constructor 的 name 屬性

constructor 是物件的一個屬性,它的值是繼承自原型的取值。而原型該屬性的取值,在不手動破壞物件的原型鏈情況下,為建立物件的建構函式。

即,預設情況下,建構函式的 prototype 指向原型,原型的 constructor 指向建構函式,那麼從該建構函式建立的物件都繼承了原型的這個屬性可指向建構函式。

所以,在這些場景下,可用物件的 constructor.name 來獲取建構函式的函式名,用函式名作為物件的標識。

function A(){}   //定義建構函式A
var a = new A();
var b = {};

函式名

這種方式有個侷限,如果手動修改建構函式的 prototype,破壞了物件的原型鏈,那麼此時,新建立的物件的 constructor 就不是指向建立它的建構函式了,此時,這種方式就無法處理了。

由於 JavaScript 不像 Java 這種靜態的類結構語言,所以沒有一種完美的方式適用於各自場景中來區分物件的標識,只能是在適用的場景選擇適合的方式。

所以,在 JavaScript 有一種程式設計理念:鴨式辯型

鴨式辯型

我不是很理解中文翻譯為什麼是這個詞,應該是某個英文詞直譯過來的。

它的理念是:像鴨子一樣走路、游泳、嘎嘎叫的鳥就稱它為鴨子。

通俗點說,程式設計時,不關心物件所屬的標識,不關心物件繼承自哪個原型、由哪個建構函式建立,只要這個物件含有相同的屬性、行為,那麼就認為它們歸屬於同一類。

有個例子就是:類陣列物件,它本質並不是陣列物件,但由於具有陣列物件的特徵,所以基本上可以把它當做陣列來使用。

對應到程式設計中,不應用判斷物件是否擁有相同的標識來區分物件,而是應該判斷物件是否含有期望的屬性即可。


大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),公眾號中有我的聯絡方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~
dasuAndroidTv2.png

相關文章