在JS中,類是後來才出的概念,早期創造物件的方式是new Function()
呼叫建構函式建立函式物件;
而現在,可以使用new className()
構造方法來建立類物件了;
所以在很多方面,類的使用方式,很像函式的使用方式:
但是類跟函式,還是有本質區別的,這在原型那裡已經說過,不再贅述;
如何定義一個類
如下所示去定義一個類:
class className {
// 屬性properties
property1 = 1;
property2 = [];
peoperty3 = {};
property4 = function() {};
property5 = () => {};
// 構造器
constructor(...args) {
super();
// code here
};
// 方法methods
method1() {
// code here
};
method2(...args) {
//code here
};
}
可以定義成員屬性和成員方法以及構造器,他們之間都有封號;
隔開;
在透過new className()
建立物件obj
的時候,會立即執行構造器方法;
屬性會成為obj
的屬性,句式為賦值語句,就算等號右邊是函式,它也依然是一個屬性,注意與方法宣告語句區別開;
方法會成為obj
的原型裡的方法,即放在className.prototype
屬性裡;
像使用function
一樣使用class
關鍵字
正如函式表示式一樣,類也有類表示式:
還可以像傳遞一個函式一樣,去傳遞一個類:
這在Java中是不可想象的,但是在JS中,就是這麼靈活;
靜態屬性和靜態方法
靜態屬性和靜態方法,不會成為物件的屬性和方法,永遠都屬於類本身,只能透過類去呼叫;
-
定義語法
// 直接在類中,透過static關鍵字定義 class className { static property = ...; static methoed() {}; } // 透過類直接新增屬性和方法,即為靜態的 class className {}; className.property = ...; className.method = function() {};
-
呼叫語法
類似於物件呼叫屬性和方法,直接透過類名去呼叫
className.property; className.method();
靜態屬性/方法,可以和普通屬性/方法同名,這不會被弄混,因為他們的呼叫者不一樣,前者是類,後者是類物件;
私有屬性和私有方法
JS新增的私有特性,在屬性和方法之前新增#
號,使其只在類中可見,物件無法呼叫,只能透過類提供的普通方法去間接訪問;
-
定義和呼叫語法
class className { // 定義,新增#號 #property = ...; #method() {}; // 只能在類中可見,呼叫也需要加#號 getProperty() { return this.#property; } set property(value) { this.#property = value; } }
注意,#property
是一個總體作為屬性名,與property
是不同的,#method
同理;
在這個私有特性之前,JS採用人為約定的方式,去間接實現私有;
在屬性和方法之前新增下劃線_
,約定這樣的屬性和方法,只能在類中可見,只能靠人為遵守這樣的約定;
類檢查instanceof
我們知道,可以用typeof
關鍵字來獲取一個變數是什麼資料型別;
現在可以用instanceof
關鍵字,來判斷一個物件是什麼類的例項;
語法obj instanceof className
,會返回一個布林值:
- 如果
className
是obj
原型鏈上的類,返回true; - 否則,返回false;
它是怎麼去判斷的呢?假設現在有如下幾個類:
class A {};
class B extends A {};
class C extends B {};
let c = new C();
c的原型是C.prototype
;
C.prototype
的原型是B.prototype
;
B.prototype
的原型是A.prototype
;
A.prototype
的原型是Object.prototype
;
Object.prototype
的原型是null;
原型鏈如上所示;
當我們執行c instanceof A
的時候,它是這樣的過程:
c.__proto__ === A.prototype
?否,則繼續;
c.__proto__.__proto__ === A.prototype
?否,則繼續;
c.__proto__.__proto__.__proto__ === A.prototype
?是,返回true;
如果一直否的話,這個過程會持續下去,直到將c
的原型鏈溯源到null,全都不等於A.prototype
,則返回false;
也就是說,instanceof關鍵字,比較的是物件的原型鏈上的原型和目標類的prototype是否相等(原型和prototype裡有constructor
,但是instanceof不會比較構造器是否相等,只會比較隱藏屬性[[Prototype]]
);
靜態方法Symbol.hasInstance
大多數類是沒有實現靜態方法[Symbol.hasInstance]
的,如果有一個類實現了這個靜態方法,那麼instanceof關鍵字會直接呼叫這個靜態方法;
如果類沒有實現這個靜態方法,那麼則會按照上述說的流程去檢查;
class className {
static [Symbol.hasInstance]() {};
}
objA.isPrototypeOf(objB)
isPrototypeOf()
方法,會判斷objA
的原型是否處在objB
的原型鏈中,如果在則返回true,否則返回false;
objA.isPrototypeOf(objB)
就相當於objB instanceof classA
;
反過來,objB instanceof classA
就相當於classA.prototype.isPrototypeOf(objB)
;
繼承
我們知道,JS的繼承,是透過原型來實現的,現在結合原型來說一下類的繼承相關內容。
關鍵字extends
JS中表示繼承的關鍵字是extends
,如果classA extends classB
,則說明classA
繼承classB
,classA
是子類,classB
是父類;
原型高於extends
時刻記住,JS的繼承,是依靠原型來實現的;
關鍵字extends
雖然確立了兩個類的父子關係,但是這只是一開始確立子類的父原型;
但是父原型是可以中途被修改的,此時子類呼叫方法,是沿著原型鏈去尋找的,而不是沿著子類父類的關鍵字宣告去尋找的,這和Java是不一樣的:
如圖所示,C extends A
確立了C一開始的父原型是A.prototype
,c.show()
呼叫的也是父類A
的方法;
但是後面修改c
的父原型為B.prototype
,c.show
呼叫的就不是父類A
的方法,而是父原型的方法;
也就是說,原型才是核心,高於extends
關鍵字;
基類和派生類
class classA {};
class classB extends classA {};
像classA
這樣沒有繼承任何類(實際上父原型是Object.prototype
)的類稱為基類;
像classB
這樣繼承classB
的類,稱為classB
的派生類;
為什麼要分的這麼細,是因為在建立類時,他們兩個的行為不同,後面會說到;
類的原型
類本身也是有原型的,就像類物件有原型一樣;
可以看到,B
的原型就是其父類A
,而A
作為基類,基類的原型是本地方法;
正因如此,B
可以透過原型去呼叫A
的靜態方法/屬性;
也就是說,靜態方法/屬性,也是可以繼承的,透過類的原型去繼承;
類物件的原型和類的prototype屬性
在建立類物件的時候,會將類的prototype屬性值複製給類物件的原型;
所以說,類物件的原型等於類的prototype屬性值;
而類的prototype屬性,預設就有兩個屬性:
- 構造器constructor:指向類本身;
- 原型[[Prototype]]:指向父類的prototype屬性;
以及
- 類的普通方法;
從上圖中可以看出,A
的prototype屬性裡,除構造器和原型以外,就只有一個普通方法show()
;
這說明,只有類的普通方法,會自動進入類的prototype
屬性參與繼承;
也就是說,一個類物件的資料結構,如下:
- 普通屬性
- (原型)prototype屬性
- 構造器
- 父類的prototype屬性(父原型)
- 方法
另外,類的prototype
屬性是不可寫的,但是類物件的原型則是可以修改的;
繼承了哪些東西
當子類去繼承父類的時候,到底繼承到了父類的哪些東西,也即子類可以用父類的哪些內容;
從結果上來看,我們可以確定如下:
- 子類繼承父類的靜態屬性/方法(基於類的原型);
- 子類物件繼承父類的普通方法和構造器(基於類的prototype);
- 子類直接將父類的普通屬性作為自己的普通屬性(普通屬性不參與繼承);
由於原型鏈的存在,這些繼承會一路沿著原型鏈回溯,繼承到所有祖宗類;
同名屬性的覆蓋
由於繼承的機制,勢必子類和父類可能會有同名屬性的存在:
從結果上可以看到,雖然子類直接將父類的普通屬性作為自己的普通屬性,但是當出現同名屬性,屬性值會進行覆蓋,最終的值採用子類自己定義的值;
同名方法的重寫
與屬性一樣,子類和父類也可能會出現同名方法;
當然大多數情況下,是我們自己要擴充方法功能而故意同名,從而重寫父類的方法;
如上所示,我們重寫了父類的靜態方法和普通方法;
如果是重寫構造器的話,分兩種情況:
// 基類重寫構造器
class A {
constructor() {
code...
}
}
// 派生類重寫構造器
class B extends A() {
constructor() {
// 一定要先寫super()
super();
code...
}
}
子類的呼叫順序
從上圖還可以看出來,子類呼叫方法的順序:
- 先從自己的方法裡呼叫,發現沒有可呼叫的方法時;
- 再沿著原型鏈,先從父類開始尋找方法,一直往上溯源,直到找到可呼叫的方法,或者沒有而出錯;
super關鍵字
類的方法裡,有一個特殊的、專門用於super
關鍵字的特殊屬性[[HomeObject]]
,這個屬性繫結super
語句所在的類的物件,不會改變;
而super
關鍵字,則指向[[HomeObject]]
繫結的物件的類的父類的prototype
;
這要求,super
關鍵字用於派生類類的方法裡,基類是不可以使用super
的,因為沒有父類;
當我們使用super
關鍵字時,藉助於[[HomeObject]]
,總是能夠正確重用父類方法;
如上,super
語句所在的類為B
,其物件為b
,即[[HomeObject]]
繫結b
;
而super
則指向b
的類的父原型,即A
的prototype屬性;
而super.show()
就類似於A.prototype.show()
,故而最終結果如上所示;
可以簡單理解成,super指向子類物件的父類的prototype
;
構造器constructor
終於說到構造器了,理解了構造器的具體建立物件的過程,我們就能理解關於繼承的很多內容了;
先來看一下基類的構造器建立物件的過程:
執行let a = new A()
時,大致流程如下:
- 首先呼叫
A.prototype
的特性[[Prototype]]
建立一個字面量物件,同時this
指標指向這個字面量物件; - 然後執行類
A()
的定義,A
定義的普通屬性成為字面量物件的屬性並初始化,A.prototype
的value
值複製給字面量物件的隱藏屬性[[Prototype]]
; - 然後再執行
constructor
構造器,沒有構造器就算了; - 返回
this
指標給變數a
,即a
此時引用該字面量物件了;
從結果上看,在執行構造器時,字面量物件就已經有原型了,以及屬性name
,且值初始化為tomA
;
然後才對屬性name
重新賦值為jerryA
;
然而,構造器中對屬性的重新賦值,從一開始就決定好了,只是在執行到這句賦值語句之前,暫存在字面量物件中;
現在再來看一下派生類建立物件的過程;
執行let b = new B()
的大致流程如下:
- 首先呼叫
B.prototype
的特性[[Prototype]]
建立一個字面量物件,同時this
指標指向這個字面量物件; - 然後執行類
B()
的定義,B
定義的普通屬性成為字面量物件的屬性並初始化,B.prototype
的value
值複製給字面量物件的隱藏屬性[[Prototype]]
; - 然後再執行
constructor
構造器(沒有顯式定義構造器會提供預設構造器),第一句super()
,開始進入類A()
的定義;- 暫存
B
的屬性值,轉而賦值為A
定義的值,A.prototype
的value
值複製給B.__proto__
的隱藏屬性[[Prototype]]
; - 然後執行
constructor
構造器(基類沒有構造器就算了); - 返回
this
指標; - 丟棄
A
賦值的屬性值,重新使用暫存的B
的屬性值;
- 暫存
- 繼續執行
constructor
構造器剩下的語句; - 返回
this
指標給變數b
,即b
引用該字面量物件了;
透過基類和派生類建立物件的流程對比,可以發現主要區別在於類的屬性的賦值上;
屬性值從一開始就已經暫存好:
- 如果構造器
constructor
中有賦值,則暫存這個值; - 如果構造器沒有,則暫存類定義中的值;
- 不管父類及其原型鏈上同名的屬性在中間進行過幾次賦值,最終都會重新覆蓋為最開始就暫存好的值;