ES6中的class物件和它的家人們

繼君發表於2023-02-26

在ES6中新增了一個很重要的特性: class(類)。作為一個在2015年就出了的特性, 相信很多小夥伴對class並不陌生。但是在日常開發中使用class的頻率感覺並不高(可能僅限於作者),感覺對class總有種一知半解的感覺。今天就帶著小夥伴們一起,好好剖析剖析這個特性。

1.什麼是class

一個特性的誕生,總是為了解決某些問題的。而class的誕生還要從ES5中的建構函式說起。

在ES5中為了能更加高效的建立物件,使用了一種名為建構函式模式 的方式建立物件。建立方式如下

function Animal() {}  // 建構函式
const animal = new Animal()
// 透過new的方式建立一個新的物件, 該物件稱為建構函式的例項物件

我們發現上述的建構函式在定義的時候和普通的函式定義是一模一樣的。而事實上,上述所謂的建構函式本質就是一個函式, 只是這個函式的作用是用於建立物件。這導致建構函式和普通的函式難以區分, 這是ES5建構函式的一個弊端。另一個問題是, ES5中的構造函在實現繼承的時候,程式碼冗長且混亂(下文中有舉例說明)。在這樣的背景下class誕生了。

什麼是class:ES5中的建構函式的語法糖,本質還是一個函式物件。用於高效的建立物件或實現繼承。

2.建立一個class

和ES5中的建構函式一樣,class也有兩種建立方式:類宣告和類表示式。我們分別的列舉下

image.png

上圖中左右加起來共四種建立類的方式,其產生的結果基本是一致的。其中需要注意的是,左側的函式式宣告會存在函式提升的的過程,而類宣告的方式不會進行提升。舉例來說

const animal = new Animal()  // 可以成功建立例項
function Animal() {}

const animal = new Animal()  //拋錯:Cannot access 'Animal' before initialization(不允許初始化前建立例項)
class Animal {}

而至於 左側的變數式宣告 和 右側的類表示式宣告,由於都是使用一個變數進行接收,所以都受變數提升影響。

3.class物件和他們的家人們

和class直接相關的有三個物件,分別是:例項物件(以下統稱為例項), 類物件(定義一個類,類本身就是一個物件),原型物件。這三個物件是怎麼建立和使用的呢?三個物件之間有什麼關係呢?接下來將分別闡述他們。

3.1 例項物件

根據類,使用new關鍵字建立的物件,稱之為這個類的例項。與之對應有兩個成員:例項屬性和例項方法(定義在例項自身的屬性和方法,以下統稱為例項成員)。該怎麼定義這兩種成員呢,有如下兩種方式:

方式一: 例項建立之後,手動新增屬性和方法

class Animal {}

const animal = new Animal()
animal.name = 'lsm'    // 新增屬性
animal.move = () => {  // 新增方法
  console.log('moving ...')
}

但是這種方式,有個最大的弊端:當有些屬性和方法需要每個例項都要有的時候,需要每次建立完例項之後都新增一遍。程式碼冗餘度非常高, 並且要是都這樣寫class就失去了它的意義。

如果我們想在建立例項的時候就建立這些成員該怎麼做呢?要實現這一點,需要提前在類中定義好這些成員。來看方式二。

方式二:在類中定義例項成員

想要在類中定義例項成員我們就要用到一個函式:constructor。那首先來了解下constructor吧。

  • constructor是什麼:一個方法。定義在每個類物件的原型物件上(這兩個物件將在3.2、3.3中進行講解)。可以在類的程式碼塊中進行重寫。

  • constructor作用:初始化例項物件。

  • constructor引數:接收建立例項時傳遞進來的實參,用於初始化例項成員。

  • constructor特性:constructor函式體中的this指向例項,所以我們給this新增的成員,實際上就是新增在例項上。換而言之,給該this新增的成員就是例項成員。最終返回this。

  • constructor呼叫時機:在我們透過new關鍵字建立例項的時候,預設的會呼叫定義在類中constructor函式。如果在類中沒有顯示的定義constructor函式,則會呼叫類的原型物件上的constructor函式。

