JavaScript設計模式之物件導向程式設計

Chris威發表於2018-09-01

為了深入地學習 javascript ,奔著一名標準 Web 開發人員的標準,想要深入瞭解一下物件導向的程式設計思想,提高自己模組化開發的能力,編寫可維護、高效率、可擴充的程式碼,最近一直拜讀 《JavaScript設計模式》 ,對其重點內容做了歸納與總結,如有總結的不詳細或者理解不透徹的,還望批評斧正~

什麼是物件導向程式設計(OOP)?

簡單來說,物件導向程式設計就是將你的需求抽象成一個物件,然後對這個物件進行分析,為其新增對應的特徵(屬性)與行為(方法),我們將這個物件稱之為 。 物件導向一個很重要的特點就是封裝,雖然 javascript 這種解釋性的弱型別語言沒有像一些經典的強型別語言(例如C++,JAVA等)有專門的方式用來實現類的封裝,但我們可以利用 javascript 語言靈活的特點,去模擬實現這些功能,接下里我們就一起來看看~

封裝

  • 建立一個類

javascript 中要建立一個類是很容易的,比較常見的方式就是首先宣告一個函式儲存在一個變數中(一般類名首字母大寫),然後將這個函式(類)的內部通過對 this 物件新增屬性或者方法來實現對類進行屬性或方法的新增,例如:

//建立一個類
var Person = function (name, age ) {
	this.name = name;
	this.age = age;
}
複製程式碼

我們也可以在類的原型物件(prototype)上新增屬性和方法,有兩種方式,一種是一一為原型物件的屬性賦值,以一種是將一個物件賦值給類的原型物件:

//為類的原型物件屬性賦值
Person.prototype.showInfo = function () {
    //展示資訊
    console.log('My name is ' + this.name , ', I\'m ' + this.age + ' years old!');
}

//將物件賦值給類的原型物件
Person.prototype = {
    showInfo : function () {
	    //展示資訊
	    console.log('My name is ' + this.name , ', I\'m ' + this.age + ' years old!');
	}
}
複製程式碼

這樣我們就將所需要屬性和方法都封裝在 Person 類裡面了,當我們要用的時候,首先得需要使用 new 關鍵字來例項化(建立)新的物件,通過 . 操作符就可以使用例項化物件的屬性或者方法了~

var person = new Person('Tom',24);
console.log(person.name)        // Tom
console.log(person.showInfo())  // My name is Tom , I'm 24 years old!
複製程式碼

我們剛說到有兩種方式來新增屬性和方法,那麼這兩種方式有啥不同呢?

通過 this 新增的屬性和方法是在當前物件新增的,而 javascript 語言的特點是基於原型 prototype 的,是通過 原型prototype 指向其繼承的屬性和方法的;通過 prototype 繼承的方法並不是物件自身的,使用的時候是通過 prototype 一級一級查詢的,這樣我們通過 this 定義的屬性或者方法都是該物件自身擁有的,我們每次通過 new 運算子建立一個新物件時, this 指向的屬性和方法也會得到相應的建立,但是通過 prototype 繼承的屬性和方法是每個物件通過 prototype 訪問得到,每次建立新物件時這些屬性和方法是不會被再次建立的,如下圖所示:

JavaScript設計模式之物件導向程式設計

其中 constructor 是一個屬性,當建立一個函式或者物件的時候都會給原型物件建立一個 constructor 屬性,指向擁有整個原型物件的函式或者物件。

如果我們採用第一種方式給原型物件(prototype)上新增屬性和方法,執行下面的語句會得到 true

    console.log(Person.prototype.constructor === Person ) // true
複製程式碼

那麼好奇的小夥伴會問,那我採用第二種方式給原型物件(prototype)上新增屬性和方法會是什麼結果呢?

    console.log(Person.prototype.constructor === Person ) // false
複製程式碼

臥槽,什麼鬼,為什麼會產生這種結果?

