聊聊JS中的繼承

允南發表於2019-04-19

JS中的類

繼承來自面嚮物件語言,如果一個類B“繼承自”另一個類A,就把這個B稱為“A的子類”,而把A稱為“B的父類”也可以稱“A是B的超類(super)”。子類具有父類的屬性和方法,達到程式碼複用的目的。繼承是類三大特性(封裝、繼承、多型)之一,在ES6之前,JS中是沒有類的概念的,包括ES6以後的class也是語法糖的實現。JS中的繼承是依賴原型實現的,至於JS中為甚麼沒有‘類’,以及原型的由來,阮一峰老師這兒講的很清楚

原型與原型鏈

__proto__prototype

在JS中通過建構函式來建立一個例項:

function Person (name) {
    this.name = name;
}

const bob = new Person('bob');
const jack = new Person('jack');

console.log(bob.name, jack.name); // bob, jack
複製程式碼

這裡建立了一個建構函式Person,並使用new關鍵字構造了兩個例項bob和jack,他們都擁有一個name屬性,兩者之間互不干擾,接下來將bob和jack同時列印出來;

1.jpg

可以看到出了name屬性之外,還包含了一個__proto__屬性:

遵循ECMAScript標準,someObject.[[Prototype]] 符號是用於指向 someObject的原型。從 ECMAScript 6 開始,[[Prototype]] 可以通過 Object.getPrototypeOf() 和 Object.setPrototypeOf() 訪問器來訪問。這個等同於 JavaScript 的非標準但許多瀏覽器實現的屬性 __proto__

這裡已經知道__proto__指向了bob的原型,即指向bob的建構函式Person的prototype屬性,這裡可以把Person列印出來:

bob.__proto__ === Person.prototype; // true
複製程式碼

要想在bob和jack之間即Person的所有例項之前共享一些屬性或者方法可以通過編輯Person的原型Person.prototype來實現:

Person.prototype.sayName = function () {
    return this.name;
}

console.log(bob.sayName(), jack.sayName()); // 'bob' 'jack'
複製程式碼

再次列印bob時,會發現__proto__裡面多出了一個sayName屬性,通過這樣的方式就可以在例項之間共享一些屬性。要注意的是不要將Person.prototype__proto__混淆:例項通過自身的__proto__屬性訪問其建構函式的原型物件prototype

到這裡會有一個問題,在剛開始定義Person時,並沒有定義prototype屬性,那麼建構函式的prototype是哪裡來的?

無論什麼時候,只要建立了一個新函式,就會根據一組特定的規則為該函式建立一個 prototype 屬性,這個屬性指向函式的原型物件。在預設情況下,所有原型物件都會自動獲得一個 constructor (建構函式)屬性,這個屬性包含一個指向 prototype 屬性所在函式的指標。

Person.prototype.constructor === Person; // true
Person.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null;             // true
複製程式碼

可以看到,建構函式的預設(這裡說預設是因為建構函式的原型物件可以重寫)原型物件是Object的例項,其[[prototype]]指向Object的原型,而Object的原型物件已經到頭了,所以Object的原型物件的[[prototype]]為null。

原型鏈

細心一點會發現,上面程式碼中例項訪問sayName方法時是直接通過.運算子去訪問的,並沒有通過__proto__屬性,即:

bob.__proto__.sayName(); // undefind: this指向prototype,其沒有name屬性
複製程式碼

原因是在訪問物件的屬性時,首先在其本身即this上查詢,當沒有找到該屬性時就到物件的原型上去查詢。這裡很容易想到屬性遮蔽的問題,即例項和其原型具有相同屬性名的屬性是,原型上的該屬性將不可見。

看下面這個例子:

function A () {}
A.prototype.sayHi = function () {
    console.log('Hi');
}

function B () {}
B.prototype = new A();

const instance = new B();
instance.sayHi();   // Hi
複製程式碼

在A上定義了sayHi, 然後定義了B,並將其原型改寫為A的例項,建立一個B的例項instance,其訪問sayHi的順序如下:

instance自身(空物件) -> instance.proto(A的例項,也是一個空物件) -> instance.proto.proto(A的原型,找到sayHi)

當例項本身上並不存在該屬性時,會訪問其原型,由於原型本身也是一個物件,如果訪問不到的話就繼續訪問原型的原型,不斷回溯,直到找到該屬性或者到null。這個過程相當於是一次連結串列的查詢,這就是原型鏈的由來。

引申:Function & Object 雞蛋問題

附上一篇文章

繼承的幾種實現方式

繼承本身也像是一條鏈,所以雖然JS中沒有”真正的類“,但通過原型鏈也可以實現繼承,接下來就談談幾種繼承的實現方式,大部分內容來自《js高階程式設計》,很香。

借用建構函式

function SuperType(){
    this.colors = ["red", "blue", "green"];
}
SuperType.prototype.mention = '借用建構函式無法繼承原型上的屬性';

function SubType(){
    SuperType.call(this);
}

