1. 程式人生 > >從ES6重新認識JavaScript設計模式: 裝飾器模式

從ES6重新認識JavaScript設計模式: 裝飾器模式

1 什麼是裝飾器模式

向一個現有的物件新增新的功能,同時又不改變其結構的設計模式被稱為裝飾器模式(Decorator Pattern),它是作為現有的類的一個包裝(Wrapper)。

可以將裝飾器理解為遊戲人物購買的裝備,例如LOL中的英雄剛開始遊戲時只有基礎的攻擊力和法強。但是在購買的裝備後,在觸發攻擊和技能時,能夠享受到裝備帶來的輸出加成。我們可以理解為購買的裝備給英雄的攻擊和技能的相關方法進行了裝飾。

這裡推薦一篇淘寶前端團隊的博文,很有趣的以鋼鐵俠的例子來講解了裝飾者模式。

2 ESnext中的裝飾器模式

ESnext中有一個Decorator

提案,使用一個以 @ 開頭的函式對ES6中的class及其屬性、方法進行修飾。Decorator的詳細語法請參考阮一峰的《ECMASciprt入門 —— Decorator》

目前Decorator的語法還只是一個提案,如果期望現在使用裝飾器模式,需要安裝配合babel + webpack並結合外掛實現。

  • npm安裝依賴

npm install babel-core babel-loader babel-plugin-transform-decorators babel-plugin-transform-decorators-legacy babel-preset-env
  • 配置.babelrc檔案
{
  "presets": ["env"],
  "plugins": ["transform-decorators-legacy"]
}
```
  • webpack.config.js中新增babel-loader

  module: {
    rules: [
      { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }
    ],
  }

如果你使用的IDE為Visual Studio Code,可能還需要在專案根目錄下新增以下tsconfig.json

檔案來組織一個ts檢查的報錯。

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "allowJs": true,
    "lib": [
      "es6"
    ],
  }
}
```

下面我將實現3個裝飾器,分別為@autobind@debounce@deprecate

2.1 @autobind實現this指向原物件

在JavaScript中,this的指向問題一直是一個老生常談的話題,在Vue或React這類框架的使用過程中,新手很有可能一不小心就丟失了this的指向導致方法呼叫錯誤。例如下面一段程式碼:


class Person {
  getPerson() {
    return this;
  }
}

let person = new Person();
let { getPerson } = person;

console.log(getPerson() === person); // false

上面的程式碼中, getPerson方法中的this預設指向Person類的例項,但是如果將Person通過解構賦值的方式提取出來,那麼此時的this指向為undefined。所以最終的列印結果為false

此時我們可以實現一個autobind的函式,用來裝飾getPerson這個方法,實現this永遠指向Person的例項。


function autobind(target, key, descriptor) {
  var fn = descriptor.value;
  var configurable = descriptor.configurable;
  var enumerable = descriptor.enumerable;

  // 返回descriptor
  return {
    configurable: configurable,
    enumerable: enumerable,
    get: function get() {
      // 將該方法繫結this
      var boundFn = fn.bind(this);
      // 使用Object.defineProperty重新定義該方法
      Object.defineProperty(this, key, {
        configurable: true,
        writable: true,
        enumerable: false,
        value: boundFn
      })

      return boundFn;
    }
  }
}

我們通過bind實現了this的繫結,並在get中利用Object.defineProperty重寫了該方法,將value定義為通過bind繫結後的函式boundFn,以此實現了this永遠指向例項。下面我們為getPerson方法加上裝飾並呼叫。


class Person {
  @autobind
  getPerson() {
    return this;
  }
}

let person = new Person();
let { getPerson } = person;

console.log(getPerson() === person); // true

2.2 @debounce實現函式防抖

函式防抖(debounce)在前端專案中有著很多的應用,例如在resizescroll等事件中操作DOM,或對使用者輸入實現實時ajax搜尋等會被高頻的觸發,前者會對瀏覽器效能產生直觀的影響,後者會對伺服器產生較大的壓力,我們期望這類高頻連續觸發的事件在觸發結束後再做出響應,這就是函式防抖的應用。


class Editor {
  constructor() {
    this.content = '';
  }

  updateContent(content) {
    console.log(content);
    this.content = content;
    // 後面有一些消耗效能的操作
  }
}

const editor1 = new Editor();
editor1.updateContent(1);
setTimeout(() => { editor1.updateContent(2); }, 400);


const editor2= new Editor();
editor2.updateContent(3);
setTimeout(() => { editor2.updateContent(4); }, 600);

// 列印結果: 1 3 2 4