原因在於第二種方式是將一整個物件賦值給了原型物件(prototype),這樣會導致原來的原型物件(prototype)上的屬性和方法會被全部覆蓋掉(pass: 實際開發中兩種方式不要混用),那麼 constructor 的指向當然也發生了變化,這就導致了原型鏈的錯亂,因此,我們需要手動修正這個問題,在原型物件(prototype)上手動新增上 constructor 屬性,重新指向 Person ,保證原型鏈的正確,即:

    Person.prototype = {
		constructor : Person ,
		showInfo : function () {
			//展示資訊
			console.log('My name is ' + this.name , ', I\'m ' + this.age + ' years old!');
		}
    }
    
    console.log(Person.prototype.constructor === Person ) // true

複製程式碼
  • 屬性與方法的封裝

在大部分物件導向的語言中,經常會對一些類的屬性和方法進行隱藏和暴露,所以就會有 私有屬性、私有方法、公有屬性、公有方法等這些概念~

ES6 之前, javascript 是沒有塊級作用域的,有函式級作用域,即宣告在函式內部的變數和方法在外部是無法訪問的,可以通過這個特性模擬建立類的 私有變數私有方法 ,而函式內部通過 this 建立的屬性和方法,在類建立物件的時候,每個物件都會建立一份並可以讓外界訪問,因此我們可以將通過 this 建立的屬性和方法看作是 例項屬性例項方法,然而通過 this 建立的一些方法們不但可以訪問物件公有屬性和方法,還能訪問到類(建立時)或物件自身的私有屬性和私有方法,由於權力這些方法的權力比較大,因此成為 特權方法 ,通過 new 建立的物件無法通過 . 運算子訪問類外面新增的屬性和和方法,只能通過類本身來訪問,因此,類外部定義的屬性和方法被稱為類的 靜態公有屬性靜態公有方法 , 通過類的原型 prototype 物件新增的屬性和方法,其例項物件都是通過 this 訪問到的,所以我們將這些屬性和方法稱為 公有屬性公有方法,也叫 原型屬性原型方法

        //建立一個類
	var Person = function (name, age ) {
    	    //私有屬性
    	    var IDNumber = '01010101010101010101' ;
    	    //私有方法
            function checkIDNumber () {}
            //特權方法
            this.getIDNumber = function () {}
            //例項屬性
            this.name = name;
            this.age = age;
            //例項方法
            this.getName = function () {}
	}

	//類靜態屬性
        Person.isChinese = true;
	//類靜態方法
        Person.staticMethod = function () {
            console.log('this is a staticMethod')
        }

        //公有屬性
	Person.prototype.isRich = false;
	//公有方法
        Person.prototype.showInfo = function () {}
複製程式碼

通過 new 建立的物件只能訪問到對應的 例項屬性 、例項方法 、原型屬性 和 原型方法 ,而無法訪問到類的靜態屬性和私有屬性,類的私有屬性和私有方法只能通過類自身方法,即:

        var person = new Person('Tom',24);

        console.log(person.IDNumber) // undefined
        console.log(person.isRich)  // false
        console.log(person.name) // Tom
        console.log(person.isChinese) // undefined
        
        console.log(Person.isChinese) // true
        console.log(Person.staticMethod()) // this is a staticMethod
複製程式碼
  • 建立物件的安全模式

我們在建立物件的時候,如果我們習慣了 jQuery 的方式,那麼我們很可能會在例項化物件的時候忘記用 new 運算子來構造,而寫出來下面的程式碼:

        //建立一個類
	var Person = function (name, age ) {
		this.name = name;
		this.age = age;
	}
	
	var person = Person('Tom',24)
複製程式碼

這時候 person 已經不是我們期望的那樣,是 Person 的一個例項了~

    console.log(person)  // undifined
複製程式碼

那麼我們建立的 nameage 都不翼而飛了,當然不是,他們被掛到了 window 物件上了,

    console.log(window.name)  // Tom
    console.log(window.age)   // 24
複製程式碼

