在ES6中新增了一個很重要的特性: class(類)。作為一個在2015年就出了的特性, 相信很多小伙伴對class並不陌生。但是在日常開發中使用class的頻率感覺並不高(可能僅限於作者),感覺對class總有種一知半解的感覺。今天就帶著小伙伴們一起,好好剖析剖析這個特性。 1.什麼是class ...
在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也有兩種創建方式:類聲明和類表達式。我們分別的列舉下
上圖中左右加起來共四種創建類的方式,其產生的結果基本是一致的。其中需要註意的是,左側的函數式聲明會存在函數提升的的過程,而類聲明的方式不會進行提升。舉例來說
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修飾符創建靜態成員的方式。
知道了什麼是靜態成員,那靜態成員有什麼用呢?其實靜態成員最主要的作用就是:脫離實例。創建與類本身強綁定的成員。 總的來說就是我想創建一些屬性和方法, 但是這些屬性和方法並不需要創建實例就能調用或者和實例本身就沒啥關係。這句話可能不好理解,我用兩個例子來說明下。
- Math.PI、Math.random(): Math中的這些成員都是靜態成員。通過創建實例的方式去調用這些成員是毫無意義的(實際上也不能),因為這些屬性的值或方法的結構全都是固定的。
- Array.isArray(): 這個方法的作用是判斷所有類型的對象是不是數組,這和數組的實例沒有一毛錢關係。
- ......
看到這,如果是細心的小伙伴,可能就會產生一些疑問:
- 為什麼ES6中添加靜態成員的時候需要添加static修飾符?
- 如果不加static修飾符,這個成員就不是靜態成員了嗎?
- 如果問題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) // 列印結果如下圖
預設情況下,該對象中只有一個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 三個對象中的成員歸納
- 想定義實例成員, 可以在constructor方法中進行初始化。對於實例屬性也可以直接定義在類的代碼塊中。
- 想定義靜態成員, 可以在類的代碼塊中的使用static 修飾符修飾屬性和方法。也可以直接使用對象的形式添加(Obj.key=val)。
- 想定義原型成員, 可以通過對象的形式在類的原型上添加成員。對於原型方法, 可以直接定義在類的代碼塊中。
示例代碼如下。
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的類中定義不同對象成員的方式
上圖可以讓我們可以很清晰的感知到, ES6中的class就是一個語法糖。
講解上述的三種對象時, 我基本都是在說如何定義卻沒說使用。因為確實也沒啥好說的。三種對象都可以使用自身的屬性和方法,除此之外唯一需要註意的就是實例對象可以使用原型對象上的成員。但是為什麼實例對象可以使用原型對象上的成員呢?接下來,讓我們好好剖析下這三個對象之間的關係
3.5 實例對象, 類對象,原型對象之間的關係。
上文遺留了兩個問題:
- 為什麼通過constructor的指向,我們能確定實例對象屬於哪個類。
- 為什麼實例對象可以使用原型對象上的屬性。
其實上述兩個問題的答案是一致的。因為在實例對象中有一個預設的指針[[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__)
通過上圖我們可知, 實例中確實有一個[[Prototype]]指針(這個指針僅代表一種引用關係,無法被訪問)指向一個對象,並且這個對象可以通過__proto__屬性獲取到, 但是這個指向的對象是不是原型對象呢。我們可以換一種能思路驗證。
通過3.3.1可知:類對象通過prototype屬性指向其原型對象。如果實例的__proto__屬性和類對象的prototype屬性相等, 是不是就可以證明實例的__proto__屬性指向的是原型對象。
console.log(animal.__proto__ === Animal.prototype) // true
驗證的結果是肯定的。而在ES5中的instanceof方法正是通過這種方式來判斷某個實例是否屬於某構造函數。
而這種引用關係同樣也是原型鏈查找的基礎。所謂原型鏈查找就是:在調用一個對象屬性的時候,會從對象自身開始查找,查找不到會去對象的原型上查找,並依次向上進行查找, 直到找到或查找到原型鏈的頂端null為止。
上面闡述了兩種引用關係:
- 實例對象的[[Prototype]]指針指向原型對象。
- 類對象的prototype屬性指向其原型對象。
需要註意的是,雖然實例對象和類對象都有屬性指向原型對象,但是這兩個對象之間沒有任何直接引用關係。
在3.3.1中還闡述了另一種關係:原型對象中的constructor指向類對象。
我用圖例來展示這三個對象之間的引用關係
瞭解了這三個對象和他們之間的關係, 整個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中的寄生式組合繼承大致分為三步:
- 繼承父類實例成員
- 繼承父類原型對象成員(執行完這一步,其實子類的原型是一個空對象)
- 添加子類的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中的繼承所不具備的。 示例代碼如下
從上述代碼中我們還可以知道,父類中沒有實例成員時,子類可以不用顯式的聲明constructor,但是在創建實例的過程中還是會隱式的調用constructor。
- super 用於繼承父類的實例成員。相當於ES5繼承中的步驟1。 super的使用有一些註意點,但在此之前我想先和大家討論下super是什麼。
已知的,我們在子類的constructor中調用super時候,父類的constructor被調用了。我們又知道constructor指向的其實就是類本身。所以其實super最終指向的就是父類本身。在瞭解這一點之後我們再來看看super使用的註意事項。
- super調用位置可以是cosntructor或靜態方法中。
- 子類的cosntructor被顯式定義時,也必須顯式的調用super方法。super接收的參數用於傳遞給父類的cosntructor
- super方法調用之前不能使用this。 這一點很好理解。super調用的是父類的cosntructor,cosntructor的作用是初始化並返回的this。所以在super調用之前,壓根就拿不到this。
- 在靜態方法中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 ...
最後,站在三個對象的角度怎麼理解繼承呢,來看張圖吧
唯一需要註意的就是標紅的那根線了。子類的原型對象其實也是一個普通對象, 是對象就有[[Prototype]]指針。該指針指向父類的原型對象。正是因為這種引用關係的存在, 我們才可以實現原型鏈查找。
以上就是今天的全部內容啦,謝謝各位看官老爺的觀看。不好的地方,還請包涵。不對的地方,還請指正。
參考文獻:JavaScript高級程式設計(第四版)