瞭解了constructor函式,接下用一組程式碼對上述的總結進行闡述

class Animal {
  // 重寫了Animal原型物件上的constructor方法
  constructor(name) {   // 接收的name引數,用於初始化例項的name屬性
    console.log("new 關鍵字呼叫")
    this.name = name    // 給例項新增name屬性並賦值
    this.move = function(speed) {   // 給例項新增move方法
      console.log('moving speed ' + speed + ' m/s')
    }
  }
}

const animal = new Animal('lsm')  //new 關鍵字呼叫
console.log(animal.name)  // lsm
animal.move(10) // moving spead 10 m/s

在上述的案例中,我們瞭解瞭如何透過class定義一個例項成員。針對於方式一, 實際上就是在給一個普通的物件新增屬性和方法,如果我們想在某個例項上加上獨屬於自身的成員,就可以使用方式一。

3.2 類物件

類是什麼?在上述對類的定義中是這樣定義的:ES5中的建構函式的語法糖,本質還是一個函式物件。 總結來說類是一個函式,驗證的方式很簡單: typeof關鍵字

class Animal {}
console.log(typeof Animal) // funtion
const animal = new Animal()
Animal()  // Uncaught TypeError: Class constructor Animal cannot be invoked without 'new'

雖然類本質的是個函式,但是我們並不能像呼叫函式那樣呼叫它,像Animal()就會報錯, 需要透過new關鍵字進行呼叫, 。

明白了類其實是一個函式物件,那麼怎麼給這個物件新增成員呢?其實我們可以像給一個普通的物件新增成員一樣給類物件新增成員, 就像下面這樣:

class Animal {}
Animal.age = 25;
Animal.move = () => {
  console.log("moving ...");
};

console.log(Animal.age)  // 25
Animal.move()  // moving ...

但class作為ES5中建構函式的語法糖,ES6中對這種給類物件新增成員的需求提供了一種新的方式:將需要新增的類成員直接定義在類程式碼塊中,並在定義的成員前面新增static修飾符。我們將這種透過static修飾的成員稱之為靜態成員。 具體的實現如下:

class Animal {
  static age = 25
  static move() {
    console.log('moving ...')
  }
}

console.log(Animal.age)  // 25
Animal.move()   // moving ...

透過上述兩個案例可以看出,雖然定義類成員的方式不同,但使用類成員的方式並沒有區別。從結果而言,上述的兩種定義類成員的方式是完全等價的。

針對於上述的案例我們不妨總結一下什麼是靜態成員:給類物件自身新增的成員稱之為靜態成員。在ES6中提供了使用static修飾符建立靜態成員的方式。

知道了什麼是靜態成員,那靜態成員有什麼用呢?其實靜態成員最主要的作用就是:脫離例項。建立與類本身強繫結的成員。 總的來說就是我想建立一些屬性和方法, 但是這些屬性和方法並不需要建立例項就能呼叫或者和例項本身就沒啥關係。這句話可能不好理解,我用兩個例子來說明下。

  1. Math.PI、Math.random(): Math中的這些成員都是靜態成員。透過建立例項的方式去呼叫這些成員是毫無意義的(實際上也不能),因為這些屬性的值或方法的結構全都是固定的。
  2. Array.isArray(): 這個方法的作用是判斷所有型別的物件是不是陣列,這和陣列的例項沒有一毛錢關係。
  3. ......

看到這,如果是細心的小夥伴,可能就會產生一些疑問:

  1. 為什麼ES6中新增靜態成員的時候需要新增static修飾符?
  2. 如果不加static修飾符,這個成員就不是靜態成員了嗎?
  3. 如果問題2成立,在3.1講述constructor的時候,constructor這個函式是直接定義在class程式碼塊中的,沒有新增static,那我們建立例項的時候呼叫的constructor函式又是屬於哪個物件的?

在回答這三個問題之前,我想重新帶大家複習一遍,和class直接相關的三個物件:例項物件, 類物件,原型物件。這很重要!!!

我帶大家首先驗證一下問題2,下述程式碼會用到一個新的API:hasOwnProperty

