JavaScript 學習之繼承

nicole_zhang發表於2018-07-17

Javascript 的繼承的實現方法有很多種,之前雖然學習過,但是沒有綜合整理過,這一次就來整理整理 Javascript 語言的繼承方面的知識。關於詳細的Javascript 的繼承方面的知識,推薦大家去看那本紅寶書 ————《JavaScript高階程式設計》。

雖然 ES6 推出了 class 這個概念,方便了我們開發人員的學習和理解,但是,class 只是一個語法糖,實際上底層的實現還是原來的那一套,利用原型鏈和建構函式來實現繼承。因此要想 Javascript 的基本功牢實一點,還是需要去學習這些知識的。

在 Javascript 的繼承實現裡,目前有原型鏈繼承法,建構函式繼承法,組合繼承法等等方法,下面我就一一對這些方法來進行說明。

1. 原型鏈繼承

原型鏈繼承法是運用 Javascript 的原型來實現,在 Javascript 中任意函式都擁有 prototype__proto__ 這兩個屬性,而每個物件都擁有一個 __proto__ 屬性,物件裡 __proto__ 屬性的值是來自於構造這個物件的函式的 prototype 屬性,通過 prototype__proto__ ,我們構造出原型鏈,然後利用原型鏈來實現繼承。

具體的程式碼例子如下

function Animal() {
    this.type = 'Cat'
    this.name = 'Nini'
    this.hobbies = ['eat fish', 'play ball']
}
Animal.prototype.say = function () {
    console.log('type is ' + this.type + ' name is ' + this.name);
}

function Cat() {
    this.age = '1'
}
Cat.prototype = new Animal()
Cat.prototype.constructor = Cat

let smallCat = new Cat()
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
smallCat.say() // type is Cat name is Nini

let bigCat = new Cat()
console.log(bigCat.hobbies) // [ 'eat fish', 'play ball', 'sleep' ]
複製程式碼

從上面的例子我們可以看到,原型鏈繼承的優點:

  • 多個例項共同引用可複用的屬性和方法,不是建立每一個例項的時候再建立一遍這些資料

缺點:

  • 所有的屬性都被例項所共享,這意味著如果屬性是基本資料型別的話,例項是無法修改這個屬性的值,因為例項會新增一個同名的屬性,我們只能對新增的屬性進行操作,以剛剛的程式碼為例
smallCat.name = 'Kiki' // 此時 smallCat 物件上新增了 name 屬性,如果訪問這個屬性的話,我們得到是這個新增的屬性而不是在原型上的 name 屬性
console.log(smallCat.name) // 'Kiki'
console.log(bigCat.name) // 'Nini'
複製程式碼

如果屬性是引用屬性的話,修改這個屬性所指向的資料裡的內容將會影響所有的例項(注意不是對屬性直接賦值,如果直接賦值了就像基本資料型別一樣,在例項本身上新建一個同名屬性),如之前的程式碼例項

smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
console.log(bigCat.hobbies) // [ 'eat fish', 'play ball', 'sleep' ]
複製程式碼

2. 建構函式繼承

建構函式繼承的基本原理就是利用 call, apply 這樣的可以指定函式 this 值的方法,來實現子類對父類屬性的繼承,例子如下

function Animal(type, name) {
    this.type = type
    this.name = name
    this.hobbies = ['eat fish', 'play ball']
}
function Cat(type, name) {
    Animal.call(this, type, name)
    this.age = '1'
    this.say = () => {
        console.log('type is ' + this.type + ' name is ' + this.name);
    }
}
let smallCat = new Cat('Cat', 'Nini')
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
smallCat.say() // type is Cat name is Nini

let bigCat = new Cat('Cat', 'Nicole')
console.log(bigCat.hobbies) // [ 'eat fish', 'play ball' ]
bigCat.say() // type is Cat name is Nicole
複製程式碼

從上面的例子可以看到,建構函式繼承的優點是

  • 所有的例項沒有共享引用屬性,也就是說每個例項都獨立擁有一份從父類那裡繼承來的屬性,任一個例項修改了引用屬性裡的資料內容,並不會影響到其他的例項

  • 可向父函式傳參

缺點:

  • 由於所有的屬性和方法都不再被所有的例項共享,因此那些公有的屬性和方法就會被重複的建立,造成了記憶體的額外開銷

3. 組合繼承 (原型鏈繼承和建構函式繼承的合體)

