一步一步讀懂JS繼承模式

JachinZou發表於2018-10-02

JavaScript作為一種弱型別程式語言被廣泛使用於前端的各種技術中,由於JS中並沒有“類”的概念,所以js的OOP特性一直沒有得到足夠的重視,而且有相當一部分使用js的專案中採用的都是程式導向的程式設計方式。但是隨著專案規模的不斷擴大,程式碼量的不斷增加,這種方式會讓我們編寫很多重複的、無用的程式碼,並使得專案的擴充套件性、可讀性、可維護性變得脆弱。因此,js的OOP程式設計技巧則成為進階的一條必經之路。

開始之前
由於js在ES6之前並沒有 “類” 的概念,因此我們必須要了解這些特性(關係)。

  1. 在每使用function宣告一個函式的時候,我們稱這個函式為建構函式,js都會為我們自動建立一個原型物件。函式稱這個物件叫做prototype(老公),原型物件稱這個函式叫constructor(老婆)。
  2. 通過new關鍵字生成的物件就是這個函式的例項(Instance),這個例項稱原型物件為__proto__(爸爸),同時也繼承了原型物件的稱呼constructor(孩兒他娘)。
  3. 例項能夠繼承原型物件自身和繼承來的的所有屬性以及方法,同樣繼承到的constructor屬性指向建構函式。

至此,一個完整的家庭成員的關係已經構造出來了,並可以通過new關鍵字不斷繁衍生息,後代總是能繼承先輩的屬性與方法。看以下這段程式碼:

function SomeClass(value){
    this.value = value;
}
SomeClass.prototype.protoValu = 'prototype value';

var  Instance = new SomeClass('some value');
複製程式碼

這段程式碼通過new關鍵字例項化了一個物件Instance,這個物件繼承了原型物件的protoValue屬性,並擁有自身的value屬性。那麼例項化的new關鍵字到底起了什麼作用呢?

  • 新建一個空物件o,並將函式的執行時上下文繫結為這個物件。(使得this指向o)
  • 使得o的__proto__指向SomeClass.prototype。(emmm應該是認爸爸)
  • 執行建構函式內容,給物件o新增屬性與方法。(長出手和腳)
  • 判斷建構函式是否有return語句,如果有執行return,如果沒有則執行return o。(出生)

new關鍵字實際上起的作用就是,創造例項、繁衍後代的作用。

類式繼承

function SupperClass(value){
    this.value = value;
    this.fn = function(){
        console.log(this.value);
    }
}
SupperClass.prototype.otherValue = 'other value';
//宣告父類

function SubClass(value){
    this.subValue = value;
}
SubClass.prototype = new SupperClass("I'm supper value");
//宣告子類,並使得子類繼承自SupperClass
//以上為宣告階段

//通過以下方式使用
var Instance = new SubClass("I'm sub value");
console.log(Instance.value);
console.log(Instance.otherValue);
console.log(Instance.subValue);
Instance.fn();
複製程式碼

一步一步讀懂JS繼承模式

但是這種方式存在著一些問題:

  • 子類繼承自父類的例項,而例項化父類的過程在宣告階段,因此在實際使用過程中無法根據實際情況向父類穿參。因此,這種方式的的可擴充套件性不理想。
  • 子類的家庭關係不完善。Instance.constructor = SupperClass,因為SubClass並沒有constructor屬性,所以最終會從SupperClass.prototype處繼承得到該屬性。
  • 不能為SubClass.prototype設定constructor屬性,該屬性會造成屬性遮蔽,導致SubClass.prototype不能正確獲取自己的constructor屬性,畢竟SubClass.prototype實際上也是SupperClass的例項。

建構函式繼承

function SupperClass(value1){
    this.xx = value1;
}
function SubClass(value1,value2){
    SupperClass.call(this,value1);
    this.xx = value2;
}

//實際使用
var Instance = new SubClass('value1','value2');
複製程式碼

建構函式繼承方式的本質就是將父類的構造方法在子類的上下文環境執行一次,從而達到複製父類屬性的目的,在這個過程中並沒有構造出一條完整的原型鏈。

雖然建構函式繼承解決了類式繼承的不能實時向父類傳參的問題,但是由於其沒有一條完整的原型鏈,因此 子類不能繼承父類的原型屬性與原型方法 。我認為它只是一個實現了繼承功能的一種方式,並非真正的繼承。

組合式繼承--完美的繼承方式

function SupperClass(value){
    this.value = value;
    this.fn = function(){
        console.log(this.value);
    }
}
SupperClass.prototype.otherValue = 'other value';
//宣告父類

function SubClass(value1,value2){
    SupperClass.call(this,value1)
    this.subValue = value2;
}
SubClass.prototype = new SupperClass("I'm supper value");
//宣告子類,並使得子類繼承自SupperClass
//以上為宣告階段

//通過以下方式使用
var Instance = new SubClass("I'm supper value","I'm sub value");
複製程式碼

組合式繼承集合了以上兩種繼承方式的優點,從而實現了“完美”繼承所有屬性並能動態傳參的功能。但是這種方式仍然不能補齊子類的家庭成員關係,因為SubClass.prototype仍然是父類的例項。

另外一點,相信大家也已經發現了,整個繼承過程中實際上呼叫了兩次父類的構造方法,使得SubClass.prototype與Instance都有一份父類的自有屬性/方法,這樣會造成額外的效能開銷,但是好在能夠完整的實現繼承的目的了。

