JavaScript面向對象詳解(三)
Original coderwhy coderwhy Yesterday
此文爲轉載
繼承是面向對象中非常重要的特性.
ES5中和類的實現一樣, 不能直接實現繼承. 實現繼承主要是依靠原型鏈來實現的。
一. 原型鏈
原型鏈是ES5中實現繼承的主要手段, 因此相對比較重要, 我們需要深入理解原型鏈.
1.1. 深入理解原型鏈
先來回顧一下構造函數、原型和實例的關係:
-
每個構造函數都有一個原型對象, 通過prototype指針指向該原型對象.
-
原型對象都包含一個指向構造函數的指針, 通過constructor指針, 指向構造函數
-
而實例都包含一個指向原型對象的內部指針, 該內部指針我們通常使用__proto__來描述.
思考如下情況:
-
我們知道, 可以通過Person.prototype = {}的方式來重寫原型對象.
-
假如, 我們後面賦值的不是一個{}, 而是另外一個類型的實例, 結果會是怎麼樣呢?
-
顯然,此時的原型對象將包含一個指向另一個原型的指針,相應地,另一個原型中也包含着一個指向另一個構造函數的指針。
-
假如另一個原型又是另一個類型的實例,那麼上述關係依然成立,如此層層遞進,就構成了實例與原型的鏈條。這就是所謂原型鏈的基本概念。
有些抽象, 我們通過代碼來理解:
// 創建Person構造函數
function Person() {
}
// 設置Animal的原型
Person.prototype = {
}
我們將代碼修改成原型鏈的形式:
// 1.創建Animal的構造函數
function Animal() {
this.animalProperty = "Animal"
}
// 2.給Animal的原型中添加一個方法
Animal.prototype.animalFunction = function () {
alert(this.animalProperty)
}
// 3.創建Person的構造函數
function Person() {
this.personProperty = "Person"
}
// 4.給Person的原型對象重新賦值
Person.prototype = new Animal()
// 5.給Person添加屬於自己的方法
Person.prototype.personFunction = function () {
alert(this.personProperty)
}
// 6.創建Person的實例
var person = new Person()
person.animalFunction()
person.personFunction()
代碼解析:
-
代碼有一些複雜, 但是如果你希望學習好原型鏈, 必須耐心去看一看上面的代碼, 你會發現其實都是我們學習過的.
-
重點我們來看第4步代碼: 給Person.prototype賦值了一個Animal的實例. 也就是Person的原型變成了Animal的實例.
-
Animal實例本身有一個__proto__可以指向Animal的原型.
-
那麼, 我們來思考一個問題: 如果現在搜索一個屬性或者方法, 這個時候會按照什麼順序搜索呢?
-
第一步, 在person實例中搜索, 搜索到直接返回或者調用函數. 如果沒有執行第二步.
-
第二步, 在Person的原型中搜索, Person的原型是誰? Animal的實例. 所以會在Animal的實例中搜索, 無論是屬性還是方法, 如果搜索到則直接返回或者執行. 如果沒有, 執行第三步.
-
第三步, 在Animal的原型中搜索, 搜索到返回或者執行, 如果沒有, 搜索結束. (當然其實還有Object, 但是先不考慮)
-
畫圖解析可能更加清晰:
當代碼執行到第3步(上面代碼的序號)的時候, 如圖所示:
img
當代碼執行第4步(上面代碼的序號)時, 發生瞭如圖所示的變化
-
注意圖片中的紅色線, 原來指向的是誰, 現在指向的是誰.
img
代碼繼續執行
-
Person.prototype.personFunction = function (){}
-
當執行第5步, 也就是給Person的原型賦值了一個函數時, 事實上在給new Animal(Animal的實例)賦值了一個新的方法.
img
代碼繼續執行, 我們創建了一個Person對象
-
創建Person對象, person對象會有自己的屬性, personProperty.
-
另外, person對象有一個__prototype__指向Person的原型.
-
Person的原型是誰呢? 就是我們之前的new Animal(Animal的一個實例), 所以會指向它.
原型鏈簡單總結:
-
通過實現原型鏈,本質上擴展了本章前面介紹的原型搜索機制。
-
當以讀取模式訪問一個實例屬性時,首先會在實例中搜索該屬性。如果沒有找到該屬性,則會繼續搜索實例的原型。在通過原型鏈實現繼承的情況下,搜索過程就得以沿着原型鏈繼續向上。
-
在找不到屬性或方法的情況下,搜索過程總是要一環一環地前行到原型鏈末端纔會停下來。
1.2. 原型和實例的關係
如果我們希望確定原型和實例之間的關係, 有兩種方式:
-
第一種方式是使用instanceof操作符,只要用這個操作符來測試實例與原型鏈中出現過的構造函數,結果就會返回true。
-
第二種方式是使用isPrototypeOf()方法。同樣,只要是原型鏈中出現過的原型,都可以說是該原型鏈所派生的實例的原型,因此isPrototypeOf()方法也會返回true
instanceof操作符
// instanceof
alert(person instanceof Object) // true
alert(person instanceof Animal) // true
alert(person instanceof Person) // true
isPrototypeOf()函數
// isPrototypeOf函數
alert("isPrototypeOf函數函數")
alert(Object.prototype.isPrototypeOf(person)) // true
alert(Animal.prototype.isPrototypeOf(person)) // true
alert(Person.prototype.isPrototypeOf(person)) // true
1.3. 添加新的方法
添加新的方法
-
在第5步操作中, 我們爲子類型添加了一個新的方法. 但是這裏有一個注意點.
-
無論是子類中添加新的方法, 還是對父類中方法進行重寫. 都一定要將添加方法的代碼, 放在替換原型語句之後.
-
否則, 我們添加的方法將會無效.
錯誤代碼引起的代碼:
// 1.定義Animal的構造函數
function Animal() {
this.animalProperty = "Animal"
}
// 2.給Animal添加方法
Animal.prototype.animalFunction = function () {
alert(this.animalProperty)
}
// 3.定義Person的構造函數
function Person() {
this.personProperty = "Person"
}
// 4.給Person添加方法
Person.prototype.personFunction = function () {
alert(this.personProperty)
}
// 5.給Person賦值新的原型對象
Person.prototype = new Animal()
// 6.創建Person對象, 並且調用方法
var person = new Person()
person.personFunction() // 不會有任何彈窗, 因爲找不到該方法
代碼解析:
-
執行上面的代碼不會出現任何的彈窗, 因爲我們添加的方法是無效的, 被賦值的新的原型覆蓋了.
-
正確的辦法是將第4步和第5步操作換一下位置即可.
總結
-
其實這個問題沒什麼好說的, 只要你理解了原型鏈(好好看看我上面畫的圖, 或者自己畫一下圖)
-
但是, 切記在看圖的過程中一樣掃過, 因爲這會讓你錯過很多細節, 對原型鏈的理解就會出現問題.
1.4. 原型鏈的問題
原型鏈對於繼承來說:
-
原型鏈似乎對初學JavaScript原型的人來說, 已經算是比較高明的設計技巧了, 有些人理解起來都稍微有些麻煩.
-
但是, 這種設計還存在一些缺陷, 不是最理性的解決方案. (但是後續的解決方案也是依賴原型鏈, 無論如何都需要先理解它)
原型鏈存在的問題:
-
原型鏈存在最大的問題是關於引用類型的屬性.
-
通過上面的原型實現了繼承後, 子類的person對象繼承了(可以訪問)Animal實例中的屬性(animalProperty).
-
但是如果這個屬性是一個引用類型(比如數組或者其他引用類型), 就會出現問題.
引用類型的問題代碼:
// 1.定義Animal的構造函數
function Animal() {
this.colors = ["red", "green"]
}
// 2.給Animal添加方法
Animal.prototype.animalFunction = function () {
alert(this.colors)
}
// 3.定義Person的構造函數
function Person() {
this.personProperty = "Person"
}
// 4.給Person賦值新的原型對象
Person.prototype = new Animal()
// 5.給Person添加方法
Person.prototype.personFunction = function () {
alert(this.personProperty)
}
// 6.創建Person對象, 並且調用方法
var person1 = new Person()
var person2 = new Person()
alert(person1.colors) // red,green
alert(person2.colors) // red,green
person1.colors.push("blue")
alert(person1.colors) // red,green,blue
alert(person2.colors) // red,green,blue
代碼解析:
-
我們查看第6步的操作
-
創建了兩個對象, 並且查看了它們的colors屬性
-
修改了person1中的colors屬性, 添加了一個新的顏色blue
-
再次查看兩個對象的colors屬性, 會發現person2的colors屬性也發生了變化
-
兩個實例應該是相互獨立的, 這樣的變化如果我們不制止將會在代碼中引發一些列問題.
原型鏈的其他問題:
-
在創建子類型的實例時,不能向父類型的構造函數中傳遞參數。
-
實際上,應該說是沒有辦法在不影響所有對象實例的情況下,給父類型的構造函數傳遞參數。
-
從而可以修改父類型中屬性的值, 在創建構造函數的時候就確定一個值.
二. 經典繼承
爲了解決原型鏈繼承中存在的問題, 開發人員提供了一種新的技術: constructor stealing(有很多名稱: 借用構造函數或經典繼承或僞造對象), steal是偷竊的意思, 但是這裏可以翻譯成借用.
2.1. 經典繼承的思想
經典繼承的做法非常簡單: 在子類型構造函數的內部調用父類型構造函數.
-
因爲函數可以在任意的時刻被調用
-
因此通過apply()和call()方法也可以在新創建的對象上執行構造函數.
經典繼承代碼如下:
// 創建Animal的構造函數
function Animal() {
this.colors = ["red", "green"]
}
// 創建Person的構造函數
function Person() {
// 繼承Animal的屬性
Animal.call(this)
// 給自己的屬性賦值
this.name = "Coderwhy"
}
// 創建Person對象
var person1 = new Person()
var person2 = new Person()
alert(person1.colors) // red,greem
alert(person2.colors) // red,greem
person1.colors.push("blue")
alert(person1.colors) // red,green,blue
alert(person2.colors) // red,green
代碼解析:
-
我們通過在Person構造函數中, 使用call函數, 將this傳遞進去.
-
這個時候, 當Animal中有相關屬性初始化時, 就會在this對象上進行初始化操作.
-
這樣就實現了類似於繼承Animal屬性的效果.
這個時候, 我們也可以傳遞參數, 修改上面的代碼:
// 創建Animal構造函數
function Animal(age) {
this.age = age
}
// 創建Person構造函數
function Person(name, age) {
Animal.call(this, age)
this.name = name
}
// 創建Person對象
var person = new Person("Coderwhy", 18)
alert(person.name)
alert(person.age)
2.2. 經典繼承的問題
經典繼承的問題:
-
對於經典繼承理解比較深入, 你已經能發現: 經典繼承只有屬性的繼承, 無法實現方法的繼承.
-
因爲調用call函數, 將this傳遞進去, 只能將父構造函數中的屬性初始化到this中.
-
但是如果函數存在於父構造函數的原型對象中, this中是不會有對應的方法的.
回顧原型鏈和經典繼承:
-
原型鏈存在的問題是引用類型問題和無法傳遞參數, 但是方法可以被繼承
-
經典繼承是引用類型沒有問題, 也可以傳遞參數, 但是方法無法被繼承.
-
怎麼辦呢? 將兩者結合起來怎麼樣?
三. 組合繼承
如果你認識清楚了上面兩種實現繼承的方式存在的問題, 就可以很好的理解組合繼承了.
組合繼承(combination inheritance, 有時候也稱爲僞經典繼承), 就是將原型鏈和經典繼承組合在一起, 從而發揮各自的優點.
3.1. 組合繼承的思想
組合繼承:
-
組合繼承就是發揮原型鏈和經典繼承各自的優點來完成繼承的實現.
-
使用原型鏈實現對原型屬性和方法的繼承.
-
通過經典繼承實現對實例屬性的繼承, 以及可以在構造函數中傳遞參數.
組合繼承的代碼:
// 1.創建構造函數的階段
// 1.1.創建Animal的構造函數
function Animal(age) {
this.age = age
this.colors = ["red", "green"]
}
// 1.2.給Animal添加方法
Animal.prototype.animalFunction = function () {
alert("Hello Animal")
}
// 1.3.創建Person的構造函數
function Person(name, age) {
Animal.call(this, age)
this.name = name
}
// 1.4.給Person的原型對象重新賦值
Person.prototype = new Animal(0)
// 1.5.給Person添加方法
Person.prototype.personFunction = function () {
alert("Hello Person")
}
// 2.驗證和使用的代碼
// 2.1.創建Person對象
var person1 = new Person("Coderwhy", 18)
var person2 = new Person("Kobe", 30)
// 2.2.驗證屬性
alert(person1.name + "-" + person1.age) // Coderwhy,18
alert(person2.name + "-" + person2.age) // Kobe,30
// 2.3.驗證方法的調用
person1.animalFunction() // Hello Animal
person1.personFunction() // Hello Person
// 2.4.驗證引用屬性的問題
person1.colors.push("blue")
alert(person1.colors) // red,green,blue
alert(person2.colors) // red,green
代碼解析:
-
根據前面學習的知識, 結合當前的代碼, 大家應該可以理解上述代碼的含義.
-
但是我還是建議大家一定要多手動自己來敲代碼, 來理解其中每一個步驟.
-
記住: 看懂, 聽懂不一定真的懂, 自己可以寫出來, 纔是真的懂了.
3.2. 組合繼承的分析
組合繼承是JavaScript最常用的繼承模式之一.
-
如果你理解到這裏, 點到爲止, 那麼組合來實現繼承只能說問題不大.
-
但是它依然不是很完美, 存在一些問題不大的問題.(不成問題的問題, 基本一詞基本可用, 但基本不用)
組合繼承存在什麼問題呢?
-
組合繼承最大的問題就是無論在什麼情況下, 都會調用兩次父類構造函數.
-
一次在創建子類原型的時候
-
另一次在子類構造函數內部(也就是每次創建子類實例的時候).
-
另外, 如果你仔細按照我的流程走了上面的每一個步驟, 你會發現: 所有的子類實例事實上會擁有兩份父類的屬性
-
一份在當前的實例自己裏面(也就是person本身的), 另一份在子類對應的原型對象中(也就是person.__proto__裏面)
-
當然, 這兩份屬性我們無需擔心訪問出現問題, 因爲默認一定是訪問實例本身這一部分的.
怎麼解決呢?
-
看起來組合繼承也不是非常完美的解決方案, 雖然也可以應用.
-
有沒有終極的解決方案呢? 預知後事如何, 且聽下回分解.