我們在沒有使用 new 操作符來建立物件,當執行 Person 方法的時候,這個函式就在全域性作用域中執行了,此時 this 指向的也就是全域性變數,也就是 window 物件,所以新增的屬性都會被新增到 window 上,而我們的 person 變數在得到 Person 的執行結果時,由於函式中沒有 return 語句, 預設返回了 undifined

為了避免這種問題的存在,我們可以採用安全模式解決,稍微修個一下我們的類即可,

    //建立一個類
	var Person = function (name, age) {
		// 判斷執行過程中的 this 是否是當前這個物件 (如果為真,則表示是通過 new 建立的)
		if ( this instanceof Person ) {
			this.name = name;
			this.age = age;
		} else {
			// 否則重新建立物件
			return new Person(name, age)
		}
	}
複製程式碼

ok,我們現在測試一下~

	var person = Person('Tom', 24)
	console.log(person)         // Person
	console.log(person.name)    // Tom
	console.log(person.age)     // 24
	console.log(window.name)    // undefined
	console.log(window.age)     // undefined
複製程式碼

這樣就可以避免我們忘記使用 new 構建例項的問題了~

pass:這裡我用的 window.name ,這個屬性比較特殊,它是 window 自帶的,用於設定或返回存放視窗的名稱的一個字串,注意更換~

繼承

繼承也是面型物件的一大特徵,但是 javascript 中沒有傳統意義上的繼承,但是我們依舊可以藉助 javascript 的語言特色,模擬實現繼承

類式繼承

比較常見的一種繼承方式,原理就是我們是例項化一個父類,新建立的物件會複製父類建構函式內的屬性和方法,並將圓形 __proto__ 指向父類的原型物件,這樣就擁有了父類原型物件上的方法和屬性,我們在將這個物件賦值給子類的原型,那麼子類的原型就可以訪問到父類的原型屬性和方法,進而實現了繼承,其程式碼如下:

    //宣告父類
    function Super () {
        this.superValue = 'super';
    }
    //為父類新增原型方法
    Super.prototype.getSuperValue = function () {
        return this.superValue;   
    }
	
    //宣告子類
    function Child () {
        this.childValue = 'child';
    }
	
    //繼承父類
    Child.prototype = new Super();
    //為子類新增原型方法
    Child.prototype.getChildValue = function () {
        return this.childValue;
    }
複製程式碼

我們測試一下~

    var child = new Child();
    console.log(child.getSuperValue());  // super
    console.log(child.getChildValue());  // child
複製程式碼

但是這種繼承方式會有兩個問題,第一由於子類通過其原型 prototype 對其父類例項化,繼承父類,只要父類的公有屬性中有引用型別,就會在子類中被所有例項共用,如果其中一個子類更改了父類建構函式中的引用型別的屬性值,會直接影響到其他子類,例如:

    //宣告父類
    function Super () {
    	this.superObject = {
    		a: 1,
    		b: 2
    	}
    }


    //宣告子類
    function Child () {}
    
    //繼承父類
    Child.prototype = new Super();
    }

    var child1 = new Child();
    var child2 = new Child();
    console.log(child1.superObject);    // { a : 1 , b : 2 }
    child2.superObject.a = 3 ;
    console.log(child1.superObject);    // { a : 3,  b : 2 }
複製程式碼

這會對後面的操作造成很大困擾!

第二,由於子類是通過原型 prototype 對父類的例項化實現的,所以在建立父類的時間,無法給父類傳遞引數,也就無法在例項化父類的時候對父類建構函式內部的屬性進行初始化操作。

為了解決這些問題,那麼就衍生出其他的繼承方式。