hasOwnProperty方法的作用:可以檢測一個成員是否存在於物件自身中(不包括原型),返回布林值。只有當成員存在於物件自身時才會返回true,否則返回false

class Animal {
  static age = 25  // 靜態屬性
  static move() {  // 靜態方法
    console.log('moving ...')
  }
  speed = 10  // 普通的屬性
  constructor() {}  // 構造方法
}

console.log(Animal.hasOwnProperty('age'))  // true
console.log(Animal.hasOwnProperty('move'))  // true
console.log(Animal.hasOwnProperty('speed'))  // false
console.log(Animal.hasOwnProperty('constructor')) // false

透過上述的測試,可以發現透過static修飾的成員確實屬於類物件本身。而沒有static修飾的成員則不屬於類物件本身。這就是問題2的答案。而至於這些沒有satic修飾的成員到底屬於哪個物件,將在3.4中進行總結歸納。

至於問題一的答案其實很簡單:為了區分類物件自身的成員和其他成員。 可能有一些小夥伴對作者提出的問題一,覺得莫名其妙。其實這裡作者是想加固小夥伴的認知:所謂static靜態成員,就是在類物件本身的一個成員而已, static只是一個語法糖。

回到問題3,我們現在可以確認,constructor這個函式並不屬於類物件, 那具體屬於哪個物件?問題的答案就是原型物件

3.3原型物件

瞭解一個新的東西大概總是從這幾個方面入手的:是什麼?怎麼用?存在意義?

3.3.1 是什麼:一個物件。會伴隨類的宣告而建立的一個物件。類中透過prototype屬性指向的一個物件。舉例如下:
class Animal {}
console.log(Animal.prototype)  // 列印結果如下圖

image.png

預設情況下,該物件中只有一個constructor屬性。上述案例的結果也證實了3.2中問題3的答案。也就是說透過new關鍵字建立一個物件的時候, 無論有沒有在類中顯示的宣告constructor,呼叫的始終都是原型物件中的constructor方法。 並且針對上圖中的列印結果我們發現一個有意思的點,原型物件上的constructor是一個屬性,該屬性指向的是類物件本身。 我們不妨列印看看

console.log(Animal.prototype.constructor === Animal)  // true

結果為true。看到這,有些小夥伴可能就迷惑了,constructor不是一個用於初始化例項的函式嗎?現在怎麼又變成了一個屬性? 並且還指向類本身? 這都是些什麼亂七八糟的。

首先, constructor這個屬性指向的是類本身,而類本身就是一個函式,所以說constructor是一個函式並沒有問題。其次,我們已經知道透過new關鍵字建立物件,最終呼叫的就是constructor, 而constructor指向的又是類本身,所以真正去建立物件的還是類本身。那為什麼要繞這麼一圈, 而不直接使用類本身建立物件。原因有如下兩點:

  • constructor的作用是什麼:初始化例項物件。我們要明白,在我們呼叫new關鍵字的那一刻就已經建立了一個物件,而constructor僅僅是初始化了這個物件,初始化完成再返回這個物件。我們可以簡單的將constructor當成一個入口, 供開發者初始化例項的入口。所以我們需要呼叫constructor而不是直接呼叫類
  • 我們知道所有的陣列都是Array類的例項, 那是怎麼確定的呢?就是透過constructor。正是透過constructor的指向,我們才能確定例項物件屬於哪個類(實現原理會在3.5中詳講)。這也是為什麼需要讓constructor指向類本身。

言歸正傳,瞭解了原型物件是什麼,接下來說說,具體怎麼用。

3.3.2 怎麼用

我們知道直接定義類程式碼塊中的 constructor,其實最終是定義在原型物件上的。我們可以進行一波猜測:直接定義在類程式碼塊中的成員就是原型物件上的成員,用程式碼驗證一波。

class Animal {
  move() {
    console.log('moving ...')
  }
  name = 'cat'
}

console.log(Animal.prototype.hasOwnProperty("move")) // true
console.log(Animal.prototype.hasOwnProperty("name")) // false

有意思的是,我們發現定義在類程式碼塊中的方法確實是原型方法,但是定義在類程式碼塊中的屬性卻不是。不是話又屬於誰,接著驗證。

