一次掌握 JavaScript 原型與繼承

Calerme發表於2018-04-16

一切兼物件

首先要明白一個概念,在 JavaScript 中除了字串數字布林值Symbolundefined 這些原始型別的值之外,其他的一切都是物件,也就是說只要不屬於這些原始型別的值,在 JavaScript 中遇到的其他資料型別的本質都是這個形式:

const data = {
    name: 'anyDataType',
    toString () {},
    toJSON () {}
}
複製程式碼

“除了基本資料型別”,那為什麼還說一切兼物件呢?這是因為將基本資料型別當作物件來操作也是可以的,JavaScript 引擎內部會在操作的一瞬間將基本資料型別封裝為一個物件,操作結束後再銷燬:

const s = 'It\'s a string.'
s.length // -> 14
複製程式碼
// 相當於
const s = 'It\'s a string.'
new String(s).length
複製程式碼

原型與原型鏈

我們再來建立一個空物件例項:

const o = {}
typeof o.toString // "function"
複製程式碼

為什麼物件o明明沒有任何屬性或方法,在它身上卻能呼叫一個toString函式呢?

這是因為在 JavaScript 中所有的物件都有一個隱匿的__proto__屬性,該屬性指向另一個物件,這個物件就是原始物件的原型。

當呼叫一個物件的屬性或方法時,如果該物件自身沒有這個屬性或方法,就會向它的原型也就是它的__proto__屬性所指向的那個物件上去尋找,如果原型上也沒有,那就到原型的原型上去找,就這樣連成一條原型鏈

原型鏈的頂端是null

再回到空物件o上:

// “const o = {}” 相當於
const o = new Object()
複製程式碼

此時o的原型就指向其建構函式Objectprototype屬性,而toString就是Object.prototype物件的方法:

o.toString === Object.prototype.toString // true
複製程式碼

物件的__proto__是可以被修改的:

const o2 = { name: 'o2' }
o.__proto__ = o2
o.name // "o2"
複製程式碼

此時再呼叫o.toString:

typeof o.toString // "function"
複製程式碼

o的原型已經變成o2了,而o2上沒有toString方法,為什麼o.toString還是一個function呢?這是因為o2也是一個物件,它也有原型,而它的原型就指向Object.prototype

o訪問toStirng方法時,發現它自身和它的原型o2上都沒有,所以就繼續向o2的原型(Object.prototype)查詢,所以整個過程就是一條完整的原型鏈。

Reflect.getPrototypeOf() Reflect.setPrototypeOf()

其實__proto__屬性並不是 ECMAScript 語言的標準,它只是多數瀏覽器實現的一個非標準屬性,如果你是一個十分注重根正功紅、血統純正的處女座,那麼就應該使用標準的方法來讀取和修改物件例項的原型:

const o = {}
// 讀取原型
Object.getPrototypeOf(o) // 返回 o 的原型

// 修改原型
const o2 = { name: 'o2' }
Object.setPrototypeOf(o, o2) // 將 o 的原型修改為 o2
複製程式碼

ES6 中將這兩個方法掛載到了Reflect物件上,通過Reflect呼叫和通過Object呼叫是等價的。

建構函式

我們可以通過人肉手寫的方式實現一個又一個例項:

const xiaoMing = {
    name: 'XiaoMing',
    age: 23,
    sayHi () {
        console.log(`Hi! My name is ${this.name}`)
    }
}
const xiaoHua = {
    name: 'XiaoHuang',
    age: 22,
    sayHi () {
        console.log(`Hi! My name is ${this.name}`)
    }
}
複製程式碼

很弱智,對不對,程式碼重複而且容易出錯,所以 JavaScript 提供了建構函式,可以使用建構函式來生成一個個相似的物件(例項),建構函式就是例項的模板。

xiaoMing xiaoHua都是人,都有nameagesayHi屬性,所以將這些提煉出來,形成Human建構函式:

function Human(name, age) {
    this.name = name
    this.age = age
    this.sayHi = function () {
        console.log(`Hi! My name is ${this.name}`)
    }
}
const xiaoMing = new Human('XiaoMing', 23)
const xiaoHua = new Human('XiaoHua', 22)
複製程式碼

new Human大體過程如下:

