淺談JS的繼承

super_YUE發表於2019-02-26

最近在看《js高階程式設計》,物件的繼承問題困擾了我很久,這個問題也是面試中大概率會被問到的問題。之前試過硬背程式碼,到了要寫的時候還是寫不出來,不知其所以然。在充分了解這一塊之後,來進行一些總結。

建立物件

在理解物件繼承之前得先弄明白建立物件這回事兒。

工廠模式

function createCar(color, passengers, brand){
    var car = new Object();
    car.color = color;
    car.passengers = color;
    car.brand = brand;
    car.outBrand = function(){
        console.log(this.brand)
    }
    return car;
}
複製程式碼

工廠模式很好理解,例項化一個物件,在把傳入的引數放入該物件,再返回。

缺點:無法進行物件識別。由於返回的物件都是由Objcet物件例項化出來的,但是開發過程中,需要建立很多種物件,肯定會有進行物件識別的需求,工廠模式顯然無法完成我們這樣的訴求。我們繼續探索。

建構函式模式

function Car(color, passengers, brand){
    this.color = color;
    this.passengers = passengers;
    this.brand = brand;
    this.outBrand = function(){
        console.log(this.brand)
    }
}
var car1 = new Car('red', ['a','b'], 'benz');
var car2 = new Car('black', ['c','d'], 'BMW');

console.log(car1 instanceof Object); //true
console.log(car1 instanceof Car);    //true
console.log(car2 instanceof Object); //true
console.log(car2 instanceof Car);    //true
複製程式碼

建構函式模式能夠很好的使用 instanceof 進行物件的識別,Objcet物件是所有物件的頂層物件類,所有的物件都會繼承他。對物件進行操作的各類方法就存放在Object物件裡面。

缺點:但是無法解決引用型別的建立問題,我們每次對Car物件進行例項化的時候,都需要對outBrand方法進行建立,無法複用,浪費記憶體。要解決只能把他放到全域性作用域。但是在全域性作用域中定義的函式一般來說只能被某個物件呼叫,這會讓全域性作用域名不副實。並且也會失去封裝性,我們來想象一下,如果該物件中有很多方法,那會讓全域性作用域充滿了單獨拎出來的方法,讓程式碼可讀性變差。

原型模式

function Car(){
    
}
car.prototype.color = "red";
car.prototype.passengers = ["a","b","c"];
car.prototype.brand = "benz";
car.prototype.outBrand = function () {
    console.log(this.brand)
};

var car1 = new Car();
var car2 = new Car();
car1.color = "blue";
car1.passengers('d');
console.log(car1.brand); //["a","b","c","d"]
console.log(car2.brand); //["a","b","c","d"]
console.log(car1.color); // "bule"
console.log(car2.color); // "red"
複製程式碼

這個模式利用了物件的原型,將基本引數掛載在原型上面。

缺點:省去了初始化引數,這一點有好有壞。最大的問題是對引用型別值的共享,car1和car2例項在例項化以後還會與Car類存在關係。如果對其賦值基本型別值的話,會在例項化的物件當中建立,並且呼叫時會首先在例項化物件中尋找。而對引用型別值進行操作的時候,會直接在原型物件的引用型別值上進行操作,所以會在所有例項中共享。

組合建構函式

function Car(color,brand){
    this.color = color;
    this.brand = brand;
    this.passengers = ["a","b","c"];
}
Car.prototype = {
    constructor: Car,
    outBrand: function () {
        console.log(this.brand)
    }
}
var car1 = new Car("red",'benz');
var car2 = new Car("blue","BMW");
car1.color = "blue";
car1.passengers('d');
console.log(car1.brand); //["a","b","c"]
console.log(car2.brand); //["a","b","c","d"]
複製程式碼

每個例項都會存在一份例項的副本,並且會對方法共享,最大程度節省了記憶體,也提供了向建構函式中傳遞引數的功能

建立物件總結

  • 我們在使用工廠模式的時候,發現了物件識別的問題,於是使用建構函式模式去解決這個問題。
  • 在使用建構函式時,發現了引用型別值建立的問題,無法對其複用。於是使用了原型模式。
  • 在原型模式中,引用型別值共享的問題又出現了。於是組合建構函式模式
  • 組合建構函式模式中,結合建構函式模式和對引用型別操作的良好處理和原型模式對方法的共享,達到了最佳方案。