const animal = new Animal()
console.log(Animal.hasOwnProperty("myName"))  // false
console.log(animal.hasOwnProperty("myName"))  // true

經過驗證我們發現,直接定義在類程式碼塊中的屬性是例項屬性。其實這個例項屬性並沒有多大意義,因為我們已經知道了可以在constructor初始化例項成員。所以在開發中這種定義方式相當少。

那為什麼在設計的時候,將直接定義在類程式碼塊中的屬性當成是例項屬性而不是原型屬性呢?這就要牽扯到原型物件存在的意義了

3.3.3 存在的意義

我們先看一段簡單程式碼

class Animal {
  constructor(name) {
    this.name = name
    this.move = () => {
      console.log(this.name + ' moving ...')
    }
  }
}

const animal = new Animal(cat)
animal.move() // cat moving ...
const animal1 = new Animal(cat2)
animal.move() // cat2 moving ...

這段程式碼很簡單,就是建立了一個類和兩個類的例項。這段程式碼有問題嗎,邏輯上來說沒有問題,但是有一個弊端,就是在對方法的處理上冗餘度過高。上述程式碼中, 我們每建立一個例項,就會給這個例項新增一個move方法。但是move方法裡面的處理邏輯是完全相同的, 如果大量的建立物件,將會佔用大量的記憶體空間,浪費資源。

而解決這個問題就是原型物件最重要的責任之一。我們可以將一些例項公用的方法抽取到原型物件上。而原型物件只會隨著類的建立而建立, 只會載入一次。 之後我們建立的例項可以直接呼叫這個原型物件上的方法。從而避免重複建立冗餘的方法。至於例項為什麼可以直接使用原型物件的上的方法將在3.5中介紹。改造一下上面的程式碼。

class Animal {
  move() {
    console.log(this.name + " moving ...");  // this指向方法的調動者
  }
  constructor(name) {
    this.name = name;
  }
}

const animal = new Animal(cat)
animal.move() // cat moving ...
const animal1 = new Animal(cat2)
animal.move() // cat2 moving ...

上述程式碼值得注意的一點是:move方法中的this和constructor中的this沒有任何關係。constructor中的this指向的是例項。move方法中的this指向的是方法的呼叫者。

我們可以將一些公共的方法抽取到原型物件上。自然也可以將一些屬性抽取到原型物件上。但大部分情況下我們不會這麼做,因為將一個屬性放到原型物件中之後,所有的例項將共享這個屬性,這會導致資料的變更變得不可控。

大部分情況下我們可能希望每個物件都擁有自身的屬性。這也回答了3.3.2中遺留的問題:為什麼直接定義在類程式碼塊中的屬性當成是例項屬性而不是原型屬性呢? 因為在設計之初就並不希望開發者去定義原型屬性。如果我們真的想定義原型屬性, 可以採用ES5的方式:

Animal.prototype.myName = 'lsm'
const animal = new Animal(cat)
console.log(animal.name)  // cat
console.log(animal.myName) // lsm

看到這的小夥伴估計就會有一種感覺:屬性和方法的定義好亂!!沒事接下來我給大家總結一下。

3.4 三個物件中的成員歸納

  1. 想定義例項成員, 可以在constructor方法中進行初始化。對於例項屬性也可以直接定義在類的程式碼塊中。
  2. 想定義靜態成員, 可以在類的程式碼塊中的使用static 修飾符修飾屬性和方法。也可以直接使用物件的形式新增(Obj.key=val)。
  3. 想定義原型成員, 可以透過物件的形式在類的原型上新增成員。對於原型方法, 可以直接定義在類的程式碼塊中。

示例程式碼如下。

class Animal {
  name1 = 'lsm'  // 例項屬性
  move1() {  // 原型方法
    console.log("moving1 ...")
  }

  constructor(name) { // 原型方法
    this.name = name  // 例項屬性
    this.move = () => {  // 例項方法
      console.log("moving ...");
    }
  }

  static name2 = 'cat'  // 靜態屬性
  static move2() {  // 靜態方法
    console.log("moving2 ...")
  }
}

