上一節都是基於 ES5 的特性來模擬實現類似於類 class 的行為,不難看出這些方法各有各自的問題,實現繼承的代碼也顯得非常冗長和混亂。因此,ES6
中新引入的 class
關鍵字具備了正式定義類的能力,它實際上是一個語法糖,背後使用的仍然是原型和構造函數的概念。
類定義#
類聲明與類表達式兩種定義方法,都使用 class
關鍵字
// 類聲明
class Person {}
// 類表達式
const Animal = class {}
與函數表達式類似,類表達式在它們被求值前也不能引用。
但不同之處在於
- 函數聲明可以提升,而類定義不能提升
- 函數受 函數作用域 限制,類受 塊作用域 限制
// 函數聲明可以提升,而類定義不能提升*
console.log(FunctionExpression); // undefined
var FunctionExpression = function() {};
console.log(FunctionExpression); // function() {}
console.log(ClassExpression); // undefined
var ClassExpression = class {};
console.log(ClassExpression); // class {}
// 函數受函數作用域限制,類受塊作用域限制
{
function FunctionDeclaration() {}
class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
類構成#
類可以包括以下方法,但都不是必須的,空的類定義仍然有效。
- 構造函數(constructor)
- 獲取函數及設置函數(get 和 set)
- 靜態類方法(static)
- 其他實例方法
默認情況下,類定義中的代碼都在 嚴格模式 下執行。
首字母大寫這個就不必多說了,可以用於區分通過他創建的實例
類表達式的名稱是可選的。在把類表達式賦值給變量後,可以通過 name
屬性取得類表達式的名稱字符串。但不能在類表達式作用域外部訪問這個標識符。
let Person = class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
}
let p = new Person();
p.identify(); // PersonName PersonName
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined
類構造函數#
constructor
關鍵字用於在類定義塊內部創建類的構造函數。
constructor
會告訴解釋器:使用new
操作符創建類的新實例時,應該調用這個函數。- 構造函數的定義不是必需的,不定義構造函數相當於將構造函數定義為空函數。
1、實例化#
constructor 函數實際上也是個語法糖,他讓 JS 解釋器知道使用 new
來定義類的一個實例時應使用 constructor
函數進行實例化。
讓我們複習一下使用 new
調用構造函數所執行的操作:
- 在內存中創建一個新對象
let obj = new Object()
- 將新對象內部的
[[Prototype]]
賦值為構造函數的prototype
obj.__proto__ = constructor.prototype;
- 構造函數內部的
this
指向這個新對象 - 執行這個構造函數內部代碼
- 上述兩步 相當於
let res = contructor.apply(obj, args)
- 上述兩步 相當於
- 若構造函數返回 非空對象, 則返回該對象
res
;否則,返回剛創建的新對象obj
return typeof res === 'object' ? res: obj;
類實例化時傳入的參數會作為構造函數的參數,若不需要參數,則類名後面的括號也是可選的 可以直接 new Person
類構造函數會在執行之後返回一個對象,這個對象會被用作實例化的對象。
注意:若構造函數返回的這個對象 res
是一個非空對象,且這個對象與 new 中第一步新建的對象obj
無關係,則新建的對象會被回收哦,而且通過 instanceof
檢測是無法檢測出跟該類有關聯,因為並沒有修改原型指針。
2、類的本質?#
class Person {}
console.log(Person); // class Person {}
console.log(typeof Person); // function
是函數!前面也說到了,它本質上就是一個語法糖,所以它具有跟函數一樣的行為。
可以向其他對象或函數引用一樣將其當作參數傳遞,也可以立即實例化(類似函數表達式的立即執行)
// 類可以像函數一樣在任何地方定義,比如在數組中
let classList = [
class {
constructor(id) {
this.id_ = id;
console.log(`instance ${this.id_}`);
}
}
];
function createInstance(classDefinition, id) {
return new classDefinition(id);
}
let foo = createInstance(classList[0], 3141); // instance 3141
// 立即執行
let p = new class Foo {
constructor(x) {
console.log(x);
}
}('bar'); // bar
console.log(p); // Foo {}
實例、原型和類成員#
類可以非常方便的定義以下三種成員,跟其它語言相似。
- 實例上的成員
- 原型上的成員
- 類本身的成員(靜態類成員)
實例成員#
在constructor
中可以通過 this 為新創建的實例添加自有屬性,每個實例是無法共享這裡的屬性的。
- ps:在類塊中直接寫的成員也會成為實例屬性
class Person {
sex = '女'
age = 21
constructor() {
// 這個例子先使用對象包裝類型定義一個字符串
// 為的是在下面測試兩個對象的相等性
this.name = new String('Jack');
this.sayName = () => console.log(this.name);
this.nicknames = ['Jake', 'J-Dog']
}
}
let p1 = new Person(),
p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
console.log(p1.sex, p1.age) // 女 21
原型成員#
將在類塊中定義的方法作為原型方法(在所有實例中共享)
class Person {
constructor() {
// 添加到 this 的所有內容都會存在於不同的實例上
this.locate = () => console.log('instance');
}
// 在類塊中定義的所有內容都會定義在類的原型上
locate() {
console.log('prototype');
}
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype
類定義也支持獲取和設置訪問器。語法與行為跟普通對象一樣:
class Person {
sex = '女'
age = 21
constructor() {
this.sayName = () => console.log(this.name);
this.nicknames = ['Jake', 'J-Dog']
}
set name(newName) {
this.name_ = newName;
}
get name() {
return this.name_;
}
}
let p = new Person();
p.name = 'cosine';
console.log(p.name); // cosine
靜態類方法#
靜態類成員在類定義中使用 static
關鍵字作為前綴。在靜態成員中,this
引用類自身。可以通過類名。方法名直接訪問
非函數原型和類成員#
雖然類定義並不顯式支持在原型或類上添加成員數據,但在類定義外部,可以手動添加:
// 在類上定義數據成員
Person.greeting = 'My name is';
// 在原型上定義數據成員
Person.prototype.name = 'Jake';
- 但是不建議這麼做,在原型和類上添加可變數據成員是一種反模式。一般來說,對象實例應該獨自擁有通過
this
引用的數據
迭代器與生成器方法#
類定義語法支持在原型和類本身上定義生成器方法:
class Person {
// 在原型上定義生成器方法
*createNicknameIterator() {
yield 'cosine1';
yield 'cosine2';
yield 'cosine3';
}
// 在類上定義生成器方法
static *createJobIterator() {
yield 'bytedance';
yield 'mydream';
yield 'bytedance mydream!';
}
}
let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // bytedance
console.log(jobIter.next().value); // mydream
console.log(jobIter.next().value); // bytedance mydream!
let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // cosine1
console.log(nicknameIter.next().value); // cosine2
console.log(nicknameIter.next().value); // cosine3
因為支持生成器方法,所以可以通過添加一個默認的迭代器,把類實例變成可迭代對象
class People {
constructor() {
this.nicknames = ['cosine1', 'cosine2', 'cosine3'];
}
*[Symbol.iterator]() {
yield *this.nicknames.entries();
}
}
let workers = new People();
for (let [idx, nickname] of workers) {
console.log(idx, nickname);
}
// 0 cosine1
// 1 cosine2
// 2 cosine3
繼承#
ES 6 最出色的一點就是原生支持了類繼承機制,是之前原型鏈的一個語法糖
繼承基礎#
ES6 類支持單繼承,使用 extends
關鍵字,可以繼承一個類,也可以繼承普通的構造函數(保持向後兼容)
- 派生類可以通過原型鏈訪問到類和原型上定義的方法
this
的值會反映調用相應方法的實例或者類。extends
關鍵字也可以在類表達式中使用
構造函數、HomeObject 和 super ()#
- 派生類的方法可以通過
super
關鍵字引用它們的原型- 只能在派生類中使用
- 僅限於類構造函數、實例方法和靜態方法內部
- 在類構造函數中使用
super
可以調用父類構造函數。
[[HomeObject]]
- ES6 給類構造函數和靜態方法添加了內部特性
[[HomeObject]]
- 指向定義該方法的對象的指針,這個指針是自動賦值的,而且只能在 JavaScript 引擎內部訪問。
- ES6 給類構造函數和靜態方法添加了內部特性
super
始終會定義為[[HomeObject]]
的原型。
在使用 super 時要注意幾個問題
super
只能在 派生類的構造函數和靜態方法中使用- 不能單獨引用
super
關鍵字,要麼用它調用構造函數,要麼用它引用靜態方法 - 調用
super()
會調用父類構造函數,並將返回的實例賦值給this
,所以不能在調用super()
之前引用this
- 需要給父類構造函數傳參,則需要手動將其傳入
super
- 如果沒有定義類構造函數,在實例化派生類時會調用
super()
,而且會傳入所有傳給派生類的參數 - 如果在派生類中顯式定義了構造函數,則要麼必須在其中調用
super()
,要麼必須顯式的返回一個對象。
抽象基類#
抽象基類也就是一種可供其他類繼承,但本身不會被實例化的類。在其他語言中也有這種概念。雖然 ECMAScript 沒有專門支持這種類的語法 ,但通過 new.target
也很容易實現
new.target
保存通過new
關鍵字調用的類或函數- 通過在實例化時檢測
new.target
是不是抽象基類,可以阻止對抽象基類的實例化
// 抽象基類
class Vehicle {
constructor() {
console.log(new.target);
if (new.target === Vehicle) {
throw new Error('Vehicle cannot be directly instantiated');
}
}
}
// 派生類
class Bus extends Vehicle {}
new Bus(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated
通過在抽象基類的構造函數中進行檢查,可以要求派生類必須定義某個方法。因為原型方法在調用原型類的構造函數之前就已經存在了,所以可以通過 this 關鍵字來檢查相應的方法是否定義
// 抽象基類
class Vehicle {
constructor() {
if (new.target === Vehicle)
throw new Error('Vehicle cannot be directly instantiated');
if (!this.foo)
throw new Error('Inheriting class must define foo()');
console.log('success!');
}
}
// 派生類
class Bus extends Vehicle { foo() {} }
// 派生類
class Van extends Vehicle {}
new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()
繼承內置類型#
ES6 類為繼承內置引用類型提供了順暢的機制,開發者可以方便地擴展內置類型,如下,向 Array 添加了一个洗牌算法的方法
class SuperArray extends Array {
shuffle() {
// 加一個洗牌算法
for (let i = this.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); //生成[0, i+1)的隨機數
[this[i], this[j]] = [this[j], this[i]]; // 交換兩張牌
}
}
}
let a = new SuperArray(1, 2, 3, 4, 5);
console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // 隨機的新數組
a.shuffle();
console.log(a); // 隨機的新數組
有些內置類型的方法會返回新實例。默認情況下,返回實例的類型與原始實例的類型是一致的,但如果想覆蓋這個默認行為,則可以覆蓋 Symbol.species
訪問器,這個訪問器決定在創建返回的實例時使用的類
class SuperArray extends Array {
static get [Symbol.species]() {
return Array;
}
}
let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter(x => !!(x%2))
console.log(a1); // [1, 2, 3, 4, 5]
console.log(a2); // [1, 3, 5]
console.log(a1 instanceof SuperArray, a1 instanceof Array); // true true
console.log(a2 instanceof SuperArray, a2 instanceof Array); // false true
類混入(多繼承的模擬實現)#
將不同類的行為集中到一個類是一種常見的 JavaScript 模式。雖然 ES6 沒有顯式支持多類繼承,但通過現有特性可以輕鬆地模擬這種行為。
首先要注意以下兩點:
- 如果只是需要混入多個對象的屬性,那麼使用
Object.assign()
就可以了。Object.assign()
方法是為了專門為混入對象行為而設計的。只有在需要混入類的行為時才有必要自己實現混入表達式。
- 很多 JavaScript 框架(特別是 React)已經拋棄混入模式,轉向了組合模式
- 組合即是把方法提取到獨立的類和輔助對象中,然後把它們組合起來,不使用繼承。
- 輕鬆實現的軟件設計原則:“組合勝過繼承(composition over inheritance)。”
extends
關鍵字後面可以是一个 JavaScript 表達式。任何可以解析為一個類或一個構造函數的表達式都是有效的。這個表達式會在求值類定義時被求值,這就是類混入的原理
如 Person 類需要組合類 A、B、C,則需要某種機制實現 B 繼承 A,C 繼承 B,而 Person 再繼承 C 從而實現將 A、B、C 組合至這個超類中:
class Vehicle {}
let AMixin = (Superclass) => class extends Superclass {
afunc() { console.log('A Mixin'); }
};
let BMixin = (Superclass) => class extends Superclass {
bfunc() { console.log('B Mixin'); }
};
let CMixin = (Superclass) => class extends Superclass {
cfunc() { console.log('C Mixin'); }
};
function mixin(BaseClass, ...Mixins) {
return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}
class Bus extends mixin(Vehicle, AMixin, BMixin, CMixin) {}
let b = new Bus();
b.afunc(); // A Mixin
b.bfunc(); // B Mixin
b.cfunc(); // C Mixin
小結#
- ECMAScript 6 新增的類很大程度上是基於既有原型機制的語法糖
- 類的語法讓開發者可以優雅地定義向後兼容的類
- 類既可以繼承內置類型,也可以繼承自定義類型
- 類有效地跨越了對象實例、對象原型和對象類之間的鴻溝
- 通過 類混入 可以巧妙地實現類似多繼承的效果,但不建議這麼做,因為 “組合勝過繼承”