上面的程式碼中我們定義了Editor這個類,其中updateContent方法會在使用者輸入時執行並可能有一些消耗效能的DOM操作,這裡我們在該方法內部列印了傳入的引數以驗證呼叫過程。可以看到4次的呼叫結果分別為1 3 2 4

下面我們實現一個debounce函式,該方法傳入一個數字型別的timeout引數。


function debounce(timeout) {
  const instanceMap = new Map(); // 建立一個Map的資料結構,將例項化物件作為key

  return function (target, key, descriptor) {

    return Object.assign({}, descriptor, {
      value: function value() {

        // 清除延時器
        clearTimeout(instanceMap.get(this));
        // 設定延時器
        instanceMap.set(this, setTimeout(() => {
          // 呼叫該方法
          descriptor.value.apply(this, arguments);
          // 將延時器設定為 null
          instanceMap.set(this, null);
        }, timeout));
      }
    })
  }
}

上面的方法中,我們採用了ES6提供的Map資料結構去實現例項化物件和延時器的對映。在函式的內部,首先清除延時器,接著設定延時執行函式,這是實現debounce的通用方法,下面我們來測試一下debounce裝飾器。


class Editor {
  constructor() {
    this.content = '';
  }

  @debounce(500)  
  updateContent(content) {
    console.log(content);
    this.content = content;
  }
}

const editor1 = new Editor();
editor1.updateContent(1);
setTimeout(() => { editor1.updateContent(2); }, 400);


const editor2= new Editor();
editor2.updateContent(3);
setTimeout(() => { editor2.updateContent(4); }, 600);

//列印結果: 3 2 4

上面呼叫了4次updateContent方法,列印結果為3 2 41由於在400ms內被重複呼叫而沒有被列印,這符合我們的引數為500的預期。

2.3 @deprecate實現警告提示

在使用第三方庫的過程中,我們會時不時的在控制檯遇見一些警告,這些警告用來提醒開發者所呼叫的方法會在下個版本中被棄用。這樣的一行列印資訊也許我們的常規做法是在方法內部新增一行程式碼即可,這樣其實在原始碼閱讀上並不友好,也不符合單一職責原則。如果在需要丟擲警告的方法前面加一個@deprecate的裝飾器來實現警告,會友好得多。

下面我們來實現一個@deprecate的裝飾器,其實這類的裝飾器也可以擴充套件成為列印日誌裝飾器@log,上報資訊裝飾器@fetchInfo等。


function deprecate(deprecatedObj) {

  return function(target, key, descriptor) {
    const deprecatedInfo = deprecatedObj.info;
    const deprecatedUrl = deprecatedObj.url;
    // 警告資訊
    const txt = `DEPRECATION ${target.constructor.name}#${key}: ${deprecatedInfo}. ${deprecatedUrl ? 'See '+ deprecatedUrl + ' for more detail' : ''}`;
    
    return Object.assign({}, descriptor, {
      value: function value() {
        // 列印警告資訊
        console.warn(txt);
        descriptor.value.apply(this, arguments);
      }
    })
  }
}

上面的deprecate函式接受一個物件引數,該引數分別有infourl兩個鍵值,其中info填入警告資訊,url為選填的詳情網頁地址。下面我們來為一個名為MyLib的庫的deprecatedMethod方法新增該裝飾器吧!


class MyLib {
  @deprecate({
    info: 'The methods will be deprecated in next version', 
    url: 'http://www.baidu.com'
  })
  deprecatedMethod(txt) {
    console.log(txt)
  }
}

const lib = new MyLib();
lib.deprecatedMethod('呼叫了一個要在下個版本被移除的方法');
// DEPRECATION MyLib#deprecatedMethod: The methods will be deprecated in next version. See http://www.baidu.com for more detail
// 呼叫了一個要在下個版本被移除的方法

3 總結

通過ESnext中的裝飾器實現裝飾器模式,不僅有為類擴充功能的作用,而且在閱讀原始碼的過程中起到了提示作用。上面所舉到的例子只是結合裝飾器的新語法和裝飾器模式做了一個簡單封裝,請勿用於生產環境。如果你現在已經體會到了裝飾器模式的好處,並想在專案中大量使用,不妨看一下core-decorators這個庫,其中封裝了很多常用的裝飾器.

參考文獻

  1. IMWeb的前端部落格:淺談JS中的裝飾器模式
  2. 淘寶前端團隊:ES7 Decorator 裝飾者模式
  3. 阮一峰:ECMAScript 6 入門

來源:https://segmentfault.com/a/1190000015970099