1. 程式人生 > >Decorators 低侵入性探索

Decorators 低侵入性探索

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

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

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

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

前端知識廣度無邊無際,深度深不可測,筆者記性不好,類似的倉庫有:

概覽

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

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

  • 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.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;

// 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
複製程式碼

這段程式碼中,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 實現邏輯

一、 首先來看下 如何給類的方法自動繫結 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);
  }
}
複製程式碼

二、接著來看下類的 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。所謂的低侵入性,也只是視覺感官上的,不過確實多少能提高程式碼的可讀性。

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

參考