繼承

原型鏈繼承

function OldCar(){
    this.color = "red";
    this.passengers = ['a','b','c']
}
OldCar.prototype.getOldColor = function(){
    return this.color;
}
function NewCar(){
   this.newColor = "blue";
}
NewCar.prototype = new OldCar();
SubType.prototype.getNewColor = function(){
    return this.newColor;
}
var car = new newCar();
console.log(car.getOldColor); //"red"
複製程式碼

原型鏈繼承通俗易懂,利用原型鏈將兩個類串起來。

問題:會產生引用型別值的問題。與生成物件中的原型模式一脈相承。

借用建構函式

function OldCar(){
    this.passengers = ['a','b','c'];
}
function NewCar(){
    OldCar.call(this);
}
複製程式碼

基本思路就是在子類的建構函式的內部呼叫超類的建構函式。因為函式只是在特定的環境中執行程式碼的物件。借用建構函式的方式可以解決引用型別的問題。使用call()和apply()方法,在子類中呼叫超類。這樣每個例項都會有自己的引用型別的副本了。

缺點:和建構函式建立物件一致的問題,方法都得在建構函式中定義,導致函式無法複用,造成記憶體的浪費。

組合繼承

function OldCar(brand){
    this.brand = brand;
    this.passengers = ['a','b','c']
}
OldCar.prototype.getBrand = function(){
    return this.brand;
}
function NewCar(name,color){
    OldCar.call(this,name)  //第一次呼叫
    this.color = color;
}
NewCar.prototype = new OldCar(); //第二次呼叫
NewCar.prototype.constructor = NewCar; //增強
SubType.prototype.getColor = function(){
    return this.color;
}
複製程式碼

組合繼承集借用建構函式方法和原型鏈繼承兩者之長,複用了方法,也解決了引用型別的問題。

缺點:需要呼叫兩次超類的建構函式,第一次是OldCar.call(this,name),第二次是new OldCar()。下一步我們需要解決的是超類的兩次呼叫問題。

function A(){
    
}
A.prototype.name = 'py';
A.prototype.age = 12;

<!--等價於-->
A.prototype = {
    name: 'py',
    age: 12
}
A.prototype.constructor = A

複製程式碼

上面的例子中,上半部分是最基本的對原型的賦值,而下班部分的對原型的賦值A的原型的建構函式會變成Object(先new Object然後再賦值引數),所以需要顯式的去增強建構函式。

寄生組合繼承

為了解決組合繼承的痛點,出現了寄生組合繼承。

function OldCar(brand){
    this.brand = brand;
    this.passengers = ['a','b','c']
}
OldCar.prototype.getBrand = function(){
    return this.brand;
}
function NewCar(name,color){
    OldCar.call(this,name)
    this.color = color;
}

//繼承開始
var middleObj = Objcet.create(OldCar.prototype);
middleObj.constructor = NewCar;
NewCar.prototype = middleObj
//繼承結束

NewCar.prototype.getColor = function(){
    return this.color;
}
複製程式碼
function createObj(obj){
    function Car();
    Car.prototype = obj;
    return new Car();
}
Object.create() 等價於 crateObj(),相當於對傳入的物件進行了一次淺複製。
複製程式碼

那麼,我們來看看繼承的過程中發生了什麼。先對超類的原型進行一次淺複製。然後將中間物件的建構函式替換為普通類。為什麼要進行這一步?因為對超類的原型進行淺複製以後,中間物件的建構函式變成了Object,需要對該物件進行增強處理。最後將普通類的原型指向中間變數,這樣就只需要呼叫一次超類就可以完成繼承。

繼承的總結

  • 在原型鏈繼承中,我們又遇到了老對手引用型別值的共享問題。
  • 在借用建構函式進行繼承中,方法共享問題,這個老對手又出現了。
  • 按照建立物件的經驗,組合兩者優點的組合繼承將成為最佳方式,但是我們卻發現了超類會被呼叫兩次的問題。
  • 為了解決超類被呼叫兩次的問題,寄生組合繼承成為了最佳方案。

相關文章