其實通過之前的分析,可以知道,無論是原型鏈繼承還是建構函式繼承,都存在自己的優缺點,對於我們的開發實現而言,都是不完美的。原型鏈繼承把所有的屬性和方法都共享給了所有的例項,也就是說,我們想要個性化的針對某一例項上所繼承的引用屬性的資料內容進行修改的話,這一操作將同時影響別的例項,這可能會給我們的開發帶來一定的問題。建構函式繼承把所有的屬性和方法都為每個例項單獨拷貝了一份,雖然實現了例項之間的資料隔離,但是對於那些本來就應該是公共的屬性和方法來說,重複而無意義的複製也無疑是增加了額外的記憶體開銷。

因此,組合繼承方法吸收了這兩個方法的優點,同時避免了各自的缺點,是一種可行的實現繼承的方法,實現的程式碼如下

function Animal(type, name) {
    this.type = type
    this.name = name
    this.hobbies = ['eat fish', 'play ball']
}
Animal.prototype.say = function () {
    console.log('type is ' + this.type + ' name is ' + this.name);
}
function Cat(type, name) {
    Animal.call(this, type, name) // 建構函式繼承
    this.age = '1'
}
Cat.prototype = new Animal() // 原型鏈繼承
Cat.prototype.constructor = Cat

let smallCat = new Cat('smallCat', 'Nini')
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
smallCat.say() // type is smallCat name is Nini

let bigCat = new Cat('bigCat', 'Nicole')
console.log(bigCat.hobbies); // [ 'eat fish', 'play ball' ]
bigCat.say() // type is bigCat name is Nicole
複製程式碼

組合繼承方法的思路是將公共的屬性和方法放在父類的 prototype 上,然後利用原型鏈繼承來實現公共的屬性和方法的繼承,而對於那種每個例項都可自定義修改的屬性採取建構函式繼承的方法來實現每個例項都獨有一份這樣的屬性。

4. 原型式繼承

原型式繼承的實現原理就是將一個物件作為建立物件的原型傳入到一個構建新物件的函式中,比如

function createObj(o) {
    function F(){}
    F.prototype = o;
    return new F();
}
複製程式碼

其實原型式繼承的思路也就是 Object.create() 方法的實現思路,來看看一個完整的原型式繼承的實現,程式碼如下

let Animal = {
    type: 'Cat',
    name: 'Nini',
    hobbies: ['eat fish', 'play ball']
}

function createCat(o) {
    function F() {}
    F.prototype = o
    return new F()
}