// “const xiaoMing = new Human('XiaoMing', 23)” 經歷瞭如下過程
const xiaoMing = (function Human(name, age) {
    // 先建立一個新物件
    const _o = {}
    // 這個新物件的原型指向該建構函式的 prototype 屬性
    Reflect.setPrototypeOf(_o, Human.prototype)
    // 之後建構函式中的所有 this 均指向這個新物件
    _o.name = name
    _o.age = age
    _o.sayHi = function () {
        console.log(`Hi! My name is ${this.name}`)
    }
    
    return _o
})('XiaoMing', 23)
複製程式碼

以上過程要特別留意,所有例項物件的原型均被指向到建構函式的prototype物件上。明白了這一點後,再看Human建構函式,其中的sayHi其實在所有的例項中是相同的,所以沒必要在每一個例項上掛載一個新的sayHi方法,可以將這個方法掛載到Humanprototype上,這樣所有例項就可以通過原型鏈訪問到這個方法了:

function Human(name, age) {
    this.name = name
    this.age = age
    // 將 this.sayHi 寫到 prototype 中
}
Human.prototype.sayHi = function () {
    console.log(`Hi! My name is ${this.name}`)
}
複製程式碼

完美。

建構函式的繼承

回顧並牢記以下兩個知識點:

  1. 原型是一個物件。
  2. 所有例項的原型__proto__就是其建構函式的prototype屬性。

所有的函式在初始宣告後,都預設有一個prototype屬性,這個屬性是一個物件,且只有一個成員constructor,就是這個樣子:

{
    constructor: 建構函式本身
}
複製程式碼

函式的prototype是可以修改的。

所以將一個建構函式的的prototype指向另一個建構函式的prototype,這樣通過該建構函式生成的例項物件就可以通過原型鏈訪問到其他建構函式prototype上的方法,實現繼承:

// 先寫一個父類 Human
function Human(name, age) {
    this.name = name
    this.age = age
}
Human.prototype.sayHi = function () {
    console.log(`Hi! My name is ${this.name}.`)
}

// 再寫一個子類
function Gentleman(name, age, occupation) {
    Human.call(this, name, age)
    this.occupation = occupation
}
Gentleman.prototype = Human.prototype

// 此時建立一個例項
const jiBo = new Gentleman('山下智博', 30, 'vlogger')
jiBo.sayHi() // "Hi! My name is 山下智博."
jiBo.occupation // "vlogger"
複製程式碼

上面我們通過在子類Gentleman中將this(此時的this就是子類例項化時內部生成的物件)繫結到 Human 並傳參執行,使得最後的例項物件上可以掛載到父建構函式內部設定掛載的屬性與方法,並且可以通過原型鏈訪問到Human.prototype上的屬性與方法。

但是如果要要給子類Gentlemanprototype新增一些只有子類才有的方法,上面的程式碼就行不能了,因為子類的prototype與父類的prototype指向同一個物件,修改子類就會影響到父類,此時就需要引入一箇中間物件:

function Human () {...}

// 建立一箇中間物件
const _o = {}
Reflect.setPrototypeOf(_o, Human.prototype)

function Gentleman () {...}
Gentleman.prototype = o
複製程式碼

這時即使在子類Gentleman.prototype上新增新的屬性和方法,也只是新增到了_o物件上,同時又可以通過原型鏈訪問到Human.prototype

JavaScript 提供Object.create()方法,它接收一個物件作為引數,返回一個以傳入物件為原型的新物件,其實整個過程類似於上面程式碼中建立中間物件_o的那兩行程式碼,所以上面的程式碼可以改寫成:

function Human () {...}

function Gentleman () {...}
Gentleman.prototype = Object.create(Human.prototype)
複製程式碼

constructor

還記不記得所有函式預設的prototype物件上有一個constructor屬性,該屬性就指向該函式本身。

這個屬性放到現在其實沒什麼用處,因為現在我們可以通過__proto__屬性或者Reflect.setPrototypeOf() Reflect.getPrototypeOf()來讀取和修改一個例項物件的原型,但在老一些的瀏覽器中這些屬性和方法是沒有的,只能通過獲取例項物件的建構函式,再通過建構函式修改其自身的prototype屬性來實現修改例項物件原型的目的:

