Class:向傳統類模式轉變的建構函式
前言
JS基於原型的‘類’,一直被轉行前端的碼僚們大呼驚奇,但接近傳統模式使用class
關鍵字定義的出現,卻使得一些前端同行深感遺憾而紛紛留言:“還我獨特的JS”、“淨搞些沒實質的東西”、“自己沒有類還非要往別家的類上靠”,甚至是“已轉行”等等。有情緒很正常,畢竟新知識意味著更多時間與精力的開銷,又不是簡單的閉眼享受。
然而歷史的軸印前行依舊,對於class
可以肯定的一點是你不能對面試官說:“拜託,不是小弟不懂,僅僅是不願意瞭解,您換個問題唄!”一方面雖然class
只是個語法糖,但extends
對繼承的改進還是不錯的。另一方面今後可能在‘類’上出現的新特性應該是由class
而不是建構函式承載,誰也不確定它將來會出落得怎樣標緻。因此,來來來,慢慢的喝下這碗熱氣騰騰的紅糖薑湯。
1 class
ECMAScript中沒有類的概念,我們的例項是基於原型由建構函式生成具有動態屬性和方法的物件。不過為了與國際接軌,描述的更為簡便和高大上,依然會使用‘類’這一詞。所以JS的類等同於建構函式。ES6的class
只是個語法糖,其定義生成的物件依然建構函式。不過為了與建構函式模式區分開,我們稱其為類模式。學習class
需要有建構函式和原型物件的知識,具體可以自行百度。
// --- 使用建構函式 function C () { console.log('New someone.'); } C.a = function () { return 'a'; }; // 靜態方法 C.prototype.b = function () { return 'b'; }; // 原型方法 // --- 使用class class C { static a() { return 'a'; } // 靜態方法 constructor() { console.log('New someone.'); } // 構造方法 b() { return 'b'; } // 原型方法 };
1.1 與變數對比
關鍵字class
類似定義函式的關鍵字function
,其定義的方式有宣告式和表示式(匿名式和命名式)兩種。通過宣告式定義的變數的性質與function
不同,更為類似let
和const
,不會提前解析,不存在變數提升,不與全域性作用域掛鉤和擁有暫時性死區等。class
定義生成的變數就是一個建構函式,也因此,類可以寫成立即執行的模式。
// --- 宣告式 class C {} function F() {} // --- 匿名錶達式 let C = class {}; let F = function () {}; // --- 命名錶達式 let C = class CC {}; let F = function FF() {}; // --- 本質是個函式 class C {} console.log(typeof C); // 'function' console.log(Object.prototype.toString.call(C)); // '[object Function]' console.log(C.hasOwnProperty('prototype')); // true // --- 不存在變數提升 C; // 報錯,不存在C。 class C {} // 存在提前解析和變數提升 F; // 不報錯,F已被宣告和賦值。 function F() {} // --- 自執行模式 let c = new (class { })(); let f = new (function () { })();
1.2 與物件對比
類內容({}
裡面)的形式與物件字面量相似。不過類內容裡面只能定義方法不能定義屬性,方法的形式只能是函式簡寫式,方法間不用也不能用逗號分隔。方法名可以是帶括號的表示式,也可以為Symbol
值。方法分為三類,構造方法(constructor
方法)、原型方法(存在於建構函式的prototype
屬性上)和靜態方法(存在於建構函式本身上)
class C {
// 原型方法a
a() { console.log('a'); }
// 構造方法,每次生成例項時都會被呼叫並返回新例項。
constructor() {}
// 靜態方法b,帶static關鍵字。
static b() { console.log('b'); }
// 原型方法,帶括號的表示式
['a' + 'b']() { console.log('ab'); }
// 原型方法,使用Symbol值
[Symbol.for('s')]() { console.log('symbol s'); }
}
C.b(); // b
let c = new C();
c.a(); // a
c.ab(); // ab
c[Symbol.for('s')](); // symbol s
不能直接定義屬性,並不表示類不能有原型或靜態屬性。解析class
會形成一個建構函式,因此只需像為建構函式新增屬性一樣為類新增即可。更為直接也是推薦的是隻使用getter
函式定義只讀屬性。為什麼不能直接設定屬性?是技術不成熟?是官方希望傳遞某種思想?抑或僅僅是筆者隨意丟擲的一個問題?
// --- 直接在C類(建構函式)上修改
class C {}
C.a = 'a';
C.b = function () { return 'b'; };
C.prototype.c = 'c';
C.prototype.d = function () { return 'd'; };
let c = new C();
c.c; // c
c.d(); // d
// --- 使用setter和getter
// 定義只能獲取不能修改的原型或靜態屬性
class C {
get a() { return 'a'; }
static get b() { return 'b'; }
}
let c = new C();
c.a; // a
c.a = '1'; // 賦值沒用,只有get沒有set無法修改。
1.3 與建構函式對比
下面是使用建構函式和類實現相同功能的程式碼。直觀上,class
簡化了程式碼,使得內容更為聚合。constructor
方法體等同建構函式的函式體,如果沒有顯式定義此方法,一個空的constructor
方法會被預設新增用於返回新的例項。與ES5一樣,也可以自定義返回另一個物件而不是新例項。
// --- 建構函式
function C(a) {
this.a = a;
}
// 靜態屬性和方法
C.b = 'b';
C.c = function () { return 'c'; };
// 原型屬性和方法
C.prototype.d = 'd';
C.prototype.e = function () { return 'e'; };
Object.defineProperty(C.prototype, 'f', { // 只讀屬性
get() {
return 'f';
}
});
// --- 類
class C {
static c() { return 'c'; }
constructor(a) {
this.a = a;
}
e() { return 'e'; }
get f() { return 'f'; }
}
C.b = 'b';
C.prototype.d = 'd';
類雖然是個函式,但只能通過new
生成例項而不能直接呼叫。類內部所定義的全部方法是不可列舉的,在建構函式本身和prototype
上新增的屬性和方法是可列舉的。類內部定義的方法預設是嚴格模式,無需顯式宣告。以上三點增加了類的嚴謹性,比較遺憾的是,依然還沒有直接定義私有屬性和方法的方式。
// --- 能否直接呼叫
class C {}
C(); // 報錯
function C() {}
C(); // 可以
// --- 是否可列舉
class C {
static a() {} // 不可列舉
b() {} // 不可列舉
}
C.c = function () {}; // 可列舉
C.prototype.d = function () {}; // 可列舉
isEnumerable(C, ['a', 'c']); // a false, c true
isEnumerable(C.prototype, ['b', 'd']); // b false, d true
function isEnumerable(target, keys) {
let obj = Object.getOwnPropertyDescriptors(target);
keys.forEach(k => {
console.log(k, obj[k].enumerable);
});
}
// --- 是否為嚴格模式
class C {
a() {
let is = false;
try {
n = 1;
} catch (e) {
is = true;
}
console.log(is ? 'true' : 'false');
}
}
C.prototype.b = function () {
let is = false;
try {
n = 1;
} catch (e) {
is = true;
}
console.log(is ? 'true' : 'false');
};
let c = new C();
c.a(); // true,是嚴格模式。
c.b(); // false,不是嚴格模式。
在方法前加上static
關鍵字表示此方法為靜態方法,它存在於類本身,不能被例項直接訪問。靜態方法中的this
指向類本身。因為處於不同物件上,靜態方法和原型方法可以重名。ES6新增了一個命令new.target
,指代new
後面的建構函式或class
,該命令的使用有某些限制,具體請看下面示例。
// --- static
class C {
static a() { console.log(this === C); }
a() { console.log(this instanceof C); }
}
let c = new C();
C.a(); // true
c.a(); // true
// --- new.target
// 建構函式
function C() {
console.log(new.target);
}
C.prototype.a = function () { console.log(new.target); };
let c = new C(); // 打印出C
c.a(); // 在普通方法中為undefined。
// --- 類
class C {
constructor() { console.log(new.target); }
a() { console.log(new.target); }
}
let c = new C(); // 打印出C
c.a(); // 在普通方法中為undefined。
// --- 在函式外部使用會報錯
new.target; // 報錯
2 extends
ES5中的經典繼承方法是寄生組合式繼承,子類會分別繼承父類例項和原型上的屬性和方法。ES6中的繼承本質也是如此,不過實現方式有所改變,具體如下面的程式碼。可以看到,原型上的繼承是使用extends
關鍵字這一更接近傳統語言的形式,例項上的繼承是通過呼叫super
完成子類this
塑造。表面上看,方式更為的統一和簡潔。
class C1 {
constructor(a) { this.a = a; }
b() { console.log('b'); }
}
class C extends C1 { // 繼承原型資料
constructor() {
super('a'); // 繼承例項資料
}
}
2.1 與建構函式對比
使用extends
繼承,不僅僅會將子類的prototype
屬性的原型物件(__proto__
)設定為父類的prototype
,還會將子類本身的原型物件(__proto__
)設定為父類本身。這意味著子類不單單會繼承父類的原型資料,也會繼承父類本身擁有的靜態屬性和方法。而ES5的經典繼承只會繼承父類的原型資料。不單單是財富,連老爸的名氣也要獲得,不錯不錯。
class C1 {
static get a() { console.log('a'); }
static b() { console.log('b'); }
}
class C extends C1 {
}
// 等價,沒有構造方法會預設新增。
class C extends C1 {
constructor(...args) {
super(...args);
}
}
let c = new C();
C.a; // a,繼承了父類的靜態屬性。
C.b(); // b,繼承了父類的靜態方法。
console.log(Object.getPrototypeOf(C) === C1); // true,C的原型物件為C1
console.log(Object.getPrototypeOf(C.prototype) === C1.prototype); // true,C的prototype屬性的原型物件為C1的prototype
ES5中的例項繼承,是先創造子類的例項物件this
,再通過call
或apply
方法,在this
上新增父類的例項屬性和方法。當然也可以選擇不繼承父類的例項資料。而ES6不同,它的設計使得例項繼承更為優秀和嚴謹。
在ES6的例項繼承中,是先呼叫super
方法建立父類的this
(依舊指向子類)和新增父類的例項資料,再通過子類的建構函式修飾this
,與ES5正好相反。ES6規定在子類的constructor
方法裡,在使用到this
之前,必須先呼叫super
方法得到子類的this
。不呼叫super
方法,意味著子類得不到this
物件。
class C1 {
constructor() {
console.log('C1', this instanceof C);
}
}
class C extends C1 {
constructor() {
super(); // 在super()之前不能使用this,否則報錯。
console.log('C');
}
}
new C(); // 先打印出C1 true,再列印C。
2.2 super
關鍵字super
比較奇葩,在不同的環境和使用方式下,它會指代不同的東西(總的說可以指代物件或方法兩種)。而且在不顯式的指明是作為物件或方法使用時,比如console.log(super)
,會直接報錯。
作為函式時。super
只能存在於子類的構造方法中,這時它指代父類建構函式。
作為物件時。super
在靜態方法中指代父類本身,在構造方法和原型方法中指代父類的prototype
屬性。不過通過super
呼叫父類方法時,方法的this
依舊指向子類。即是說,通過super
呼叫父類的靜態方法時,該方法的this
指向子類本身;呼叫父類的原型方法時,該方法的this
指向該(子類的)例項。而且通過super
對某屬性賦值時,在子類的原型方法裡指代該例項,在子類的靜態方法裡指代子類本身,畢竟直接在子類中通過super
修改父類是很危險的。
很迷糊對吧,瘋瘋癲癲的,還是結合著程式碼看吧!
class C1 {
static a() {
console.log(this === C);
}
b() {
console.log(this instanceof C);
}
}
class C extends C1 {
static c() {
console.log(super.a); // 此時super指向C1,打印出function a。
this.x = 2; // this等於C。
super.x = 3; // 此時super等於this,即C。
console.log(super.x); // 此時super指向C1,打印出undefined。
console.log(this.x); // 值已改為3。
super.a(); // 打印出true,a方法的this指向C。
}
constructor() {
super(); // 指代父類的建構函式
console.log(super.c); // 此時super指向C1.prototype,打印出function c。
this.x = 2; // this等於新例項。
super.x = 3; // 此時super等於this,即例項本身。
console.log(super.x); // 此時super指向C1.prototype,打印出undefined。
console.log(this.x); // 值已改為3。
super.b(); // 打印出true,b方法的this指向例項本身。
}
}
2.3 繼承原生建構函式
使用建構函式模式,構建繼承了原生資料結構(比如Array
)的子類,有許多缺陷的。一方面由上文可知,原始繼承是先建立子類this
,再通過父類建構函式進行修飾,因此無法獲取到父類的內部屬性(隱藏屬性)。另一方面,原生建構函式會直接忽略call
或apply
方法傳入的this
,導致子類根本無法獲取到父類的例項屬性和方法。
function MyArray(...args) {
Array.apply(this, args);
}
MyArray.prototype = Array.prototype;
// MyArray.prototype.constructor = MyArray;
let arr = new MyArray(1, 2, 3); // arr為物件,沒有儲存值。
arr.push(4, 5); // 在arr上新增了0,1和length屬性。
arr.map(d => d); // 返回陣列[4, 5]
arr.length = 1; // arr並沒有更新,依舊有0,1屬性,且arr[1]為5。
建立類的過程,是先構造一個屬於父類卻指向子類的this
(繞口),再通過父類和子類的建構函式進行修飾。因此可以規避建構函式的問題,獲取到父類的例項屬性和方法,包括內部屬性。進而真正的建立原生資料結構的子類,從而簡單的擴充套件原生資料型別。另外還可以通過設定Symbol.species
屬性,使得衍生物件為原生類而不是自定義子類的例項。
class MyArray extends Array { // 實現是如此的簡單
static get [Symbol.species]() { return Array; }
}
let arr = new MyArray(1, 2, 3); // arr為陣列,儲存有1,2,3。
arr.map(d => d); // 返回陣列[1, 2, 3]
arr.length = 1; // arr正常更新,已包含必要的內部屬性。
需要注意的是繼承Object
的子類。ES6改變了Object
建構函式的行為,一旦發現其不是通過new Object()
這種形式呼叫的,建構函式會忽略傳入的引數。由此導致Object
子類無法正常初始化,但這不是個大問題。
class MyObject extends Object {
static get [Symbol.species]() { return Object; }
}
let o = new MyObject({ id: 1 });
console.log(o.hasOwnPropoty('id')); // false,沒有被正確初始化