banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

深入JavaScript學習之路(二)JavaScript中的類

上一節都是基於 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 引擎內部訪問。
  • 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 新增的類很大程度上是基於既有原型機制的語法糖
  • 類的語法讓開發者可以優雅地定義向後兼容的類
  • 類既可以繼承內置類型,也可以繼承自定義類型
  • 類有效地跨越了對象實例對象原型對象類之間的鴻溝
  • 通過 類混入 可以巧妙地實現類似多繼承的效果,但不建議這麼做,因為 “組合勝過繼承”
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。