在不少框架中,都會對“擴充套件”這一概念有需求。所謂擴充套件,即一個可組合的元件,用於嵌入到目標的生命週期中,對目標的行為進行額外的處理使得目標擁有不同的表現。
一個非常簡單的案例即日誌的記錄。通常框架自身並不會有業務相關的日誌記錄的功能,而業務程式碼也不希望混入並非業務邏輯的日誌記錄部分。那麼使用一個擴充套件,在合適的點進行日誌的收集和儲存是很合適的設計。
在以往,比較流行的擴充套件通常有幾種形式:
- Mixin形式。這種形式下擴充套件與目標形成完全的覆蓋關係,屬於暴力而簡單的方法。
1234567891011121314151617181920class Component {constructor({mixins}) {mixins.forEach(mixin => Object.assign(this, mixin));}doWork() {// ...}}let logMixin = {doWork(...args) {console.log('Start do work');Component.prototype.doWork.apply(this, ...args);console.log('Finish do work');}};let foo = new Component({mixins: [logMixin]});foo.doWork(); - 生命週期形式。這種模式在框架設計之初就定義多個擴充套件可運作的點,在生命週期的特定階段啟用擴充套件,同時給予擴充套件足夠的事件以及可重寫的方法來完成其功能:
123456789101112131415161718192021222324class Component {constructor({extensions}) {for (let extension of extensions) {extension.target = this;extension.enable();}}doWork() {this.fire('beforedowork');// ...this.fire('dowork');}}let logExtension = {enable() {this.target.on('beforedowork', () => console.log('Start do work'));this.target.on('finishdowork', () => console.log('Finish do work'));}};let foo = new Component({extensions: [logExtension]});foo.doWork();
但是這兩種方式都存在著一些固有的缺陷:
- 目標需要有非常精細的設計來支援擴充套件的運作,如果事件不夠則擴充套件需要重寫特定方法。雖然JavaScript確實是一個弱型別的動態語言,但是否應該放任一段外部邏輯重寫任意方法,在設計上是值得商榷的。
- 類的保護(
protected
)方法是否對擴充套件開放,在概念上難以權衡。如果不開放保護方法則很可能擴充套件沒有足夠的資訊來完成工作,而開放保護方法則破壞了物件導向本身封裝性的概念。 - 多個擴充套件都對同一個方法的重寫時存在衝突,設計不合理導致相互覆蓋很可能讓擴充套件產生不可預期的結果。
- 重寫方法較為複雜,需要先保留原有方法函式引用再進行重寫,重寫過程中需要使用
.apply
或.call
進行呼叫,無法使用如super
等ES6的語言特性。 - 如果擴充套件應用的物件不幸經過了
Object.freeze
等方法的處理,則擴充套件很大概率將無法工作。 - 擴充套件啟用/銷燬的生命週期難以設計,過早介入可能導致擴充套件在啟用時沒有足夠的資訊判斷自己需要做的工作,過晚介入則可能錯過一些階段。
總結以上的問題,我們發現很多問題在於目標成員的可訪問性上,而可訪問性是應用於“繼承”這一概念上的。
那麼,一個很好的方案是讓擴充套件也在“繼承”上進行體現,而不是以“組合”的關係工作。雖然我們一直說“組合優於繼承”,但是在可訪問性限制等種種因素下,在擴充套件這一場景下,繼承恰恰能給予更好的支援。
在JavaScript中,類實際上就是一個函式,那麼對於類進行轉換的所謂“擴充套件”,我們也稱其為一個高階函式,其正規化為:
1 |
F(class1) => class2 |
即我們的擴充套件接受一個類的建構函式(也是類本身),返回另一個類,其作用是通過繼承對類進行一定的轉換。在這種設計下,我們上面的程式碼可以實現為:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
class Component { constructor() { } doWork() { // ... } } let log = (Target) => { return class extends Target { doWork() { console.log('Start do work') super.doWork(); console.log('Finish do work') } } }; let create = (Class, extensions) { let TargetClass = extensions.reduce((Raw, extension) => extension(Raw), Class); return new TargetClass(); }; let foo = create(Component, [log]); foo.doWork(); |
通過繼承我們可以很好地實現方法的重寫,也可以利用如super
這樣的關鍵字,同時也不需要考慮doWork
是保護方法還是公開方法,使得Component
類完全不需要為了擴充套件而進行額外的設計,所有的擴充套件均在外部的工廠(create
函式)實現,更好地進行了邏輯的解耦。
同時,這一方案也與JavaScript Class Decorator的功能相相容,其微小的區別在於:
- 由於擴充套件生效時類的
prototype
已經封閉,因此擴充套件必須返回一個子類,而不能直接對prototype
進行修改。 - 擴充套件可在建立例項時動態定義。
由於擴充套件的限制比裝飾器更為嚴格,因此一個擴充套件同時可以靜態地在定義類時通過裝飾器的形式使用,也可以在工廠生產例項時動態地使用,這也保證了更好的程式碼複用性。