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同時列印出來;
可以看到出了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裡面的方法是放到哪兒的呢?
可以看到,sayName是放在A的prototype上面的,這個不難解釋得通,因為類的方法是可以被子類繼承的,所以sayName在A的prototype上合情合理,
在列印出來的原型中,並沒有getMaxNumber
,因為靜態屬性不能被例項繼承,只能由類直接呼叫,所以靜態屬性是直接掛載到類上的,這也是為什麼不能再靜態屬性中訪問this,因為通過類直接呼叫的話,this指向類本身。另外要說明的是雖然靜態方法是掛載類上的,但由於其是不可列舉的,所以無法通過Object.keys這樣的方式取到的。
到目前為止,js裡還沒有一個完善的私有屬性的定義方式,不過在提案中已經有通過‘#’定義私有屬性的方式:
class B {
#name;
}
複製程式碼
總結
第一次認真寫文章,前前後後寫了有四五個小時吧,總算把js的原型和繼承捋了一遍,有些地方可能還講的不夠細,後面會再翻看一些資料,查漏補缺。
歡迎指正!github