原型鏈與繼承 new 關鍵字的執行過程 讓我們回顧一下,this 指向里提到的new關鍵字執行過程。 創建一個新的空對象 將構造函數的原型賦給新創建對象(實例)的隱式原型 利用顯式綁定將構造函數的 this 綁定到新創建對象併為其添加屬性 返回這個對象 手寫new關鍵字的執行過程: function ...
目錄
原型鏈與繼承
new 關鍵字的執行過程
讓我們回顧一下,this 指向里提到的new
關鍵字執行過程。
- 創建一個新的空對象
- 將構造函數的原型賦給新創建對象(實例)的隱式原型
- 利用顯式綁定將構造函數的 this 綁定到新創建對象併為其添加屬性
- 返回這個對象
手寫new
關鍵字的執行過程:
function myNew(fn, ...args) { // 構造函數作為參數
let obj = {}
obj.__proto__ = fn.prototype
fn.apply(obj, args)
return obj
}
這裡提到了__proto__
和prototype
:前者被稱為隱式原型,後者被稱為顯式原型。
構造函數、實例對象和原型對象
三者的概念
構造函數:用於生成實例對象。構造函數可分為兩類:
- 自定義構造函數:
function foo () {}
- 原生構造函數:
function Function () {}
和function Object () {}
等
原型對象:每個構造函數都有自己的原型對象,可通過prototype
訪問。
實例對象:可由構造函數通過new
關鍵字生成的對象。
三者的關係
![](https://markdown-1314387653.cos.ap-guangzhou.myqcloud.com/img/%E6%88%AA%E5%B1%8F2022-11-27%2000.39.52.png)
構造函數可以通過prototype
訪問其原型對象,而原型對象可通過constructor
訪問其構造函數。構造函數可通過new
關鍵字創建實例對象,實例對象可通過__proto__
訪問其原型對象。
我們來看一段代碼的輸出結果:
function Foo(name, age) {
this.name = name
this.age = age
}
let a = new Foo('小明', 22)
console.log('構造函數:', Foo)
console.log('原型對象', Foo.prototype)
console.log('實例對象', a)
// 可以輸出一下,看看它們都是什麼樣子
![](https://markdown-1314387653.cos.ap-guangzhou.myqcloud.com/img/%E6%88%AA%E5%B1%8F2022-11-27%2017.07.27.png)
可以看出實例對象內部的第一個[[Prototype]]
的展開內容等於原型對象的展開內容,可構建一個等式如下:
// 實例對象可通過 __proto__ 訪問其原型對象
a.__proto__ === Foo.prototype // true
// 原型對象可通過 constructor 訪問其構造函數
Foo.prototype.constructor === Foo // true
原型鏈的概念及圖解
來看一張關於原型鏈的經典圖:
![](https://markdown-1314387653.cos.ap-guangzhou.myqcloud.com/img/jsobj_full.jpg)
上面這張圖的箭頭乍一看能讓人頭疼,我們對圖中的元素進行分類並劃分層次,可有以下三層:
第一層__proto__
指向:實例對象
- 通過構造函數生成的實例對象
// 生成實例對象
function Foo() {}
let obj1 = new Foo()
// __proto__指向驗證
obj1.__proto__ === Foo.prototype // true
- 通過
new Object()
、對象字面量生成的實例對象
// 生成實例對象
let obj2 = new Object()
// __proto__指向驗證
obj2.__proto__ === Object.prototype // true
- 通過
function
或class
聲明生成的實例對象
// 生成實例對象
function Foo(){}
// 原生構造函數
// function Function(){}
// function Object(){}
// __proto__指向驗證
Foo.__proto__ === Function.prototype // true
Function.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true
說明:其實我們自己定義的函數也是由Function構造函數生成的實例對象。
第二層__proto__
指向:Function.prototype
和Foo.prototype
Foo.prototype.__proto__ === Object.prototype // true
Function.prototype.__proto__ === Object.prototype // true
第三層__proto__
指向:Object.prototype
)
Object.prototype.__proto__ === null // true
我們自己再畫一張圖看一下:
![](https://markdown-1314387653.cos.ap-guangzhou.myqcloud.com/img/%E6%88%AA%E5%B1%8F2022-11-27%2001.27.29.png)
自底向上有三層的__proto__
構成基本的JavaScript原型模式生態,最後再總結一下規則:
- 實例對象都會指向其構造函數原型
- 構造函數原型都會指向
Object.prototype
Object.prototype
最終指向null
總結:其實我們的原型鏈指的就是__proto__
的路徑。
註意:這裡只是為了原型鏈能更加直觀,請不要忘了構造函數原型的constructor
屬性,它會指回對應的構造函數。
原型鏈繼承
我們利用任務驅動型的方法去學習繼承方式,考慮這樣一個類結構:
- 普通用戶:作為父類
- VIP 用戶:作為子類
說明:VIP用戶需要繼承普通用戶。其中,VIP用戶的武器列表可以添加屠龍寶刀。
原型鏈搜索機制:若要訪問當前對象所沒有的屬性和方法,則會首先以當前對象為起點沿著原型鏈__proto__
向上尋找每個對象內部的屬性和方法。直到找到對應的屬性和方法,沒有則會直接走到原型鏈盡頭null
。
來看這樣一段代碼:
function USER(username, password) {
this.username = username
this.password = password
this.weapon = ['水果小刀']
}
VIP.prototype = new USER() // 為什麼要放到中間?
// 註意:改寫原型,要記得把 constructor 指會原構造函數
VIP.prototype.constructor = VIP
function VIP() { }
VIP.prototype.addWeapon = function (weaponName) {
this.weapon.push(weaponName)
}
let a = new VIP('小明')
// 缺陷1:無法給父類構造函數傳參,只能在 VIP 中自行添加相應參數。無法實現父類屬性重用
let b = new VIP()
b.addWeapon('屠龍寶刀')
console.log(b.weapon)
let c = new VIP()
console.log(c.weapon)
// 缺陷2: 我們想要單獨給實例 b 的武器列表添加一把屠龍寶刀,結果是實例 c 的武器列表也會增加屠龍寶刀
原型鏈__proto__
實現繼承會經過的對象(從子類實例到父類原型):
-
子類構造函數實例 :
new VIP()
-
父類構造函數實例 :
new USER()
-
父類構造函數原型 :
USER.prototype
我們可以構建兩個表達式去驗證:
new VIP().__proto__ === new USER() // true
new USER().__proto__ === USER.prototype // true
再進一步提煉以上兩個表達式,可獲得最終表達式。以下為實現繼承關鍵的完整原型鏈:
// VIP 構造函數所生成的實例會經過兩層__proto__找到父類原型
new VIP().__proto__.__proto__ === USER.prototype
// 接下來,由 VIP 構造函數生成的實例所沒有的屬性和方法,都會去父類原型找到屬性和方法。
原型鏈繼承缺點:
- 父類原型中若存在的引用值則會在所有實例間共用。
- 子類構造函數在實例化時不能給父類構造函數傳參,即我們的父類屬性無法重用。
為什麼 VIP.prototype = new USER()
這一步要放到兩個構造函數中間?
如果這一步表達式放到後面,我們的VIP.prototype
是其原本構造函數 VIP 的原型。在這個原本的構造函數原型上添加方法,不會有繼承效果。
我們的想法是通過父類構造函數生成實例,利用它實例的__proto__
去實現繼承效果。要想在子類構造函數添加方法,我們實際做了這樣的操作。如下:
// new USER()就是我們父類構造函數生成的實例
new USER().addWeapon = function (weaponName) {
this.weapon.push(weaponName)
}
但是上面這樣會出現問題,我們子類構造函數怎麼辦?他想new一個實例,還是會根據原來的原型。
因此,我們需要將new USER()
傳遞給VIP.prototype
。這樣VIP構造函數生成實例才會有繼承效果,如下:
VIP.prototype = new USER() // 傳遞__proto__實現繼承
VIP.prototype.addWeapon = function (weaponName) {
this.weapon.push(weaponName)
}
盜用構造函數
來看這樣一段代碼:
function USER(username, password) {
this.username = username
this.password = password
this.weapon = ['水果小刀']
}
function VIP(username, password) {
USER.call(this, username, password) // 調用父類構造函數,為其屬性賦值
} // 這裡的 this 指向子類構造函數生成的新實例
// 1. 接下來我們可以向父類構造函數傳參
let a = new VIP('小紅')
console.log(a.username) // 小紅
// 2. 也可以解決引用值產生的問題
let b = new VIP()
b.weapon.push('屠龍寶刀')
console.log(b.weapon) // ['水果小刀', '屠龍寶刀']
let c = new VIP()
console.log(c.weapon) // ['水果小刀']
// 這樣實例 b 和 c 的武器列表的數據都是獨立的
過程解析:new VIP('小紅')
傳入了一個“小紅”參數。
第一次綁定操作:new
執行過程會執行一次綁定操作,將this指向實例對象。
第二次綁定操作:VIP構造函數內部的call
方法再次綁定實例對象,調用父類構造函數
總結:我們通過傳參實際調用了兩次綁定操作,最終使得子類構造函數的新實例也能擁有父類的屬性和值。
盜用構造函數缺點:
- 只能在構造函數內部定義方法使用,不能訪問父類原型定義的方法。即我們的父類方法不能重用
組合繼承( = 原型鏈繼承 + 盜用構造函數 )
如果你已經清楚的知道上面兩種繼承方式的優點和缺陷,那麼我們可以利用1 + 1 > 2 的方法實現組合繼承。
function USER(username, password) {
this.username = username
this.password = password
this.weapon = ['水果小刀']
}
VIP.prototype = new USER()
// 註意:改寫原型,要記得把 constructor 指會原構造函數
VIP.prototype.constructor = VIP
function VIP(username, password) {
USER.call(this, username, password) // 調用父類構造函數,為其屬性賦值
} // 這裡的 this 指向子類構造函數生成的新實例
VIP.prototype.addWeapon = function (weaponName) {
this.weapon.push(weaponName)
}
// 我們嘗試給父類構造函數傳參
let a = new VIP('小紅')
console.log(a.username) // 小紅
// 看看添加屠龍寶刀,有沒有相互影響
let b = new VIP()
b.addWeapon('屠龍寶刀')
console.log(b.weapon)
let c = new VIP()
console.log(c.weapon)
以上的組合繼承方式輸出了正確的答案,算是完美解決了原型鏈繼承和盜用構造函數繼承出現的問題。我們將以上代碼放入瀏覽器打斷點分析。如下:
很明顯,我們第一次new USER()
會調用父類構造函數,而後子類構造函數每一次生成新實例都會調用父類構造函數。也就是說,多了第一次會調用父類構造函數的情況。
原型繼承
在 JavaScirpt 高級程式設計 8.3.4 中提到了這種方式,來看這樣一段代碼
function object(obj) {
function Fn() { }
Fn.prototype = obj
return new Fn() // 返回一個空函數,其內部原型改寫為 obj
}
有沒有熟悉的感覺,其實正是我們之前手寫bind
函數利用的繼承方法。與ES6中的Object.create()
方法效果相同。它適於在原有對象的基礎上再克隆一個對象。此外,對象屬性值若為原始值則可以進行改寫,若為引用值則會產生引用值的特點。即多個克隆對象會共用同一個引用值,也就是說這個“克隆”操作相當於我們的淺拷貝操作。
寄生繼承
function createAnother(obj) {
let clone = object(obj)
clone.sayHello = () => {
console.log('Hello World')
}
}
這種方式可以使克隆對象在原基礎上增強,即添加屬性和方法,
註意:原型繼承和寄生繼承都重點關註對象的使用,而不考慮構造函數的使用
寄生組合繼承( = 組合繼承 + 原型繼承 + 寄生繼承 )
我們可以再利用瀏覽器打斷點試試,是不是不會發生像組合繼承那樣首次調用構造函數的情況。
// 寄生組合繼承
function inheritPrototype(subType, superType) {
subType.prototype = Object.create(superType.prototype) // 創建對象
subType.prototype.constructor = subType // 增強對象
}
function USER(username, password) {
this.username = username
this.password = password
this.weapon = ['水果小刀']
}
// 驗證表達式時,下麵這一條語句要加上註釋。
inheritPrototype(VIP, USER) // 調用繼承函數,
// 驗證表達式時,把下麵這兩條語句註釋去掉。
// VIP.prototype = Object.create(USER.prototype) // 創建對象
// VIP.prototype.constructor = VIP // 增強對象
function VIP(username, password) {
USER.call(this, username, password) // 調用父類構造函數,為其屬性賦值
} // 這裡的 this 指向子類構造函數生成的新實例
VIP.prototype.addWeapon = function (weaponName) {
this.weapon.push(weaponName)
}
// 我們嘗試給父類構造函數傳參
let a = new VIP('小紅')
console.log(a.username) // 小紅
// 看看添加屠龍寶刀,有沒有相互影響
let b = new VIP()
b.addWeapon('屠龍寶刀')
console.log(b.weapon)
let c = new VIP()
console.log(c.weapon)
原型鏈__proto__
實現繼承會經過的對象(從子類實例到父類原型):
- 子類構造函數實例 :
new VIP()
- 空構造函數實例 :
Object.create(USER.prototype)
- 父類構造函數原型 :
USER.prototype
我們同樣構建兩個表達式去驗證:
new VIP().__proto__ === Object.create(USER.prototype) // true
Object.create(USER.prototype).__proto__ === USER.prototype // true
再進一步提煉以上兩個表達式,可獲得最終表達式。以下為實現繼承關鍵的完整原型鏈:
new VIP().__proto__.__proto__ === USER.prototype // true
// 接下來,由 VIP 構造函數生成的實例所沒有的屬性和方法,都會去父類原型找到屬性和方法。
原型鏈和寄生組合的繼承區別比較
原型鏈的繼承實現:利用new USER()
作為跳板實現繼承。
VIP.prototype = new USER() // 傳遞__proto__實現繼承
new VIP().__proto__ === new USER() // true
new USER().__proto__ === USER.prototype // true
寄生組合的繼承實現:利用Object.create(USER.prototype)
作為跳板實現繼承。
VIP.prototype = Object.create(USER.prototype) // 傳遞__proto__實現繼承
new VIP().__proto__ === Object.create(USER.prototype) // true
Object.create(USER.prototype).__proto__ === USER.prototype // true
註意:Object.create(USER.prototype)
會返回一個空函數實例,這個實例的__proro__
指向USER()
構造函數。
class繼承(ES6 語法)( ≈ 寄生組合繼承 )
在 ES5 之前我們都是利用構造函數實現面向對象編程。ES6 的class
作為語法糖,其實內部也是利用了構造函數實現面向對象編程。
class USER {
constructor(username, password) {
this.username = username
this.password = password
this.weapon = ['水果小刀']
}
}
class VIP extends USER {
constructor(username, password) {
super(username, password) // 調用父類構造函數,相當於執行 call 方法
}
addWeapon(weaponName) {
this.weapon.push(weaponName)
}
}
// 我們嘗試給父類構造函數傳參
let a = new VIP('小紅')
console.log(a.username) // 小紅
// 看看添加屠龍寶刀,有沒有相互影響
let b = new VIP()
b.addWeapon('屠龍寶刀')
console.log(b.weapon)
let c = new VIP()
console.log(c.weapon)
// 以上同樣可運行我們的測試代碼
總結 JavaScript 繼承方式:
- 原型鏈繼承
- 盜用構造函數繼承
- 組合繼承( = 原型鏈繼承 + 盜用構造函數 )
- 原型式繼承
- 寄生繼承
- 寄生組合繼承( = 組合繼承 + 原型繼承 + 寄生繼承 )
- class繼承( ≈ 寄生組合繼承 )
以上可以看出 JavaScript 對與繼承方式的優化是一個多次迭代不斷優化的過程。
參考