Decorators 低侵入性探索
當大家都再聊要不要學習框架的時候,筆者卻還在學規範,當標題黨。本文的一切,源於網路,感恩開源的世界...
雖然本文的初衷是講 ES7 中的裝飾器,但筆者更喜歡在探索的過程中加深對前端基礎知識的理解。本著一顆刨根問底兒的心,分享內容會盡可能多地將一些關聯知識串聯起來講解。
乍一看可能會有點亂,但卻是筆者學習一個新知識的完整路徑。 一種帶著關鍵詞去學習的方法,比較笨,讀者選讀即可,取精華去糟粕。
另外,這個倉庫 是專門用來記錄 Decorators 低侵入性探索 收穫的知識。後續可能會結合 mobx 原始碼、以及在 React 中實際應用場景來深入。
前端知識廣度無邊無際,深度深不可測,筆者記性不好,類似的倉庫有:
概覽
Decorators 屬於 ES7, 目前處於提案階段,可通過 babel
或 TS
編譯使用。
本文屬於探索型,主要分為三部分:
-
Decorators 基礎知識
-
Babel 與 TypeScript 支援
-
常見應用場景
基礎知識
裝飾器 (Decorators) 讓你可以在設計時對類和類的屬性進行“註解”和修改。
Decorators
一般接受三個引數:
-
目標物件 target
-
屬性名稱 key
-
描述物件 descriptor
可選地返回一個描述物件來安裝到目標物件上,其的函式簽名為
function(target, key?, descriptor?)
。
Object.defineProperty
Decorators
的本質是利用了 ES5 的 Object.defineProperty
方法,這個方法著實改變了很多,比如 vue 響應式資料的實現方法,當然還有更為迷人 proxy
,是不是發現,很多框架背後的靠山都離不開這些底層規範的支援。
下面來簡單瞭解下這個方法:
Object.defineProperty()
方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。
Object.defineProperty(obj, prop, descriptor)
-
obj
要在其上定義屬性的物件。 -
prop
要定義或修改的屬性的名稱。 -
descriptor
將被定義或修改的屬性描述符。 -
返回值
被傳遞給函式的物件。
其中 descriptor
可通過 Object.getOwnPropertyDescriptor()
方法獲得。
Object.getOwnPropertyDescriptor
Object.getOwnPropertyDescriptor()
方法返回指定物件上一個自有屬性對應的屬性描述符。(自有屬性指的是直接賦予該物件的屬性,不需要從原型鏈上進行查詢的屬性)
-
obj
需要查詢的目標物件 -
prop
目標物件內屬性名稱(String 型別) -
返回值
如果指定的屬性存在於物件上,則返回其屬性描述符物件(property descriptor),否則返回undefined
。
Descriptor
一個屬性描述符是一個記錄,由下面屬性當中的某些組成的:
-
value
該屬性的值(僅針對資料屬性描述符有效) -
writable
當且僅當屬性的值可以被改變時為true
。(僅針對資料屬性描述有效) -
configurable
當且僅當指定物件的屬性描述可以被改變或者屬性可被刪除時,為true
。 -
enumerable
當且僅當指定物件的屬性可以被枚舉出時,為true
。 -
get
獲取該屬性的訪問器函式(getter
)。如果沒有訪問器, 該值為undefined
。(僅針對包含訪問器或設定器的屬性描述有效) -
set
獲取該屬性的設定器函式(setter
)。 如果沒有設定器, 該值為undefined
。(僅針對包含訪問器或設定器的屬性描述有效)
各式的裝飾器一般都是基於修改上述屬性來實現,比如 writable
可用於設定 @readonly
。更多的功能,可參考 lodash-decorator
基礎知識小結
現在我們對 Decorators 方法 function(target, key?, descriptor?)
混了個臉熟,同時知道了Object.defineProperty
和 Descriptor
與 Decorators 的聯絡。
但是,目前瀏覽器對 Es7 這一特性支援 並不友好。Decorators 目前還只是語法糖,嚐鮮可通過 babel 、TypeScript。
接下來就來了解這一部分的內容。
babel 與 Decorators
很多構建工具都離不開 babel,比如筆者用於快速跑 demo 的 parcel。雖然很多時候我們並不需要關心這些構建後的程式碼,但筆者建議有時間還是多瞭解下,畢竟前端打包後出現的 bug 還是很常見的。
回到裝飾器,現階段官方說有 2 種裝飾器,但從實際使用上可分為 4 種,分別是:
-
“類裝飾器” 作用於
class
。 -
“屬性裝飾器” 作用於屬性上的,這需要配合另一個的類屬性語法提案,或者作用於物件字面量。
-
“方法裝飾器” 作用於方法上。
-
“訪問器裝飾器” 作用於
getter
或setter
上的。
下面我們通過 babel 命令列,來感受一下各裝飾器:
babel 配置
先簡單介紹下 babel 的用法:
- 全域性安裝
babel
npm i -g babel
複製程式碼
- 配置
.babelrc
{
"presets": [["es2015", { "modules": false }]],
"plugins": ["transform-decorators-legacy", "transform-class-properties"],
"env": {
"development": {
"plugins": ["transform-es2015-modules-commonjs"]
}
}
}
複製程式碼
- 在
package.json
配置 npm script
{
"babel": "babel ./demo/demo.js -w --out-dir dist"
}
複製程式碼
該命令的意思是:監聽 demo
目錄下 demo.js
檔案,並將編譯結果輸出到 dist
目錄
下面列出各裝飾器在 babel
編譯後對應的輸出結果。
“類裝飾器”
從編譯後的結果可以看到,autobind
作為裝飾器只接受了一個引數,也就是類本身(建構函式)。
class MyClass = {}
MyClass = autobind(MyClass) || MyClass
複製程式碼
“方法裝飾器”
bebel 對於方法裝飾器
的處理會比較特別,下面看下核心處理:
var _class;
// 1、首先,初始化一個 class
var initClass = (_class = (function() {
// ... 類定義
})());
// 2、通過 `_applyDecoratedDescriptor` 方法使用傳入的裝飾器對 `_class.prototype` 中的方法進行裝飾處理。
var Decorator = _applyDecoratedDescriptor(
_class.prototype,
'getName',
[autobind],
Object.getOwnPropertyDescriptor(_class.prototype, 'getName'),
_class.prototype
);
// 3、利用逗號操作符的作用,返回裝飾完的 `_class`
var MyClass = (initClass, Decorator, _class);
複製程式碼
後續會對 _applyDecoratedDescriptor
方法進一步講解。
逗號操作符 對它的每個運算元求值(從左到右),並返回最後一個運算元的值。
“訪問器裝飾器”
“訪問器裝飾器” 的處理方式與 “方法裝飾器”類似。
“屬性裝飾器”
區別在於傳入的第三個引數 Descriptor
並不是由 Object.getOwnPropertyDescriptor(_class.prototype, 'getName')
返回的,並且多了一個 Descriptor
上並不存在的 initializer
屬性供 _applyDecoratedDescriptor
方法使用。
_applyDecoratedDescriptor(
_class.prototype,
'getName',
[autobind],
// Object.getOwnPropertyDescriptor(_class.prototype, 'getName'),
{
enumerable: true,
initializer: function initializer() {
return function() {};
}
}
))
複製程式碼
接下來就讓我們來看一下 _applyDecoratedDescriptor
都做了哪些事
_applyDecoratedDescriptor
_applyDecoratedDescriptor
其實是對 decorator
的一個封裝,用於處理多種情況。其接受的引數跟 decorator
大體一致。
-
target
目標物件 -
property
屬性名稱 -
descriptor
屬性描述物件 -
decorators
裝飾器函式 (陣列,表示可傳入多個裝飾器) -
context
上下文 -
返回值
屬性描述物件
function _applyDecoratedDescriptor(
target,
property,
decorators,
descriptor,
context
) {
// 1、通過傳入引數 `descriptor` 初始化最終匯出的 `屬性描述物件`
var desc = {};
Object['ke' + 'ys'](descriptor).forEach(function(key) {
desc[key] = descriptor[key];
});
desc.enumerable = !!desc.enumerable;
desc.configurable = !!desc.configurable;
// 2、存在 `value` 或者 class 初始化屬性 則將 `writable` 設定為 `true`
if ('value' in desc || desc.initializer) {
desc.writable = true;
}
// 3、處理傳入的 decorator 函式
// 其中 `reverse` 保證了,當同一個方法有多個裝飾器,會由內向外執行。
desc = decorators
.slice()
.reverse()
.reduce(function(desc, decorator) {
return decorator(target, property, desc) || desc;
}, desc);
// 看 babel 編譯後的程式碼,當 `initializer` 不為 `undefined` 時,並不會傳入 `context`
// 筆者看不懂! ??? 這是一個永遠不會執行的邏輯... 難道改走 `_initDefineProp` 邏輯了?
if (context && desc.initializer !== void 0) {
desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
desc.initializer = undefined;
}
// 4. 使用 Object.defineProperty 對 `target` 物件的 `property` 屬性賦值為 `desc`
if (desc.initializer === void 0) {
Object['define' + 'Property'](target, property, desc);
desc = null;
}
return desc;
}
複製程式碼
void 運算子 對給定的表示式進行求值,然後返回
undefined
。
現在我們對 Descorators
有了大致的瞭解,接下來看下 Descorators 基於 babel 編譯下的裝飾器
自動繫結 this
我們先來看一個關於 this
的問題
this
的指向問題
class Person {
getPerson() {
return this;
}
}
let person = new Person();
const { getPerson } = person;
getPerson() === person; // false
person.getPerson() === person; // true
複製程式碼
這段程式碼中,getPerson
和 person.getPerson
指向同一個函式且返回 this
,但它們的執行結果卻不一樣。
this
指的是函式執行時所在的環境:
-
getPerson()
執行在全域性環境,所以this
指向全域性環境 -
person.getPerson
執行在person
環境,所以this
指向person
關於 this
的原理可以參考 這篇:
在本例中,getPerson()
是一個函式,JavaScript 引擎會將函式單獨儲存在記憶體中,然後再將函式的地址賦值給 getPerson
屬性的 value
屬性 (descriptor)
由於函式單獨存在於記憶體中,所以它可以在不同的環境 (上下文) 執行。
來看個例子:
// 注意,這裡都是用 var 宣告變數
var name = 'globalName';
var fn = function() {
console.log(this.name);
return this.name;
};
var person = {
getPerson: fn,
name: 'personName'
};
// 單獨執行
var ref = person.getPerson;
ref();
// or
fn();
// person 環境指執行
person.getPerson();
複製程式碼
函式可以在不同的執行環境 (context),所以需要一種機制,能夠在函式體內部獲得當前的執行環境。
這裡 this
的設計目的就是在函式體內部,指代函式當前的執行環境。
例子中,fn()
和 ref()
的執行環境都是 全域性執行環境 而 person.getPerson()
的執行環境是 person
,因此得到了不同的 this
解決 this
指向的方法有很多種,比如函式的原型方法
通過上面學習到的知識,接著來講解 Decorator
中如何實現 autobind
給函式或類自動繫結 this
autobind 實現邏輯
一、 首先來看下 如何給類的方法自動繫結 this
:
- 開始前,先來執行下面這段程式碼:
var obj = {
fn: function() {
console.log('執行時的', this);
}
};
var fn = Object.getOwnPropertyDescriptor(obj, 'fn').value;
Object.defineProperty(obj, 'fn', {
get() {
console.log('get 訪問器裡的', this);
return fn;
}
});
var fn = obj.fn;
fn();
obj.fn();
複製程式碼
-
可以得到的一個結論:
get(){}
訪問器屬性裡面的this
始終指向obj
這個物件。 -
如果簡化邏輯,也就是不考慮其他特殊情況下,
autobindMethod
應該是這樣的:
function autobindMethod(target, key, { value: fn, configurable, enumerable }) {
return {
configurable,
enumerable,
get() {
const boundFn = fn.bind(this);
defineProperty(this, key, {
configurable: true,
writable: true,
enumerable: false,
value: boundFn
});
return boundFn;
},
set: createDefaultSetter(key)
};
}
複製程式碼
bind() 方法建立一個新的函式, 當這個新函式被呼叫時 this 鍵值為其提供的值,其引數列表前幾項值為建立時指定的引數序列。
有了 autobind
這個裝飾器,getName
方法的 this
就始終指向例項物件本身了。
class TestGet {
@autobind
getName() {
console.log(this);
}
}
複製程式碼
二、接著來看下類的 autobind
實現
對類繫結 this
其實就是為了批量給類的例項方法繫結 this
所以只要獲取所有例項方法,再呼叫 autobindMethod
即可。
function autobindClass(klass) {
const descs = getOwnPropertyDescriptors(klass.prototype);
const keys = getOwnKeys(descs);
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i];
const desc = descs[key];
if (typeof desc.value !== 'function' || key === 'constructor') {
continue;
}
defineProperty(
klass.prototype,
key,
autobindMethod(klass.prototype, key, desc)
);
}
}
複製程式碼
以上實現考慮的是 Babel 編譯後的檔案,除了 Babel ,TypeScript 也支援編譯 Decorators。
因此就需要一個更為通用的 Decorators 包裝函式,接下來讓我們一起實現它。
TypeScript 與 Decorators
先來一起看下 TypeScript 編譯後的結果。
從上圖可以看出,TypeScript 對 Decorator 編譯的結果跟 Babel 略微不同,TypeScript 對屬性和方法沒有過多的處理,唯一的區別可能就是在對類的處理上,傳入的 target
為類本身,而不是 Prototype
。
通用 Decorator
無論是用什麼編譯器生成的程式碼,最終引數還是離不開 target, name, descriptor
。另外,無論怎麼包裝,最終也是為了提供一個能夠新增或者修改 descriptor
某個屬性的函式,只要是對屬性的修改,就必然離不開 Object.defineProperty
。
有時候,我們難以讀懂某段程式碼,可能只是因為沒有進入這段程式碼的真實上下文(應用場景)。如果是按需求來開發某個 Decorator,事情就會變得簡單。
通用 Decorator,意味著將要用於生成具有共有特徵且用於不同場景的裝飾器,通常最容易讓人想到就是工廠模式。
我們來看下 lodash-decorators
中的實現:
export class InternalDecoratorFactory {
createDecorator(config: DecoratorConfig): GenericDecorator {
// 基礎裝飾器
}
createInstanceDecorator(config: DecoratorConfig): GenericDecorator {
// 生成用於例項的裝飾器
}
private _isApplicable(
context: InstanceChainContext,
config: DecoratorConfig
): boolean {
// 是否可呼叫
}
private _resolveDescriptor(
target: Object,
name: string,
descriptor?: PropertyDescriptor
): PropertyDescriptor {
// 獲取 Descriptor 的通用方法。
}
}
複製程式碼
這裡用 TypeScript 的好處在於,類本身具備某種結構。也就是可供型別描述使用。另外,在看原始碼過程中,TypeScript 的型別有助於快速理解作者意圖。
比如單看上面程式碼,我們就可以知道 createDecorator
和 createInstanceDecorator
都接收型別為 DecoratorConfig
的引數,以及返回都是通用的 Decorator GenericDecorator
。
那我們先來看下:
export interface DecoratorConfigOptions {
bound?: boolean;
setter?: boolean;
getter?: boolean;
property?: boolean;
method?: boolean;
optionalParams?: boolean; // 是否使用自定義引數
}
export class DecoratorConfig {
constructor(
public readonly execute: Function, // 處理函式,如傳入 debounce 函式
public readonly applicator: Applicator, // 根據處理函式不同,選用不同的函式呼叫程式。
public readonly options: DecoratorConfigOptions = {}
) {}
}
複製程式碼
關鍵的引數有:
execute
裝飾函式的核心處理函式。applicator
主要作用是用於配置引數及函式的呼叫。options
額外的配置選項,如是否是屬性,是否是方法,是否使用自定義引數等。
這裡的 Applicator 屬於函式呼叫中公共部分的抽離:
export interface ApplicateOptions {
config: DecoratorConfig;
target: any;
value: any;
args: any[];
instance?: Object;
}
export abstract class Applicator {
abstract apply(options: ApplicateOptions): any;
}
複製程式碼
一個通用的 Decorator 的核心部分差不多就這些了,但由於筆者實際應用 Decorators 的地方不多,對於 lodash-decorators
原始碼中為什麼有 createDecorator
和 createInstanceDecorator
兩種生成方法,以及為什麼要引入 weekMap
的原因,一時也給不了非常準確的答案。createInstanceDecorator
也許是出於原型鏈考慮?因為例項,才能訪問原型鏈繼承後得到的方法,以後有機會再單獨深入。
希望有這方面研究的讀者可以不吝賜教,筆者不勝感激。
常見應用場景
結合 lodash
,關注點分離了。實現各種 decorators 在程式碼實現上就變得非常簡單。比如,前端可能會經常用到的函式節流,函式防抖,delay。
import debounce = require('lodash/debounce');
import { PreValueApplicator } from './applicators';
const decorator = DecoratorFactory.createInstanceDecorator(
new DecoratorConfig(debounce, new PreValueApplicator(), { setter: true })
);
export function Debounce(
wait?: number,
options?: DebounceOptions
): LodashDecorator {
return decorator(wait, options);
}
複製程式碼
通過呼叫 DecoratorFactory
生成通用的 decorator,實現各種裝飾器功能就只需要像上面一樣組織程式碼即可。
另外像 Mixin
這種看似組合優於繼承的用法是一種對類的裝飾,可以這麼去實現:
import assign = require('lodash/assign');
export function Mixin(...srcs: Object[]): ClassDecorator {
return ((target: Function) => {
assign(target.prototype, ...srcs);
return target;
}) as any;
}
複製程式碼
更多的功能,筆者就不再過多贅述。再講就變成 lodash 原始碼解析了。有心的讀者可以去舉一反三了,或者直接看 lodash-decorators
原始碼。畢竟我也是看它們原始碼來學習的。
總結
這麼草率的結束,也許意味著還有更多學習空間。
Decorators
涉及的知識並不難,關鍵在於如何巧妙運用。初期沒經驗,可以學習筆者看些周邊庫,比如 lodash-decorators
。所謂的低侵入性,也只是視覺感官上的,不過確實多少能提高程式碼的可讀性。
最後,前端路上,多用 【聞道有先後,術業有專攻】安慰自己,學習永無止境。 感謝閱讀,願君多采擷!