理解javascript裝飾器

不久前,我開發了一個react應用,使用mobx作狀態管理。這是一個時而興奮時而困惑,但整體而言很享受的經歷,很快我將會把它寫出來。在使用mobx開發時,我發現了一個很是有趣的獨特之處,那就是它使用裝飾器來註釋類的屬性。我以前在寫javascript時還沒用過它,但自從我使用了mobx提供的這個功能以及作了一些開發後,我發現這是一個有巨大潛力的功能。javascript

裝飾器如今還不是javascript的核心特性,他們正經過ECMATC39的標準化流程進行工做。不過並不表明咱們不能去熟悉它。
在不久的未來,它將獲得瀏覽器和node的原生支持,與此同時,babel也獲得支持。html

什麼是裝飾器

Decoratordecorator function/methored的縮寫。它是一個函數,它會經過返回一個新函數來修改傳入的函數或方法的行爲。java

你能夠在函數式編程的任何語言中實現裝飾器,好比javascript,你能夠把函數綁定到一個變量上,也能夠把函數當成函數的參數傳遞。這些語言中的幾種有特殊的語法糖,用來定義和使用裝飾器,其中一個就是pythonnode

def cashify(fn):
    def wrap():
        print("$$$$")
        fn()
        print("$$$$")
    return wrap

@cashify
def sayHello():
    print("hello!")

sayHello()

# $$$$
# hello!
# $$$$

讓咱們看看發生了什麼,cashify函數是一個裝飾器,他接受一個函數做爲參數,它的返回值也是函數。咱們使用pythonpie syntax把裝飾器應用到sayHello函數上,本質上和咱們在sayHello的定義下執行此操做是同樣的:python

def sayHello():
    print("hello!")

sayHello = cashify(sayHello)

不管咱們裝飾的函數打印什麼,最後的結果都會在他們先後打印$符號。react

爲何我要使用python的例子來介紹ECMAScript的裝飾器,很高興你問這個問題!git

  • python是一個很好地方式去解釋基礎知識,由於它的裝飾器的概念比它在JS中的工做方式更簡單直接
  • jsTS都是用pythonpie syntax把裝飾器應用到類的函數和屬性上,因此它們外觀和語法格式都很類似

好了,那麼js裝飾器有什麼不一樣呢?es6

JS 裝飾器和屬性描述符

python把傳入的須要裝飾的任何函數當作參數,但由於對象在js中的特殊工做方式,js裝飾器能夠獲取到更多信息。github

對象在js中有屬性,而且這些屬性有如下值:編程

const oatmeal = {
  viscosity: 20,
  flavor: 'Brown Sugar Cinnamon',
};

但除了它的值,每一個屬性還有一些其餘隱藏的信息,用於定義它工做方式的不一樣方面,叫作屬性描述符:

console.log(Object.getOwnPropertyDescriptor(oatmeal, 'viscosity'));

/*
{
  configurable: true,
  enumerable: true,
  value: 20,
  writable: true
}
*/

JS在追蹤與這個屬性有關的不少東西:

  • configurable 決定該屬性的類型可否被修改,以及它可否從對象中刪除
  • enumerable 控制當你在枚舉對象屬性時,該屬性是否顯示(好比當你調用Object.keys(oatmeal)或者使用for循環時)
  • writable 控制你是否能夠經過賦值操做符=修改該屬性的值
  • value 是你訪問這個屬性時,所看到的靜態值。一般,這是你常常看到和關心的屬性描述符的惟一部分。它能夠是任何JS值,包括一個函數,這會使這個屬性成爲其所屬對象的方法。

屬性描述符也有兩個其餘的屬性,爲訪問器描述符(一般稱爲gettersetter):

  • get 是一個返回屬性值而不是用靜態value屬性的的函數
  • set 是一個特殊的函數,當你給這個屬性賦值時,該函數會將你在等號右邊放置的任何內容做爲參數

沒有多餘的裝飾

jses5就已經有了操做屬性描述符的API,經過Object.getOwnPropertyDescriptorObject.defineProperty的形式。好比我喜歡個人燕麥片的濃度,我可使用這個API像下邊這樣把它變成只讀的:

Object.defineProperty(oatmeal, 'viscosity', {
  writable: false,
  value: 20,
});