原型式繼承

原型式繼承又被成為純潔繼承,它的重點只關注物件與物件之間的繼承關係,淡化了類與建構函式的概念,這樣能避免開發者花費過多的精力去維護類與類/類與原型之間的關係,從而將重心轉移到開發業務邏輯上面來。

var supperObj = {
    key1: 'value',
    func: function(){
        console.log(this.key1);
    }
}

function Factory(obj){
    function F(){}
    F.prototype = obj;
    return new F()
}

//實際使用方法
//var Instance = new Factory(supperObj);
var Instance = Factory(supperObj);
複製程式碼

原型式繼承因為只關注與物件與物件之間的關係,因此大多數都是使用工廠函式的方法生成繼承物件。在工廠函式中我們 定義了一箇中間函式(會被釋放),並將這個函式的原型指向被繼承的物件,因此通過這個函式生成的物件的__proto__也就指向了被繼承物件。

一步一步讀懂JS繼承模式

在工廠函式內部實現繼承的方式與類式繼承實現的原理是一樣的,區別在於原型式繼承更加純淨,因此原型繼承方式具有類式繼承方式所有的缺點:

  • 無法根據使用的實際情況動態生成supperObj(無法動態傳參)。
  • 雖然實現了物件的繼承,但是生成的子類還沒有新增自己的屬性與方法。

同時原型繼承也有以下優點:

  • 由於其純潔性,開發者不必再去維護constructor與prototype屬性,僅僅只需要關注原型鏈。
  • 更少的記憶體開銷。

寄生式繼承--原型式繼承的二次封裝

在原型繼承中,每執行一次工廠函式都會重新生成一個新的中間函式F,並在函式結束時被回收,像我這種強迫症患者是不太能接受這種方式的。所幸,ES5提供了Object.create(),並且在原型式繼承,以及多繼承中起著重要的作用。在寄生式繼承中我們會對原型繼承做一次優化。

var supperObj = {
    key1: 'value',
    func: function(){
        console.log(this.key1);
    }
}
function inheritPrototype(obj,value){
    //var subObj = Factory(obj);
    var subObj = Object.create(obj);
    subObj.name = value;
    subObj.say = function(){
        console.log(this.name);
    }
    return subObj;
}

var Instance = inheritPrototype(supperObj,'sub');
Instance.func();
Instance.say();
複製程式碼

寄生式繼承實際上就是對原型式繼承的二次封裝,在這次封裝過程中實現了根據提供的引數新增子類的自定義屬性。但是缺點仍然存在,被繼承物件無法動態生成

因為原型式繼承是基於物件的繼承,物件是無法接收引數的,因此要解決這個問題還要回到建構函式的問題上面來。

將類式繼承與寄生式繼承結合

function inheritPrototype(sub,sup){
    var obj = Object.create(sup.prototype);
    sub.prototype = obj;
    obj.constructor = sub;
    Object.defineProperty(obj,'constructor',{enumerable: false});
    //將constructor屬性變為不可遍歷,避免多繼承時出現問題
}

function SupperClass(value1){
    this.supperValue = value1;
    this.func1 = function(){
        console.log(1);
    }
}
SupperClass.prototype.func2 = function(){
    console.log(this.supperValue);
}
//宣告父類

function SubClass(value2){
    this.subValue = value2;
    this.func3 = function(){
        console.log(this.subValue);
    }
}
//宣告子類

inheritPrototype(SubClass,SupperClass);
var Instance = new SubClass('sub');
console.log(Instance.supperValue);  //undefined
console.log(Instance.subValue); //sub
Instance.func1();   //Error
Instance.func2();   //undefined
Instance.func3();   //sub
複製程式碼

一步一步讀懂JS繼承模式

在這種方式中,由於obj物件並不是SupperClass的例項,因此可以與SubClass維護一個完整的關係(prototype與constructor),在維護關係的同時 一定要修改constructor的可列舉屬性

在維護了建構函式與原型之間的完整關係的同時,也有一個致命的缺陷----由於obj物件不是SupperClass的例項,所以在例項化子類的時候父類建構函式從未被呼叫過,因此 子類只能繼承到父類原型屬性與方法,無法繼承到父類自有方法。

寄生組合繼承

寄生組合繼承就是將經過改良之後的寄生繼承與建構函式繼承方式組合,從而彌補寄生繼承無法繼承父類自有屬性與方法的缺陷。

function SubClass(value1,value2){
    SupperClass.call(this,value1);
    this.subValue = value2;
    this.func3 = function(){
    	console.log(this.subValue);
    }
}
//宣告子類

var Instance = new SubClass('sup','sub');
複製程式碼

一步一步讀懂JS繼承模式

組合之後,只用在SubClass中呼叫一次SupperClass的建構函式。本質上父類原型屬性與原型方法是通過原型鏈來繼承的,父類的自有方法是通過呼叫建構函式複製到自身實現繼承的。

寄生組合繼承不僅完美的實現了屬性與方法的繼承,也避免了組合繼承產生重複屬性造成效能浪費,另外也支援建立子類時動態向父類傳參。在大型專案中合理運用這種方式實現類的繼承能夠顯著提升程式碼的可閱讀性,以及可擴充套件性。

參考

《JavaScript設計模式》
《你不知道的JavaScript》

相關文章