Decorators 低侵入性探索

當你們都再聊要不要學習框架的時候,筆者卻還在學規範,當標題黨。本文的一切,源於網絡,感恩開源的世界...javascript

雖然本文的初衷是講 ES7 中的裝飾器,但筆者更喜歡在探索的過程當中加深對前端基礎知識的理解。本着一顆刨根問底兒的心,分享內容會盡量多地將一些關聯知識串聯起來說解。html

乍一看可能會有點亂,但倒是筆者學習一個新知識的完整路徑。 一種帶着關鍵詞去學習的方法,比較笨,讀者選讀便可,取精華去糟粕。前端

另外,這個倉庫 是專門用來記錄 Decorators 低侵入性探索 收穫的知識。後續可能會結合 mobx 源碼、以及在 React 中實際應用場景來深刻。vue

前端知識廣度一望無際,深度深不可測,筆者記性很差,相似的倉庫有:java

概覽

Decorators 屬於 ES7, 目前處於提案階段,可經過 babelTS 編譯使用。git

本文屬於探索型,主要分爲三部分:github

  • Decorators 基礎知識npm

  • Babel 與 TypeScript 支持json

  • 常見應用場景數組

基礎知識

裝飾器 (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.definePropertyDescriptor 與 Decorators 的聯繫。

可是,目前瀏覽器對 Es7 這一特性支持 並不友好。Decorators 目前還只是語法糖,嚐鮮可經過 babel 、TypeScript。

接下來就來了解這一部分的內容。

babel 與 Decorators

不少構建工具都離不開 babel,好比筆者用於快速跑 demo 的 parcel。雖然不少時候咱們並不須要關心這些構建後的代碼,但筆者建議有時間仍是多瞭解下,畢竟前端打包後出現的 bug 仍是很常見的。

回到裝飾器,現階段官方說有 2 種裝飾器,但從實際使用上可分爲 4 種,分別是:

  • 類裝飾器」 做用於 class

  • 屬性裝飾器」 做用於屬性上的,這須要配合另外一個的類屬性語法提案,或者做用於對象字面量。

  • 方法裝飾器」 做用於方法上。

  • 訪問器裝飾器」 做用於 gettersetter 上的。

下面咱們經過 babel 命令行,來感覺一下各裝飾器:

babel 配置

先簡單介紹下 babel 的用法:

  1. 全局安裝 babel
npm i -g babel
複製代碼
  1. 配置 .babelrc
{
  "presets": [["es2015", { "modules": false }]],
  "plugins": ["transform-decorators-legacy", "transform-class-properties"],
  "env": {
    "development": {
      "plugins": ["transform-es2015-modules-commonjs"]
    }
  }
}
複製代碼
  1. 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;

// 一、首先,初始化一個 class
var initClass = (_class = (function() {
  // ... 類定義
})());

// 二、經過 `_applyDecoratedDescriptor` 方法使用傳入的裝飾器對 `_class.prototype` 中的方法進行裝飾處理。
var Decorator = _applyDecoratedDescriptor(
  _class.prototype,
  'getName',
  [autobind],
  Object.getOwnPropertyDescriptor(_class.prototype, 'getName'),
  _class.prototype
);

// 三、利用逗號操做符的做用,返回裝飾完的 `_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 ) {
  // 一、經過傳入參數 `descriptor` 初始化最終導出的 `屬性描述對象`
  var desc = {};
  Object['ke' + 'ys'](descriptor).forEach(function(key) {
    desc[key] = descriptor[key];
  });
  desc.enumerable = !!desc.enumerable;
  desc.configurable = !!desc.configurable;

  // 二、存在 `value` 或者 class 初始化屬性 則將 `writable` 設置爲 `true`
  if ('value' in desc || desc.initializer) {
    desc.writable = true;
  }

  // 三、處理傳入的 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
複製代碼

這段代碼中,getPersonperson.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 實現邏輯

1、 首先來看下 如何給類的方法自動綁定 this

  1. 開始前,先來運行下面這段代碼:
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();
複製代碼

  1. 能夠獲得的一個結論:get(){} 訪問器屬性裏面的 this 始終指向 obj 這個對象。

  2. 若是簡化邏輯,也就是不考慮其餘特殊狀況下,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);
  }
}
複製代碼

2、接着來看下類的 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 的類型有助於快速理解做者意圖。

好比單看上面代碼,咱們就能夠知道 createDecoratorcreateInstanceDecorator 都接收類型爲 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 源碼中爲何有 createDecoratorcreateInstanceDecorator 兩種生成方法,以及爲何要引入 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。所謂的低侵入性,也只是視覺感官上的,不過確實多少能提升代碼的可讀性。

最後,前端路上,多用 【聞道有前後,術業有專攻】安慰本身,學習永無止境。 感謝閱讀,願君多采擷!

參考

相關文章
相關標籤/搜索