建構函式繼承 利用 call 這個方法可以改變函式的作用環境,在子類中呼叫這個方法,將子類中的變數在父類中執行一遍,由於父類中是給 this 繫結的, 因此子類也就繼承了父類的例項屬性,即:

    //宣告父類
    function Super (value) {
    	this.value = value;
    	this.superObject = {
    		a: 1,
    		b: 2
    	}
    }

    //為父類新增原型方法
    Super.prototype.showSuperObject = function () {
    	console.log(this.superValue);
    }

    //宣告子類
    function Child (value) {
    	// 繼承父類
        Super.call(this,value)
    }
    
    var child1 = new Child('Tom');
    var child2 = new Child('Jack');

    child1.superObject.a = 3 ;
    console.log(child1.superObject);    // { a : 3 , b : 2 }
    console.log(child1.value)           // Tom
    console.log(child2.superObject);    // { a : 1,  b : 2 }
    console.log(child2.value);          // Jack
複製程式碼

Super.call(this,value) 這段程式碼是建構函式繼承的精華,這樣就可以避免類式繼承的問題了~

但這種繼承方式沒有涉及到原型 prototype , 所以父類的原型方法不會得到繼承,而如果要想被子類繼承,就必須要放到建構函式中,這樣建立出來的每個例項都會單獨擁有一份,不能共用,為了解決這個問題,有了 組合式繼承。

組合式繼承

我們只要在子類的建構函式作用環境中執行一次父類的建構函式,在將子類的原型 prorotype 對父類進行例項化一次,就可以實現 組合式繼承 , 即:

    //宣告父類
    function Super (value) {
    	this.value = value;
    	this.superObject = {
    		a: 1,
    		b: 2
    	}
    }
    
    //為父類新增原型方法
    Super.prototype.showSuperObject = function () {
    	console.log(this.superObject);
    }
    
    //宣告子類
    function Child (value) {
    	// 建構函式式繼承父類 value 屬性
        Super.call(this,value)
    }
    
    //類式繼承
    Child.prototype = new Super();
    
    var child1 = new Child('Tom');
    var child2 = new Child('Jack');
    
    child1.superObject.a = 3 ;
    console.log(child1.showSuperObject());      // { a : 3 , b : 2 }
    console.log(child1.value)                   // Tom
    child1.superObject.b = 3 ;
    console.log(child2.showSuperObject());      // { a : 1,  b : 2 }
    console.log(child2.value);                  // Jack
複製程式碼

這樣就能融合類式繼承和建構函式繼承的有點,並且過濾掉其缺點。 看起來是不是已經很完美了,NO , 細心的同學可以發現,我們在使用建構函式繼承時執行了一遍父類的建構函式,而在實現子類原型的類式繼承時又呼叫了一父類的建構函式,那麼父類的建構函式執行了兩遍,這一點是可以繼續優化的。

寄生組合式繼承

我們上面學習了 組合式繼承 ,也看出了這種方式的缺點,所以衍生出了 寄生組合式繼承 ,其中 寄生 是寄生式繼承 ,而寄生式繼承依託於原型式繼承,因此學習之前,我們得了解一下 原型式繼承寄生式繼承

原型式繼承跟類式繼承類似,當然也存在同樣的問題,程式碼如下:

    //原型式繼承
    function inheritObject (o) {
        // 宣告一個過渡函式物件
        function F () {}
    	// 過渡物件的原型繼承父物件
    	F.prototype = o;
        // 返回過渡物件的一個例項,該例項的原型繼承了父物件
    	return new F();
    }
    var Super = {
    	name : 'Super' ,
        object : {
    		a : 1 ,
            b : 2
        }
    }
    var child1 = inheritObject(Super);
    var child2 = inheritObject(Super);
    console.log(child1.object) // { a : 1 , b : 2 }
    child1.object.a = 3 ;
    console.log(child2.object) // { a : 3 , b : 2 }
複製程式碼

寄生式繼承是對原型繼承的第二次封裝,並在封裝過程中對物件進行了擴充,新物件就有了新增的屬性和方法,實現方式如下:

    //原型式繼承
    function inheritObject (o) {
        // 宣告一個過渡函式物件
        function F () {}
    	// 過渡物件的原型繼承父物件
    	F.prototype = o;
        // 返回過渡物件的一個例項,該例項的原型繼承了父物件
    	return new F();
    }
    
    // 寄生式繼承
    // 宣告基物件
    var Super = {
    	name : 'Super' ,
        object : {
    		a : 1 ,
            b : 2
        }
    }
    
    function createChild (obj) {
    	// 通過原型繼承建立新物件
        var o = new inheritObject(obj);
        // 擴充新物件
        o.getObject = function () {
            console.log(this.object)
    	}
    	return o;
    }