// 當我試圖設置oatmeal.viscosity爲不一樣的值時,它將會默默地報錯
oatmeal.viscosity = 30;
console.log(oatmeal.viscosity);
// => 20

我甚至能夠寫一個通用的decorate函數,能夠修改任何對象的任何屬性的修飾符

function decorate(obj, property, callback) {
  var descriptor = Object.getOwnPropertyDescriptor(obj, property);
  Object.defineProperty(obj, property, callback(descriptor));
}

decorate(oatmeal, 'viscosity', function(desc) {
  desc.configurable = false;
  desc.writable = false;
  desc.value = 20;
  return desc;
});

Adding the Shiplap and Crown Molding(巴拉巴拉...)

第一個主要的裝飾器的提案只與ES的類有關,而非普通對象。讓咱們設計一些類來表明咱們的粥:

class Porridge {
  constructor(viscosity = 10) {
    this.viscosity = viscosity;
  }

  stir() {
    if (this.viscosity > 15) {
      console.log('This is pretty thick stuff.');
    } else {
      console.log('Spoon goes round and round.');
    }
  }
}

class Oatmeal extends Porridge {
  viscosity = 20;

  constructor(flavor) {
    super();
    this.flavor = flavor;
  }
}

咱們使用一個類來表明咱們的燕麥粥,他繼承自一個更通用的的 Porridge 類。Oatmeal設置了默認的濃度來覆蓋Porridge的默認值,而且添加了新的口味屬性。咱們也使用了另外一個es提案 class fields去覆蓋濃度屬性。
咱們能夠從新建立咱們原始的燕麥粥了:

const oatmeal = new Oatmeal('Brown Sugar Cinnamon');

/*
Oatmeal {
  flavor: 'Brown Sugar Cinnamon',
  viscosity: 20
}
*/

很好,咱們獲得了咱們的es6燕麥粥,咱們要準備寫裝飾器了!

如何去寫一個裝飾器

js裝飾器函數被傳入三個參數:

  • target 是咱們對象所繼承的類
  • key 是咱們應用裝飾器的屬性的名稱,爲字符串。
  • descriptor 是屬性描述符對象

咱們在裝飾器內作什麼依賴於咱們裝飾器的目的。爲了裝飾對象的方法和屬性,咱們須要返回一個新的屬性描述器。咱們能夠經過如下方式寫一個裝飾器來使一個屬性爲只讀:

function readOnly(target, key, descriptor) {
  return {
    ...descriptor,
    writable: false,
  };
}

咱們能夠像這樣修改咱們的oatmeal類:

class Oatmeal extends Porridge {
  @readOnly viscosity = 20;
  // 你也能夠吧@readonly放在屬性上一行

  constructor(flavor) {
    super();
    this.flavor = flavor;
  }
}

如今咱們燕麥粥像膠水同樣的濃度不會被幹預了,謝天謝地。
若是咱們想作一些真正有用的東西呢?我在最近的項目時遇到了一種狀況,其中裝飾器節省了我不少開發和維護的開銷。

處理API錯誤

在我開頭提到的Mobx/React app中,我有一些不一樣的類做爲數據中心。他們各自都表明與用戶交互的不一樣類別的集合,而且與不一樣的API端點對話以獲取服務端的數據。爲了處理API錯誤,我使每一個數據中心在與網絡通訊時都準守一個協議:

  1. 設置ui中心的networkStatus屬性爲loading
  2. 發送api請求
  3. 處理結果

    • 若是成功,使用結果更新本地狀態
    • 若是報錯了,設置ui中心的apiError屬性爲接收到的錯誤
  4. 設置ui中心的networkStatus屬性爲idle

我發如今我注意到以前,已經重複了不少次這種模式:

class WidgetStore {
  async getWidget(id) {
    this.setNetworkStatus('loading');

    try {
      const { widget } = await api.getWidget(id);
      // Do something with the response to update local state:
      this.addWidget(widget);
    } catch (err) {
      this.setApiError(err);
    } finally {
      this.setNetworkStatus('idle');
    }
  }
}

