首頁 > 軟體

如何利用JavaScript 實現繼承

2022-02-18 10:02:10

一、背景簡介

JavaScript 在程式語言界是個特殊種類,它和其他程式語言很不一樣,JavaScript 可以在執行的時候動態地改變某個變數的型別。

比如你永遠也沒法想到像isTimeout這樣一個變數可以存在多少種型別,除了布林值truefalse,它還可能是undefined、1和0、一個時間戳,甚至一個物件。

如果程式碼跑異常,開啟瀏覽器,開始斷點偵錯,發現InfoList這個變數第一次被賦值的時候是個陣列:

[{name: 'test1', value: '11'}, {name: 'test2', value: '22'}]

過了一會竟然變成了一個物件:

{test1:'11', test2: '22'}

除了變數可以在執行時被賦值為任何型別以外,JavaScript 中也能實現繼承,但它不像 Java、C++、C# 這些程式語言一樣基於類來實現繼承,而是基於原型進行繼承。

這是因為 JavaScript 中有個特殊的存在:物件。每個物件還都擁有一個原型物件,並可以從中繼承方法和屬性。

提到物件和原型,有如下問題:

  • JavaScript 的函數怎麼也是個物件?
  • protoprototype到底是啥關係?
  • JavaScript 中物件是怎麼實現繼承的?
  • JavaScript 是怎麼存取物件的方法和屬性的?

二、原型物件和物件的關係

在 JavaScript 中,物件由一組或多組的屬性和值組成:

{
  key1: value1,
  key2: value2,
  key3: value3,
}

JavaScript 中,物件的用途很是廣泛,因為它的值既可以是原始型別(number、string、boolean、null、undefined、bigint和symbol),還可以是物件和函數。

不管是物件,還是函數和陣列,它們都是Object的範例,也就是說在 JavaScript 中,除了原始型別以外,其餘都是物件。

這也就解答了問題1:JavaScript 的函數怎麼也是個物件?

在 JavaScript 中,函數也是一種特殊的物件,它同樣擁有屬性和值。所有的函數會有一個特別的屬性prototype,該屬性的值是一個物件,這個物件便是我們常說的“原型物件”。

我們可以在控制檯列印一下這個屬性:

function Person(name) {
  this.name = name;
}
console.log(Person.prototype);

列印結果顯示為:

可以看到,該原型物件有兩個屬性:constructorproto

到這裡,我們彷彿看到疑惑 “2:proto和prototype到底是啥關係?”的答案要出現了。在 JavaScript 中,proto屬性指向物件的原型物件,對於函數來說,它的原型物件便是prototype

函數的原型物件prototype有以下特點:

  • 預設情況下,所有函數的原型物件(prototype)都擁有constructor屬性,該屬性指向與之關聯的建構函式,在這裡建構函式便是Person函數;
  • Person函數的原型物件(prototype)同樣擁有自己的原型物件,用proto屬性表示。前面說過,函數是Object的範例,因此Person.prototype的原型物件為Object.prototype。

我們可以用這樣一張圖來描述prototype、proto和constructor三個屬性的關係:

從這個圖中,我們可以找到這樣的關係:

  • 在 JavaScript 中,proto屬性指向物件的原型物件;
  • 對於函數來說,每個函數都有一個prototype屬性,該屬性為該函數的原型物件;

二、使用 prototype 和 proto 實現繼承

物件之所以使用廣泛,是因為物件的屬性值可以為任意型別。因此,屬性的值同樣可以為另外一個物件,這意味著 JavaScript 可以這麼做:通過將物件 A 的proto屬性賦值為物件 B,即:

A.__proto__ = B

此時使用A.proto便可以存取 B 的屬性和方法。

這樣,JavaScript 可以在兩個物件之間建立一個關聯,使得一個物件可以存取另一個物件的屬性和方法,從而實現了繼承;

三、使用prototype和proto實現繼承

Person為例,當我們使用new Person()建立物件時,JavaScript 就會建立建構函式Person的範例,比如這裡我們建立了一個叫“zhangsan”的Person:

var zhangsan = new Person("zhangsan");

上述這段程式碼在執行時,JavaScript 引擎通過將Person的原型物件prototype賦值給範例物件zhangsan的proto屬性,實現了zhangsan對Person的繼承,即執行了以下程式碼:

//JavaScript 引擎執行了以下程式碼
var zhangsan = {};
zhangsan.__proto__ = Person.prototype;
Person.call(zhangsan, "zhangsan");

我們來列印一下zhangsan範例:

console.log(zhangsan)

結果如下圖所示:

可以看到,zhangsan作為Person的範例物件,它的proto指向了Person的原型物件,即Person.prototype

這時,我們再補充下上圖中的關係:

從這幅圖中,我們可以清晰地看到建構函式和constructor屬性、原型物件(prototype)和proto、範例物件之間的關係,這是很多容易混淆。根據這張圖,我們可以得到以下的關係:

  • 每個函數的原型物件(Person.prototype)都擁有constructor屬性,指向該原型物件的建構函式(Person);
  • 使用建構函式(new Person())可以建立物件,建立的物件稱為範例物件(lily);
  • 範例物件通過將proto屬性指向建構函式的原型物件(Person.prototype),實現了該原型物件的繼承。