const instance1 = new SubType();
const instance2 = new SubType();

instance1.colors.push("black");

console.log(instance1.colors);    //"red,blue,green,black"
console.log(instance2.colors);    //"red,blue,green"
console.log(instance1.mention);   // undefind
複製程式碼

所謂的“借調”即通過使用 call()方法(或 apply()方法 也可以)在子類的建構函式中呼叫父類建構函式,因為子類的this繫結給了父類建構函式,所以父類的建構函式裡的屬性就會新增到子類的例項上,實際上相當於用父類的建構函式對子類建構函式進行了擴充套件。但像程式碼中展現的一樣,劣勢很明顯,無法繼承(連結到)父類的prototype, 而其優勢在於可以向父類建構函式傳參。

組合繼承

function SuperType(name){
        this.name = name;
        this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
    console.log(this.name);
}

function SubType(name, age){
    SuperType.call(this, name);
    this.age = age;
}
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
    console.log(this.age);
};

const instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");

console.log(instance1.colors);  //"red,blue,green,black"
instance1.sayName();    //"Nicholas";
instance1.sayAge(); // 29

const instance2 = new SubType("Greg", 27);
console.log(instance2.colors);  //"red,blue,green"
instance2.sayName();    //"Greg";
instance2.sayAge(); //27
複製程式碼

組合繼承相當於是借用建構函式的加強版,通過將父類的例項重寫子類的原型,這樣子類的原型就可以連結到父類的原型。這裡需要注意一個小細節:

SubType.prototype.constructor = SubType;
複製程式碼

建構函式的原型物件上的constructor指向建構函式本身,這裡因為重新賦值被改寫了,所以需要修正回來。

組合繼承有個缺點,父類的建構函式會被呼叫兩次,一次是建立例項給子類的prototype,另一次是在子類的建構函式裡面借調的。

原型繼承

function object(o) {
    function f() {}
    f.prototype = o;
    return new f();
}
const parent = {
    name: 'parent',
    colors: ['black', 'red'],
}
const o1 = object(parent);  
const o2 = object(parent);

console.log(o1.colors); // ["black", "red"]
console.log(o2.colors); // ["black", "red"]

o1.colors.push('green');
console.log(o2.colors); // ["black", "red", "green"]
複製程式碼

原型繼承大致可以描述為: 建立一個給定原型的物件。ECMAScript 5 通過新增 Object.create()方法規範化了原型式繼承。其行為與上述方式相同。

寄生式繼承

function createAnother(origin) {
    const clone = object(origin);
    clone.sayHi = function () {
        return 'Hi';
    }
}
複製程式碼

寄生式繼承的思路與寄生建構函式和工廠模式類似,即建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件。

使用寄生式繼承來為物件新增函式,會由於不能做到函式複用而降低效率; 這一點與建構函式模式類似。

寄生組合式繼承

function inheritPrototype(subType, superType){
    var prototype = object(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

function SuperType(name){
        this.name = name;
        this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
    console.log(this.name);
};

function SubType(name, age){
    SuperType.call(this, name);
    this.age = age;
}

inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function(){
    console.log(this.age);
};
複製程式碼

相對於組合繼承直接用父類例項改寫子類原型的做法,寄生組合式繼承的方式更加細膩了一些,通過寄生的方式,通過父類的原型建立一個物件給子類的原型,這樣子類的prototype通過[[prototype]]可以連結到父類,更加優雅的實現了繼承,且只呼叫了一遍父類的建構函式。

ES6中的class

class A {
    constructor(name) {
        this.name = name;
    }
    static getMaxNumber(a, b) {
        return a > b ? a : b;
    }
    sayName() {
        console.log(this.name);
    }
}
複製程式碼

在es6中定義了class關鍵字,但其依舊是function + 原型的語法糖:

typeof A === 'function'; // true
複製程式碼

可以看到A本身依舊是一個function,那A裡面的方法是放到哪兒的呢?

2.jpg

可以看到,sayName是放在A的prototype上面的,這個不難解釋得通,因為類的方法是可以被子類繼承的,所以sayName在A的prototype上合情合理,

在列印出來的原型中,並沒有getMaxNumber,因為靜態屬性不能被例項繼承,只能由類直接呼叫,所以靜態屬性是直接掛載到類上的,這也是為什麼不能再靜態屬性中訪問this,因為通過類直接呼叫的話,this指向類本身。另外要說明的是雖然靜態方法是掛載類上的,但由於其是不可列舉的,所以無法通過Object.keys這樣的方式取到的。

3.jpg

到目前為止,js裡還沒有一個完善的私有屬性的定義方式,不過在提案中已經有通過‘#’定義私有屬性的方式:

class B {
    #name;
}
複製程式碼

總結

第一次認真寫文章,前前後後寫了有四五個小時吧,總算把js的原型和繼承捋了一遍,有些地方可能還講的不夠細,後面會再翻看一些資料,查漏補缺。

歡迎指正!github

相關文章