Animal.name3 = "lion"  // 靜態屬性, 推薦使用static的方式
Animal.move3 = () => {  // 靜態方法, 同上
  console.log("moving3 ...")
}

Animal.prototype.name4 = "cattle"  // 原型屬性, 不推薦
Animal.prototype.move4 = () => {  // 原型方法, 推薦直接在類中定義
  console.log("moving4 ...")
}

下來我們來對比下ES5和ES6的類中定義不同物件成員的方式

image.png

上圖可以讓我們可以很清晰的感知到, ES6中的class就是一個語法糖。

講解上述的三種物件時, 我基本都是在說如何定義卻沒說使用。因為確實也沒啥好說的。三種物件都可以使用自身的屬性和方法,除此之外唯一需要注意的就是例項物件可以使用原型物件上的成員。但是為什麼例項物件可以使用原型物件上的成員呢?接下來,讓我們好好剖析下這三個物件之間的關係

3.5 例項物件, 類物件,原型物件之間的關係。

上文遺留了兩個問題:

  1. 為什麼透過constructor的指向,我們能確定例項物件屬於哪個類。
  2. 為什麼例項物件可以使用原型物件上的屬性。

其實上述兩個問題的答案是一致的。因為在例項物件中有一個預設的指標[[Prototype]]指向原型物件。不同瀏覽器對該指標有不同的實現方式。在chrome、Firefox等瀏覽器中的,對該指標的實現為__proto__屬性。換而言之,我們可以透過__proto__屬性訪問到原型物件。 正是因為例項和原型物件之間存在這樣的引用關係,我們才可以實現上述的兩種操作。我們可以驗證一波:

class Animal {
  move() {  // 定義了原型方法
    console.log('moving ...')
  }
}
Animal.prototype.myName = "cat"  // 定義了原型屬性

const animal = new Animal()
console.log("animal = ", animal)  // 列印結果見下圖
console.log("animal.__proto__ = ", animal.__proto__)

image.png

透過上圖我們可知, 例項中確實有一個[[Prototype]]指標(這個指標僅代表一種引用關係,無法被訪問)指向一個物件,並且這個物件可以透過__proto__屬性獲取到, 但是這個指向的物件是不是原型物件呢。我們可以換一種能思路驗證。

透過3.3.1可知:類物件透過prototype屬性指向其原型物件。如果例項的__proto__屬性和類物件的prototype屬性相等, 是不是就可以證明例項的__proto__屬性指向的是原型物件。

console.log(animal.__proto__ === Animal.prototype)  // true

驗證的結果是肯定的。而在ES5中的instanceof方法正是透過這種方式來判斷某個例項是否屬於某建構函式。

而這種引用關係同樣也是原型鏈查詢的基礎。所謂原型鏈查詢就是:在呼叫一個物件屬性的時候,會從物件自身開始查詢,查詢不到會去物件的原型上查詢,並依次向上進行查詢, 直到找到或查詢到原型鏈的頂端null為止。

上面闡述了兩種引用關係:

  1. 例項物件的[[Prototype]]指標指向原型物件。
  2. 類物件的prototype屬性指向其原型物件。

需要注意的是,雖然例項物件和類物件都有屬性指向原型物件,但是這兩個物件之間沒有任何直接引用關係。

在3.3.1中還闡述了另一種關係:原型物件中的constructor指向類物件。

我用圖例來展示這三個物件之間的引用關係

image.png

瞭解了這三個物件和他們之間的關係, 整個class基本上只剩一個東西:繼承,一起看看吧

4.繼承

開篇在,什麼是class中我們提到:ES5中的構造函在實現繼承的時候,程式碼冗長且混亂。那我們不妨先來看看ES5中的繼承方式。ES5中的繼承方式有很多,最常用的就是寄生式組合繼承,我們就以寄生式組合繼承為例:

function Animal(myName) {  // 父類
  this.myName = myName
}
Animal.prototype.move = () => {
  console.log("moving ...")
}