function F () {}
const f = new F()
f.constructor.prototype.sayHi = function () { console.log('new property') }
f.sayHi() // "new property"
複製程式碼

所以,因為這個歷史原因,我們要養成一個好習慣,就是在修改了函式的prototype後,要手動地給新prototype新增一個constructor屬性,並指向該函式自身:

function F () {}
F.prototype = { a: 1, b: 2 }
F.prototype.constructor = F
複製程式碼

this

所有通過例項物件呼叫的方法,不管這個方法是例項物件自身的,還是從原型鏈上繼承的,方法內部的this都指向這個例項物件:

function F() {}
F.prototype.intro = function () { console.log(this.name) }
const f = new F()
f.name = 'f'
f.intro() // "f"
複製程式碼

注意,建構函式prototype屬性上的方法千萬不要使用箭頭函式定義,因為箭頭函式會將this與它定義時的環境繫結到一起,再通過例項物件呼叫時就不能改變this的值了:

function F() {}
F.prototype.intro = () => console.log(this.name)
const f = new F()
f.name = 'f'
f.intro() // ""
複製程式碼

ES6 中的 class

ES6中新增了class語法,它只是一個語法糖,因為本質上還是通過建構函式和原型鏈實現的。

把上面的Humanclass的方式重寫一遍:

class Human {
    constructor (name, age) {
        this.name = name
        this.age = age
    }
    sayHi () {
        console.log(`Hi! My name is ${this.name}.`)
    }
}

const xiaoMing = new Human('XiaoMing', 23)
複製程式碼

對照著建構函式的老寫法,class的語法也一目瞭然,其中constructor就是Human建構函式的本體,其餘的函式就是定義在Human.prototype上的方法,so easy。

static 關鍵字

ES5中給一個建構函式新增靜態方法(直接在建構函式上新增)是這樣的:

function Human () { /*...*/ }
Human.isHuman(human) {
    return human instanceof Human
}
複製程式碼

ES6的 class 中,通過給函式的前面新增一個static實現:

class Human {
    constructor () { /*...*/ }
    sayHi () { /*...*/ }
    static isHuman (human) {
        return human instanceof Human
    }
}
複製程式碼

總結,在class中前面沒有static關鍵字的函式被掛載到建構函式自身上,有static關鍵字的被掛載到建構函式的prototype上。

ES6 中的繼承:extends 與 super 關鍵字

使用ES6的新語法實現繼承了Human的子類Gentleman

class Gentleman extends Human {
    constructor (name, age, occupation) {
        super(name, age)
        this.occupation = occupation
    }
}
複製程式碼

ES6規定,子類的constructor裡必須實行super,且在執行super前不可使用this關鍵字,否則會報錯。

這裡的super代表的就是父類的constructor。因為ES6class實現繼承時是先建立父類的例項,然後再通過子類對這個例項進行加工而成的,整個過程用ES5寫大概就是這個樣子:

// “new Human('山下智博', 30, 'vlogger')” 這裡的 human 是通過 class 宣告的
const _o = new Human('山下智博', 30)
Gentleman.call(_o, 'vlogger')
Reflect.setPrototypeOf(_o, Gentleman.prototype)
複製程式碼

雖然建立的順序與過去通過建構函式實現的繼承不同,但本質是相同的。

通過class繼承的子類,不僅prototype繼承自父類的prototype,其本身(constructor)的原型也為父類:

Gentleman.prototype.__proto__ === Human.prototype // true
Gentleman.__proto__ === Human // true
複製程式碼

所以,子類可以呼叫父類的靜態方法。

super 的不同身份

super在子類中有三種身份:

  • 在子類的construtor中代表父類的constructor
  • 在子類的其他方法中,代表父類的prototype
  • 在子類的靜態方法中,代表父類,可以通過它執行父類的靜態方法

其實,要想掌握JavaScript中的繼承,還是得必須完全掌握本文的前半部分,因為原型和原型鏈才是JavaScript中實現繼承的真正方法,ES6class只是語法糖,它與其他語言如Javaclass還是有本質區別的,所以要抓住根本,把__proto__ prototype這些關鍵的知識點攻破,掌握class就是水到渠成的事了。

本文對class的介紹並不完整,希望瞭解更多的同學可以檢視阮一峰老師的文章,那裡寫得非常詳細,我就不再重複了。

相關文章