一切兼物件
首先要明白一個概念,在 JavaScript 中除了字串
、數字
、布林值
、Symbol
、undefined
這些原始型別的值之外,其他的一切都是物件,也就是說只要不屬於這些原始型別的值,在 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
的原型就指向其建構函式Object
的prototype
屬性,而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
都是人,都有name
、age
和sayHi
屬性,所以將這些提煉出來,形成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
方法,可以將這個方法掛載到Human
的prototype
上,這樣所有例項就可以通過原型鏈訪問到這個方法了:
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}`)
}
複製程式碼
完美。
建構函式的繼承
回顧並牢記以下兩個知識點:
- 原型是一個物件。
- 所有例項的原型
__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
上的屬性與方法。
但是如果要要給子類Gentleman
的prototype
新增一些只有子類才有的方法,上面的程式碼就行不能了,因為子類的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
語法,它只是一個語法糖,因為本質上還是通過建構函式和原型鏈實現的。
把上面的Human
用class
的方式重寫一遍:
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
。因為ES6
中class
實現繼承時是先建立父類的例項,然後再通過子類對這個例項進行加工而成的,整個過程用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
中實現繼承的真正方法,ES6
的class
只是語法糖,它與其他語言如Java
的class
還是有本質區別的,所以要抓住根本,把__proto__
prototype
這些關鍵的知識點攻破,掌握class
就是水到渠成的事了。
本文對class
的介紹並不完整,希望瞭解更多的同學可以檢視阮一峰老師的文章,那裡寫得非常詳細,我就不再重複了。