function Cat (myName, age) {  // 子類
  // 1.繼承父類例項成員。這裡就是將Animal當成一個普通的函式,透過call呼叫,返回的結果就是父類中的例項成員
  Animal.call(this, myName) 
  this.age = age
}

 // 2.繼承父類原型物件成員。
 //  Object.create建立一個新物件,物件的原型是 Animal.prototype, 結果返回給子類的原型
Cat.prototype = Object.create(Animal.prototype) 
 // 3.此時子類的原型是空物件,下面的操作是給子類的原型新增constructor屬性並指向子類自身
Cat.prototype.constructor = Cat

const cat = new Cat("lsm", 25)
cat.move() // moving ...

根據上述的程式碼可知,ES5中的寄生式組合繼承大致分為三步:

  1. 繼承父類例項成員
  2. 繼承父類原型物件成員(執行完這一步,其實子類的原型是一個空物件)
  3. 新增子類的constructor指向自己(用於確定例項屬於哪個類)

上述的程式碼不難看出,實現的過程還是比較複雜的,並且實現繼承的一些步驟是寫在建構函式的外部的,程式碼比較混亂。接下來我們來看看ES6中的繼承吧。

class Animal {
  move () {
    console.log("moving ...")
  }

  constructor (myName) {
    this.myName = myName
  }
}

class Cat extends Animal {
  constructor(myName, age) {
    super(myName)
    this.age = age
  }
}

const cat = new Cat("lgt", 75)
cat.move()  // moving ...

以上兩種繼承方式的結果幾乎是相同的。不難看出, class的繼承方式簡潔很多, 並且繼承的步驟都是在類上執行的,比起ES5的繼承方式更加內聚。

接下我來說明下ES6的繼承步驟,主要依靠兩個關鍵字extends和super。

  • extends 用於繼承父類的原型物件成員。相當於ES5繼承中的步驟2。除此之外,extends甚至還可以繼承父類的靜態成員當做子類的靜態成員。這是ES5中的繼承所不具備的。 示例程式碼如下

image.png

從上述程式碼中我們還可以知道,父類中沒有例項成員時,子類可以不用顯式的宣告constructor,但是在建立例項的過程中還是會隱式的呼叫constructor。

  • super 用於繼承父類的例項成員。相當於ES5繼承中的步驟1。 super的使用有一些注意點,但在此之前我想先和大家討論下super是什麼。

已知的,我們在子類的constructor中呼叫super時候,父類的constructor被呼叫了。我們又知道constructor指向的其實就是類本身。所以其實super最終指向的就是父類本身。在瞭解這一點之後我們再來看看super使用的注意事項。

  1. super呼叫位置可以是cosntructor或靜態方法中。
  2. 子類的cosntructor被顯式定義時,也必須顯式的呼叫super方法。super接收的引數用於傳遞給父類的cosntructor
  3. super方法呼叫之前不能使用this。 這一點很好理解。super呼叫的是父類的cosntructor,cosntructor的作用是初始化並返回的this。所以在super呼叫之前,壓根就拿不到this。
  4. 在靜態方法中super可以呼叫父類的靜態成員。 這點也很好理解,因為super指向的就是父類,呼叫父類自身的屬性是合理的。這一點帶大家實踐一波
class Animal {
  static myName = "lsm"
  static move() {
    console.log('moving ...')
  }
}

class Cat extends Animal {
  static useAnimal() {
    console.log(super.myName)  // 呼叫父類的靜態屬性
    console.log(Cat.myName)  // 呼叫繼承來的靜態屬性
    super.move()  // 呼叫父類的靜態方法
    Cat.move()  // 呼叫繼承來的靜態方法
  }
}

Cat.useAnimal() // lsm
                // lsm
                // moving ...
                // moving ...

最後,站在三個物件的角度怎麼理解繼承呢,來看張圖吧

image.png

唯一需要注意的就是標紅的那根線了。子類的原型物件其實也是一個普通物件, 是物件就有[[Prototype]]指標。該指標指向父類的原型物件。正是因為這種引用關係的存在, 我們才可以實現原型鏈查詢。

以上就是今天的全部內容啦,謝謝各位看官老爺的觀看。不好的地方,還請包涵。不對的地方,還請指正。

參考文獻:JavaScript高階程式設計(第四版)

相關文章