深入理解JavaScript之模擬“類”
JavaScript並不是一門面向物件的程式語言。但是在無數人的努力之下,JavaScript漸漸的開始有了面向物件的特性(通過我們自定義的物件)。
在JavaScript中從ES6開始提供了"class"關鍵字等等的與類有關的語法,但是,在JavaScript機制卻一直在阻止你使用近似類的語法。
1 混入
顯示混入跟面向物件中的繼承差不多,我們可以這樣來理解面向物件中的繼承“繼承是子類對父類的複製”。但是在JavaScript中只有“物件”沒有類,那麼我們如何讓“物件”實現這一功能呢?
其實按照之前繼承的理解,“繼承是子類對父類的複製”,JavaScript開發者也想出了一個用於模擬類複製行為的方法,此方法叫做“混入”
混入分成兩種,一種是顯式,一種是隱式。
1.1 顯式混入
由於JavaScript不會主動實現兩個物件之間的複製,因此需要我們手動實現複製功能。這個功能在許多庫和框架中被稱為"extend(...)"
看下面的程式碼
//mixin只能主動選擇複製得物件 function mixin(sourceObj,targetObj) { //傳入源物件,目標物件 for(var key in sourceObj){ //利用for in 迴圈遍歷出sourceObj中可列舉得屬性 if(!(key in targetObj)){ //當存在屬性不屬於目標類時,發生複製 targetObj[key]=sourceObj[key]; } } return targetObj; } var Vehicle={ engines:1, //引擎 ignition: function () { console.log("啟動我的引擎"); }, drive: function () { this.ignition(); console.log("開始前進"); } }; var Car=mixin(Vehicle,{ wheels:4, drive:function () { Vehicle.drive.call(this); //顯式繫結該目標物件與原物件的this,derive()立即發生 console.log("開始啟動"+this.wheels+"車輪") } }); console.log(Car.engines); //1 console.log(Car.wheels); //4 Car.drive(); //啟動我的引擎 //開始前進 //開始啟動4車輪
Car物件就存在了"Vehicle"屬性和函式的副本了,記得我們前面在《深入JavaScript之物件》一章講過關於物件的複製,最簡單直接的方法就是使用"Object.assign(....)",這裡的函式複製只是複製引用。同樣的在我們顯式注入中同樣是複製函式的引用。相反,屬性"engines"和"wheels"則是實實在在的複製。
Car已經有了drive屬性,所以這個屬性引用並沒有被"mixin"重寫,從而保留了Car中定義的同名屬性,實現了“子類”對“父類”屬性的重寫(在if(...)迴圈中判斷兩者有沒有屬性名相同的物件,有的話則忽略,不復制)。
①多型
注意這裡存在的"Vehicle.drive.call(this)"---------這是多型中的一個顯式多型,在此例中,"Vehicle"中存在"drive(...)","Car"中也存在"drive(...)",為了指明呼叫物件,我們必須使用絕對(不是相對),引用。我們通過名稱顯式指定"Vehicle"物件並呼叫它的"drive(...)"。
不然的話,就會出現,當我們想要呼叫"Vehicle"中的"drive(...)"時,會呼叫"Car"中的"drive(...)"。
假如,"Vehicle"中的不是"drive(...)"而是"pilot(....)"兩者時同樣的功能和語句,那麼在"Car"中就不需要顯式絕對引用"drive(...)"了,而是可以相對引用"pilot(...)"----------------這就是相對多型。
②混合複製
在我們之前提到過的"mixin(...)"中,讓我們來回想一下,這是怎麼做的?
function mixin(sourceObj,targetObj) { //傳入源物件,目標物件
for(var key in sourceObj){ //利用for in 迴圈遍歷出sourceObj中可列舉得屬性
if(!(key in targetObj)){ //當存在屬性不屬於目標類時,發生複製
targetObj[key]=sourceObj[key];
}
}
return targetObj;
}
"for....in"會遍歷目標物件"sourceObj",將源物件的可列舉屬性一一羅列出來,接著使用"if(...)"判斷目標物件(targetObj)中是否存在同名屬性,否的話,複製。最後輸出目標物件。由於我們是在目標物件初始化以後才進行復制的,所以要注意不要覆蓋目標物件的原有屬性。
這時候問題來了,那麼,當我們先複製再對目標物件進行初始化呢?這樣是否就繞過了"if(....)"檢查,從而提升程式碼的執行效率呢?
多說無益,我們來看看程式碼
//mixin只能主動選擇複製得物件
function mixin(sourceObj,targetObj) { //傳入源物件,目標物件
for(var key in sourceObj){ //利用for in 迴圈遍歷出sourceObj中可列舉得屬性
targetObj[key]=sourceObj[key];
}
return targetObj;
}
var Vehicle={
engines:1, //引擎
ignition: function () {
console.log("啟動我的引擎");
},
drive: function () {
this.ignition();
console.log("開始前進");
}
};
//先複製
var Car=mixin(Vehicle,{}); //建立一個新的空物件,將原物件傳進去
//再初始化物件
mixin( {wheels:4, //利用mixin(...)將物件的初始值傳入
drive:function () {
Vehicle.drive.call(this); //強制繫結該目標物件與原物件的this
console.log("開始啟動"+this.wheels+"車輪")}
},Car);
console.log(Car.engines); //1
console.log(Car.wheels); //4
Car.drive();
//啟動我的引擎
//開始前進
//開始啟動4車輪
從結果可以看出來,兩者執行結果都一樣,只是複製的先後順序不同。
注意這裡,在你對目標物件初始化時,千萬不要用普通的賦值操作"Car={wheels:4......}"而要用"mixin(...)"賦值,否則會出現"TypeError"
其實“先複製再初始化”並不好用而且效率更低,不如“先初始化在複製”。
“混入”這一個名稱來源於這個過程的另一個解釋:Car中混入了"Vehicle"的內容,就好比你在冰激淋上塗了巧克力屑一樣。
複製操作完成以後,"Car"和"Vehicle"就分離了,我們往"Car"中新增屬性不會影響到"Vehicle",反過來意識一樣(這裡其實這兩者之間時會有點微妙聯絡的,比如引用同一個物件)。
由於複製時的函式,為函式引用,所以兩者本質上說都是引用同一種函式而已並不能完全模擬面向物件屬性中的複製。當這個共享函式發生改變,例如增添了什麼屬性,那麼源物件和目標物件都會受到影響。
1.2 隱式注入
隱式注入與顯式注入不同之處在於,隱式注入並不需要你編寫注入程式碼,而是通過"this"繫結中的顯式繫結,".call"強制目標物件借用原物件的內容。
看以下程式碼:
var Something={
cool: function () {
this.greeting="Hello word";
this.count=this.count?this.count+1:1;
}
};
Something.cool(); //物件的屬性時函式時要先進行引用
console.log(Something.greeting); //Hello word
console.log(Something.count); //1
var Another={
cool:function () {
Something.cool.call(this);
}
};
Another.cool();
console.log(Another.greeting); //Hello word
console.log(Another.count); //1 count不是共享狀態
通過建構函式呼叫或者方法呼叫中使用"Something.cool.call(this)",我們實際上“借用”了"Something.cool()"並在"Another"的上下文環境中呼叫它,於是"this"理所應當的指向了當前物件。因此,我們將"Something.cool()"行為注入到了"Another"中。
雖然這類技術運用了"this"繫結的規則,重新使"this"繫結物件發生變化,但是這種方法仍然是不太靈活,比如受"this"的約束。因此我們儘量避免採用這種結構,保持程式碼的完整性。