在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高級程式設計(第四版)
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/545146.html
標籤:JavaScript
下一篇:js-惰性函式