let smallCat = createCat(Animal)
let bigCat = createCat(Animal)
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
console.log(bigCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
bigCat.name = 'Nicole' // 直接在 bigCat 這個物件上新增一個 name 屬性,並非去修改原型上的 name 屬性
console.log(smallCat.name); // 'Nini'
console.log(bigCat.name); // 'Nicole'
console.log(bigCat.__proto__.name); // 'Nini' 原型上的 name 屬性依舊保持
複製程式碼

原型式繼承法其實和原型鏈繼承有點相似,都是所有的屬性和方法放在了原型上,如果建立所有的例項時都用的是同一個物件作為原型的話,那麼原型鏈繼承遇到的問題,這個方法同樣也有。

關於原型式繼承的更多思考

在學習原型式繼承的時候,我想到了如果建立每個例項的時候,傳入的父類物件都是不同的物件,但是都是同屬於一個父類的物件,那麼如果我們將公共的屬性和方法放在父類的原型上,把可自定義的屬性放在父類的建構函式上,那也可以實現比較合理的繼承,具體程式碼如下

function Animal(type, name) {
    this.type = type
    this.name = name
    this.hobbies = ['eat fish', 'play ball']
}
Animal.prototype.say = function () {
    console.log('type is ' + this.type + ' name is ' + this.name);
}
function createCat(o) {
    function F() {}
    F.prototype = o
    return new F()
}

let smallCat = createCat(new Animal('smallCat', 'Nini'))
let bigCat = createCat(new Animal('bigCat', 'Nicole'))
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
console.log(bigCat.hobbies); // [ 'eat fish', 'play ball' ]
複製程式碼

這個思路看起來不錯,但是仔細想想還是有一定的問題的,相比於之前提到的組合式繼承來說,這個方法每次在建立例項的時候,我們都會 new 一個新的父類例項,這其實造成了記憶體的浪費,而組合繼承則保證了父類的例項只會被 new 一次,而那些可以自定義的屬性都被存在每個子類的例項中,保證了資料的互不影響,我們可以通過下面的圖片來看看具體的差異

JavaScript 學習之繼承

5.寄生式繼承

寄生式繼承其實和原型式繼承的實現有些相似,不過寄生式繼承在原型式繼承的基礎上新增了在建立例項的函式中以某種形式來增強物件,最後返回物件。其實意思就是,在建立子例項的函式中,先通過原型式繼承的方法建立一個例項,然後為這個例項新增屬性和方法,最後返回這個例項,程式碼例項如下

function createCat(o) {
    let cloneObj = Object.create(o)
    cloneObj.say = function (){ // 為例項新增一個 say 方法
        console.log('type is ' + this.type + ' name is ' + this.name);
    }
    return cloneObj
}

let Animal = {
    type: 'Cat',
    name: 'Nini',
    hobbies: ['eat fish', 'play ball']
}

let smallCat = createCat(Animal)
let bigCat = createCat(Animal)
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
console.log(bigCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
smallCat.say() // type is Cat name is Nini
bigCat.say() // type is Cat name is Nini
複製程式碼

通過上面程式碼我們可以很清楚的看到,寄生式繼承有原型鏈繼承的缺點和建構函式繼承的缺點,也就是說通過寄生式繼承創造出來的例項,如果修改了它原型上的引用屬性裡的內容,其他的例項也會受影響,而且每次建立例項的時候,那些公共的屬性和方法都會被建立一次。

6. 寄生組合式繼承

上面我們提到了組合式繼承是一種還不錯的繼承實現方式,既能讓每個例項擁有繼承來的可自定義的屬性和方法,也能共享公共的方法和屬性。但是這種方法還有能夠優化的地方,這個需要優化的點在於,組合式繼承時,父類的建構函式會被呼叫兩次,結合程式碼看一下

function Cat(type, name) {
    Animal.call(this, type, name) // 這裡呼叫了一次父類的建構函式
    this.age = '1'
}
Cat.prototype = new Animal() // 這裡也呼叫了一次父類的建構函式
Cat.prototype.constructor = Cat
複製程式碼

實際上,子函式的 prototype 只需要指向那些公共的屬性和方法就可以了,不需要指向整個父函式的例項,由於我們把需要繼承的公共的屬性和方法放在了父函式prototype 上,所以我們可以考慮讓子函式的 prototype 間接訪問父函式的 prototype。實現的程式碼例子如下

// 利用寄生式繼承來讓子函式的 prototype 能訪問到父函式的原型
function createObj(child, parent) {
    let prototype = Object.create(parent.prototype) 
    // 這個物件相比於父例項少了那些子函式已通過parent.call 繼承到的屬性和方法,僅僅含有一個指向父函式原型的屬性
    prototype.constructor = child
    child.prototype = prototype
}
createObj(Cat, Animal)
複製程式碼

最後,完整的寄生組合式繼承的實現程式碼如下

function Animal(type, name) {
    this.type = type
    this.name = name
    this.hobbies = ['eat fish', 'play ball']
}
Animal.prototype.say = function () {
    console.log('type is ' + this.type + ' name is ' + this.name);
}
function Cat(type, name) {
    Animal.call(this, type, name)
    this.age = '1'
}

function createObj(child, parent) {
    let prototype = Object.create(parent.prototype)
    prototype.constructor = child
    child.prototype = prototype
}
createObj(Cat, Animal)

let smallCat = new Cat('smallCat', 'Nini')
smallCat.hobbies.push('sleep')
console.log(smallCat.hobbies); // [ 'eat fish', 'play ball', 'sleep' ]
smallCat.say() // type is smallCat name is Nini

let bigCat = new Cat('bigCat', 'Nicole')
console.log(bigCat.hobbies); // [ 'eat fish', 'play ball' ]
bigCat.say() // type is bigCat name is Nicole
複製程式碼

因此寄生組合式繼承在吸取了組合式繼承的優點上,避免了在子函式的原型上面建立不必要的、多餘的屬性,而寄生組合式繼承也是目前的一種理想的比較好的繼承方法的實現。

總結

其實 Javascript 繼承的關鍵點是一定要將私有的屬性和方法,公有的屬性和方法分別處理,私有的屬性和方法需要讓每個例項都獨有一份,保證資料的更改互不影響,公有的屬性和方法需要放在父類的原型上,確保不重複建立。

相關文章