複製程式碼

我們將兩者的特點結合起來就出現了寄生組合式繼承,通過借用建構函式來繼承屬性,通過原型鏈的混成形式來繼承方法,

    /**
    * 寄生組合式繼承
    * 傳遞引數
    *   childClass 子類
    *   superClass 父類
    * */
    
    //原型式繼承
    function inheritObject (o) {
       // 宣告一個過渡函式物件
       function F () {}
       // 過渡物件的原型繼承父物件
       F.prototype = o;
       // 返回過渡物件的一個例項,該例項的原型繼承了父物件
       return new F();
    }
    
    function inheritPrototype (childClass , superClass) {
        // 複製一份父類的原型儲存在變數中
        var p = inheritObject(superClass.prototype);
        // 修復子類的 constructor
        p.constructor = childClass;
        // 設定子類的原型
        childClass.prototype = p;
    }
複製程式碼

我們需要繼承父類的原型,不需要在呼叫父類的建構函式,我們只需要父類原型的一個副本,而這個副本我們是可以通過原型繼承拿到,如果直接賦值給子類物件,會導致子類的原型錯亂,因為父類的原型物件複製到 P 中的 constructor 指向的不是子類的物件,所以經行了修正,並賦值給子類的原型,這樣子類也就繼承了父類的原型,但是沒有執行父類的構造方法。

ok,測試一下:

    // 定義父類
    function SuperClass (name) {
    	this.name = name;
    	this.object = {
    		a: 1,
    		b: 2
    	}
    }
    // 定義父類的原型
    SuperClass.prototype.showName = function () {
        console.log(this.name)
    }
    
    // 定義子類
    function ChildClass (name,age) {
        // 建構函式式繼承
        SuperClass.call(this,name);
        // 子類新增屬性
        this.age = age;
    }
    
    // 寄生式繼承父類原型
    inheritPrototype(ChildClass,SuperClass);
    // 子類新增原型方法
    ChildClass.prototype.showAge = function () {
        console.log(this.age)
    }
    
    //
    var child1 = new ChildClass('Tom',24);
    var child2 = new ChildClass('Jack',25);
    
    console.log(child1.object)  // { a : 1 , b : 2 }
    child1.object.a = 3 ;
    console.log(child1.object)  // { a : 3 , b : 2 }
    console.log(child2.object)  // { a : 1 , b : 2 }
    
    console.log(child1.showName())  // Tom
    console.log(child2.showAge())   // 25
複製程式碼

現在沒問題了哈,之前的問題也都解決了,大功告成~

多繼承

JavaC++ 物件導向中會有多繼承你的概念,但是 javascript 的繼承是依賴原型鏈實現的,但是原型鏈只有一條,理論上是不能實現多繼承的。但是我們可以利用 javascript 的靈活性,可以通過繼承多個物件的屬性來實現類似的多繼承。

首先,我們來看一個比較經典的繼承單物件屬性的方法 —— extend

function extend (target,source) {
    //遍歷源物件中的屬性
    for( var property in source ){
    	//將源物件中的屬性複製到目標物件
        target[property] = source[property]
    }
     // 返回目標物件
    return target;
}
複製程式碼

但是這個方法是一個淺複製過程,也就是說只能複製基本資料型別,對於引用型別的資料達不到預期效果,也會出現資料篡改的情況:

var parent = {
	name: 'super',
	object: {
		a: 1,
		b: 2
	}
}

var child = {
	age: 24
}

extend(child, parent);

console.log(child);     //{ age: 24, name: "super", object: { a : 1 , b : 2 } }
child.object.a = 3;
console.log(parent);    //{ name: "super", object: { a : 3 , b : 2 } }
複製程式碼