那麼現在,關於proto和prototype的關係,我們可以得到這樣的答案:

  • 每個物件都有proto屬性來標識自己所繼承的原型物件,但只有函數才有prototype屬性;
  • 對於函數來說,每個函數都有一個prototype屬性,該屬性為該函數的原型物件;
  • 通過將範例物件的proto屬性賦值為其建構函式的原型物件prototype,JavaScript 可以使用建構函式建立物件的方式,來實現繼承。

所以一個物件可通過proto存取原型物件上的屬性和方法,而該原型同樣也可通過proto存取它的原型物件,這樣我們就在範例和原型之間構造了一條原型鏈。紅色線條所示:

四、通過原型鏈存取物件的方法和屬性

當 JavaScript 試圖存取一個物件的屬性時,會基於原型鏈進行查詢。查詢的過程是這樣的:

  • 首先會優先在該物件上搜尋。如果找不到,還會依次層層向上搜尋該物件的原型物件、該物件的原型物件的原型物件等(套娃告警);
  • JavaScript 中的所有物件都來自ObjectObject.prototype.proto === null。null沒有原型,並作為這個原型鏈中的最後一個環節;
  • JavaScript 會遍歷存取物件的整個原型鏈,如果最終依然找不到,此時會認為該物件的屬性值為undefined。

我們可以通過一個具體的例子,來表示基於原型鏈的物件屬性的存取過程,在該例子中我們構建了一條物件的原型鏈,並進行屬性值的存取:

var o = {a: 1, b: 2}; // 讓我們假設我們有一個物件 o, 其有自己的屬性 a 和 b:
o.__proto__ = {b: 3, c: 4}; // o 的原型 o.__proto__有屬性 b 和 c:

當我們在獲取屬性值的時候,就會觸發原型鏈的查詢:

console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined

綜上,整個原型鏈如下:

{a:1, b:2} ---> {b:3, c:4} ---> null, // 這就是原型鏈的末尾,即 null

可以看到,當我們對物件進行屬性值的獲取時,會觸發該物件的原型鏈查詢過程。

既然 JavaScript 中會通過遍歷原型鏈來存取物件的屬性,那麼我們可以通過原型鏈的方式進行繼承。

也就是說,可以通過原型鏈去存取原型物件上的屬性和方法,我們不需要在建立物件的時候給該物件重新賦值/新增方法。比如,我們呼叫lily.toString()時,JavaScript 引擎會進行以下操作:

  • 先檢查lily物件是否具有可用的toString()方法;
  • 如果沒有,則``檢查lily的原型物件(Person.prototype)是否具有可用的toString()方法;
  • 如果也沒有,則檢查Person()建構函式的prototype屬性所指向的物件的原型物件(即Object.prototype)是否具有可用的toString()方法,於是該方法被呼叫。

由於通過原型鏈進行屬性的查詢,需要層層遍歷各個原型物件,此時可能會帶來效能問題:

  • 當試圖存取不存在的屬性時,會遍歷整個原型鏈;
  • 在原型鏈上查詢屬性比較耗時,對效能有副作用,這在效能要求苛刻的情況下很重要。

因此,我們在設計物件的時候,需要注意程式碼中原型鏈的長度。當原型鏈過長時,可以選擇進行分解,來避免可能帶來的效能問題。

五、其他方式實現繼承

除了通過原型鏈的方式實現 JavaScript 繼承,JavaScript 中實現繼承的方式還包括經典繼承(盜用建構函式)、組合繼承、原型式繼承、寄生式繼承,等等。

  • 原型鏈繼承方式中參照型別的屬性被所有範例共用,無法做到範例私有;
  • 經典繼承方式可以實現範例屬性私有,但要求型別只能通過建構函式來定義;
  • 組合繼承融合原型鏈繼承和建構函式的優點,它的實現如下:
function Parent(name) {
  // 私有屬性,不共用
  this.name = name;
}
// 需要複用、共用的方法定義在父類別原型上
Parent.prototype.speak = function() {
  console.log("hello");
};
function Child(name) {
  Parent.call(this, name);
}
// 繼承方法
Child.prototype = new Parent();

組合繼承模式通過將共用屬性定義在父類別原型上、將私有屬性通過建構函式賦值的方式,實現了按需共用物件和方法,是 JavaScript 中最常用的繼承模式。

雖然在繼承的實現方式上有很多種,但實際上都離不開原型物件和原型鏈的內容,因此掌握protoprototype、物件的繼承等這些知識,是我們實現各種繼承方式的前提條件。

七、總結

關於 JavaScript 的原型和繼承,常常會在我們面試題中出現。隨著 ES6/ES7 等新語法糖的出現,可能更傾向於使用class/extends等語法來編寫程式碼,原型繼承等概念逐漸變淡。

其次JavaScript 的設計在本質上依然沒有變化,依然是基於原型來實現繼承的。如果不瞭解這些內容,可能在我們遇到一些超出自己認知範圍的內容時,很容易束手無策。

到此這篇關於如何利用JavaScript 實現繼承的文章就介紹到這了,更多相關JavaScript 實現繼承內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com