這是不少錯誤處理的樣板。由於我已經在全部更新可觀察屬性的方法上使用了MobX@action裝飾器了(爲了簡單起見,此處未顯示),因此也能夠再添加一個裝飾器用來節省我錯誤處理的代碼。我想出了這個:

function apiRequest(target, key, descriptor) {
  const apiAction = async function(...args) {
    // More about this line shortly:
    const original = descriptor.value || descriptor.initializer.call(this);
    
    this.setNetworkStatus('loading');

    try {
      const result = await original(...args);
      return result;
    } catch (e) {
      this.setApiError(e);
    } finally {
      this.setNetworkStatus('idle');
    }
  };

  return {
    ...descriptor,
    value: apiAction,
    initializer: undefined,
  };
}

而後我就能夠像這樣替換那些寫在每一個API操做方法上的模板:

class WidgetStore {
  @apiRequest
  async getWidget(id) {
    const { widget } = await api.getWidget(id);
    this.addWidget(widget);
    return widget;
  }
}

個人錯誤處理代碼依然在那,可是我只須要寫一次,而且確保每一個使用它的class都有setNetworkStatussetApiError方法便可。

babel解決方案

我選擇descriptor.value和調用descriptor.initializer其中之一的那一行發生了什麼?這是與babel相關的事。個人預感是,這種方式在js原生支持裝飾器的時候不會起做用,但當考慮到babel處理做爲類屬性的箭頭函數的方式時,就會頗有必要。

當你定義一個類屬性,而且給它賦值一個箭頭函數時,babel會巧妙地把函數綁定到類正確的實例上而且提供你正確的this值。經過設置descriptor.initializer爲一個函數,它會返回你寫的那個函數,而且在其做用域內爲正確的this值。

一個例子會讓事情變簡單:

class Example {
  @myDecorator
  someMethod() {
    // 在這個例子中,咱們的方法能夠由descriptor.value引用到
  }

  @myDecorator
  boundMethod = () => {
    // 在這裏,descriptor.initializer是一個函數,他會返回咱們的boundMethod函數,而且this執行已經被調整爲Example的實例
  };
}

裝飾類

除了屬性和方法,你還能夠裝飾整個類。想要裝飾類,你只須要傳入裝飾器函數的第一個參數target。好比,我想寫一個自動把類註冊爲自定義html標籤的裝飾器,我在這裏使用了一個閉包,來保證裝飾器可以接收咱們想要爲標籤提供參數的任何名稱:

function customElement(name) {
  return function(target) {
    // customElements是一個全局API,用來建立自定義標籤
    customElements.define(name, target);
  };
}

咱們將這樣使用它:

@customElement('intro-message');
class IntroMessage extends HTMLElement {
  constructor() {
    super();

    const shadow = this.attachShadow({ mode: 'open' });
    this.wrapper = this.createElement('div', 'intro-message');
    this.header = this.createElement('h1', 'intro-message__title');
    this.content = this.createElement('div', 'intro-message__text');
    this.header.textContent = this.getAttribute('header');
    this.content.innerHTML = this.innerHTML;

    shadow.appendChild(this.wrapper);
    this.wrapper.appendChild(this.header);
    this.wrapper.appendChild(this.content);
  }

  createElement(tag, className) {
    const elem = document.createElement(tag);
    elem.classList.add(className);
    return elem;
  }
}

把它加入到咱們的html中,能夠這樣使用它:

<intro-message header="Welcome to Decorators">
  <p>Something something content...</p>
</intro-message>

瀏覽器中顯示以下:

總結

現在在你的項目中使用裝飾器須要一些轉譯配置。我所見的最直接的教程就在MobX的文檔中,它有TS和兩個主要版本的babel信息。

請記住裝飾器當前仍是發展中的提議,若是你在生產代碼中使用它,你可能須要作一些更新或者持續使用babel裝飾器插件,直到它成爲ECMA官方的正式規範。甚至babel也沒有很好地支持,最新版的裝飾器提案包含很大的改動,並無很好地向後兼容上一個版本。

裝飾器像不少最新的js特性同樣,是你工具箱中頗有用的工具,他很大程度的簡化了不一樣和不相關的類的行爲共享。然而過早的採用總須要一些成本。因此使用裝飾器,也須要了解它對你代碼庫的影響。

相關文章
相關標籤/搜索