順著這個思路,要實現多繼承,就要將傳入的多個物件的屬性複製到源物件中,進而實現對多個物件的屬性繼承,我們可以參考 jQuery 框架中的 extend 方法,對我們上面的函式進行改造~

//判斷一個物件是否是純物件
function isPlainObject(obj) {
    var proto, Ctor;

    // (1) null 肯定不是 Plain Object
    // (2) 使用 Object.property.toString 排除部分宿主物件,比如 window、navigator、global
    if (!obj || ({}).toString.call(obj) !== "[object Object]") {
        return false;
    }

    proto = Object.getPrototypeOf(obj);

    // 只有從用 {} 字面量和 new Object 構造的物件,它的原型鏈才是 null
    if (!proto) {
        return true;
    }

    // (1) 如果 constructor 是物件的一個自有屬性,則 Ctor 為 true,函式最後返回 false
    // (2) Function.prototype.toString 無法自定義,以此來判斷是同一個內建函式
    Ctor = ({}).hasOwnProperty.call(proto, "constructor") && proto.constructor;
    return typeof Ctor === "function" && Function.prototype.toString.call(Ctor) === Function.prototype.toString.call(Object);
}

function extend() {
    var name, options, src, copy, clone, copyIsArray;
    var length = arguments.length;
    // 預設不進行深拷貝
    var deep = false;
    // 從第二個引數起為被繼承的物件
    var i = 1;
    // 第一個引數不傳佈爾值的情況下,target 預設是第一個引數
    var target = arguments[0] || {};
    // 如果第一個引數是布林值,第二個引數是 target
    if (typeof target == 'boolean') {
        deep = target;
        target = arguments[i] || {};
        i++;
    }
    // 如果target不是物件,我們是無法進行復制的,所以設為 {}
    if (typeof target !== "object" && !( typeof target === 'function')) {
        target = {};
    }

    // 迴圈遍歷要複製的物件們
    for (; i < length; i++) {
        // 獲取當前物件
        options = arguments[i];
        // 要求不能為空 避免 extend(a,,b) 這種情況
        if (options != null) {
            for (name in options) {
                // 目標屬性值
                src = target[name];
                // 要複製的物件的屬性值
                copy = options[name];

                // 解決迴圈引用
                if (target === copy) {
                    continue;
                }

                // 要遞迴的物件必須是 plainObject 或者陣列
                if (deep && copy && (isPlainObject(copy) ||
                    (copyIsArray = Array.isArray(copy)))) {
                    // 要複製的物件屬性值型別需要與目標屬性值相同
                    if (copyIsArray) {
                        copyIsArray = false;
                        clone = src && Array.isArray(src) ? src : [];

                    } else {
                        clone = src && isPlainObject(src) ? src : {};
                    }

                    target[name] = extend(deep, clone, copy);

                } else if (copy !== undefined) {
                    target[name] = copy;
                }
            }
        }
    }

    return target;
};
複製程式碼

該方法預設是淺拷貝,即:

var parent = {
    name: 'super',
    object: {
        a: 1,
        b: 2
    }
}
var child = {
    age: 24
}

extend(child,parent)
console.log(child); // { age: 24, name: "super", object: { a : 1 , b : 2 } }
child.object.a = 3;
console.log(parent) // { name: "super", object: { a : 3 , b : 2 } }
複製程式碼

我們只需要將第一個引數傳為 true , 就可以深複製了,即:

    extend(true,child,parent)
    console.log(child); // { age: 24, name: "super", object: { a : 1 , b : 2 } }
    child.object.a = 3;
    console.log(parent) // { name: "super", object: { a : 1 , b : 2 } }
複製程式碼

ok~這些就是 javascript 中物件導向的一些知識,能仔細看到這裡的小夥伴,相信你們對 javascript 中物件導向程式設計有了進一步的認識和了解,也為後面的設計模式的學習奠定了基礎,接下來也會繼續分享 javascript 中不同的設計模式,歡迎喜歡的小夥伴持續關注~

相關文章