JavaScript:類(class)

Journing發表於2022-12-24

在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關鍵字

正如函式表示式一樣,類也有類表示式:

image-20221221213521212

還可以像傳遞一個函式一樣,去傳遞一個類:

image-20221221213652757

這在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,會返回一個布林值:

  • 如果classNameobj原型鏈上的類,返回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繼承classBclassA是子類,classB是父類;

原型高於extends

時刻記住,JS的繼承,是依靠原型來實現的;

關鍵字extends雖然確立了兩個類的父子關係,但是這只是一開始確立子類的父原型;

但是父原型是可以中途被修改的,此時子類呼叫方法,是沿著原型鏈去尋找的,而不是沿著子類父類的關鍵字宣告去尋找的,這和Java是不一樣的:

image-20221223233526394

如圖所示,C extends A確立了C一開始的父原型是A.prototypec.show()呼叫的也是父類A的方法;

但是後面修改c的父原型為B.prototypec.show呼叫的就不是父類A的方法,而是父原型的方法;

也就是說,原型才是核心,高於extends關鍵字;

基類和派生類

class classA {};
class classB extends classA {};

classA這樣沒有繼承任何類(實際上父原型是Object.prototype)的類稱為基類;

classB這樣繼承classB的類,稱為classB的派生類;

為什麼要分的這麼細,是因為在建立類時,他們兩個的行為不同,後面會說到;

類的原型

類本身也是有原型的,就像類物件有原型一樣;

image-20221223211958950

可以看到,B的原型就是其父類A,而A作為基類,基類的原型是本地方法;

正因如此,B可以透過原型去呼叫A的靜態方法/屬性;

也就是說,靜態方法/屬性,也是可以繼承的,透過類的原型去繼承;

類物件的原型和類的prototype屬性

在建立類物件的時候,會將類的prototype屬性值複製給類物件的原型;

所以說,類物件的原型等於類的prototype屬性值;

image-20221223214052596

而類的prototype屬性,預設就有兩個屬性:

  • 構造器constructor:指向類本身;
  • 原型[[Prototype]]:指向父類的prototype屬性;

以及

  • 類的普通方法;

從上圖中可以看出,A的prototype屬性裡,除構造器和原型以外,就只有一個普通方法show()

這說明,只有類的普通方法,會自動進入類的prototype屬性參與繼承;

也就是說,一個類物件的資料結構,如下:

  • 普通屬性
  • (原型)prototype屬性
    • 構造器
    • 父類的prototype屬性(父原型)
    • 方法

另外,類的prototype屬性是不可寫的,但是類物件的原型則是可以修改的;

繼承了哪些東西

當子類去繼承父類的時候,到底繼承到了父類的哪些東西,也即子類可以用父類的哪些內容;

image-20221223220502179

從結果上來看,我們可以確定如下:

  • 子類繼承父類的靜態屬性/方法(基於類的原型);
  • 子類物件繼承父類的普通方法和構造器(基於類的prototype);
  • 子類直接將父類的普通屬性作為自己的普通屬性(普通屬性不參與繼承);

由於原型鏈的存在,這些繼承會一路沿著原型鏈回溯,繼承到所有祖宗類;

同名屬性的覆蓋

由於繼承的機制,勢必子類和父類可能會有同名屬性的存在:

image-20221223221756177

從結果上可以看到,雖然子類直接將父類的普通屬性作為自己的普通屬性,但是當出現同名屬性,屬性值會進行覆蓋,最終的值採用子類自己定義的值;

同名方法的重寫

與屬性一樣,子類和父類也可能會出現同名方法;

當然大多數情況下,是我們自己要擴充方法功能而故意同名,從而重寫父類的方法;

image-20221223222233351

如上所示,我們重寫了父類的靜態方法和普通方法;

如果是重寫構造器的話,分兩種情況:

// 基類重寫構造器
class A {
    constructor() {
        code...
    }
}
    
// 派生類重寫構造器
class B extends A() {
    constructor() {
        // 一定要先寫super()
        super();
        code...
    }
}

子類的呼叫順序

從上圖還可以看出來,子類呼叫方法的順序:

  • 先從自己的方法裡呼叫,發現沒有可呼叫的方法時;
  • 再沿著原型鏈,先從父類開始尋找方法,一直往上溯源,直到找到可呼叫的方法,或者沒有而出錯;

super關鍵字

類的方法裡,有一個特殊的、專門用於super關鍵字的特殊屬性[[HomeObject]],這個屬性繫結super語句所在的類的物件,不會改變;

super關鍵字,則指向[[HomeObject]]繫結的物件的類的父類的prototype

這要求,super關鍵字用於派生類類的方法裡,基類是不可以使用super的,因為沒有父類;

當我們使用super關鍵字時,藉助於[[HomeObject]],總是能夠正確重用父類方法;

image-20221223225446030

如上,super語句所在的類為B,其物件為b,即[[HomeObject]]繫結b

super則指向b的類的父原型,即A的prototype屬性;

super.show()就類似於A.prototype.show(),故而最終結果如上所示;

可以簡單理解成,super指向子類物件的父類的prototype

構造器constructor

終於說到構造器了,理解了構造器的具體建立物件的過程,我們就能理解關於繼承的很多內容了;

先來看一下基類的構造器建立物件的過程:

image-20221224002125631

執行let a = new A()時,大致流程如下:

  • 首先呼叫A.prototype的特性[[Prototype]]建立一個字面量物件,同時this指標指向這個字面量物件;
  • 然後執行類A()的定義,A定義的普通屬性成為字面量物件的屬性並初始化,A.prototypevalue值複製給字面量物件的隱藏屬性[[Prototype]]
  • 然後再執行constructor構造器,沒有構造器就算了;
  • 返回this指標給變數a,即a此時引用該字面量物件了;

從結果上看,在執行構造器時,字面量物件就已經有原型了,以及屬性name,且值初始化為tomA

然後才對屬性name重新賦值為jerryA

然而,構造器中對屬性的重新賦值,從一開始就決定好了,只是在執行到這句賦值語句之前,暫存在字面量物件中;

現在再來看一下派生類建立物件的過程;

image-20221224005351505

執行let b = new B()的大致流程如下:

  • 首先呼叫B.prototype的特性[[Prototype]]建立一個字面量物件,同時this指標指向這個字面量物件;
  • 然後執行類B()的定義,B定義的普通屬性成為字面量物件的屬性並初始化,B.prototypevalue值複製給字面量物件的隱藏屬性[[Prototype]]
  • 然後再執行constructor構造器(沒有顯式定義構造器會提供預設構造器),第一句super(),開始進入類A()的定義;
    • 暫存B的屬性值,轉而賦值為A定義的值,A.prototypevalue值複製給B.__proto__的隱藏屬性[[Prototype]];
    • 然後執行constructor構造器(基類沒有構造器就算了);
    • 返回this指標;
    • 丟棄A賦值的屬性值,重新使用暫存的B的屬性值;
  • 繼續執行constructor構造器剩下的語句;
  • 返回this指標給變數b,即b引用該字面量物件了;

透過基類和派生類建立物件的流程對比,可以發現主要區別在於類的屬性的賦值上;

屬性值從一開始就已經暫存好:

  • 如果構造器constructor中有賦值,則暫存這個值;
  • 如果構造器沒有,則暫存類定義中的值;
  • 不管父類及其原型鏈上同名的屬性在中間進行過幾次賦值,最終都會重新覆蓋為最開始就暫存好的值;

相關文章