理解javascript裝飾器

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

裝飾器現在還不是javascript的核心特性,他們正通過ECMATC39的標準化流程進行工作。不過並不代表我們不能去熟悉它。
在不久的將來,它將得到瀏覽器和node的原生支持,與此同時,babel也得到支持。

什麼是裝飾器

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

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

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

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

sayHello()

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

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

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

sayHello = cashify(sayHello)

無論我們裝飾的函數打印什麼,最後的結果都會在他們前後打印$符號。

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

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

好了,那麼js裝飾器有什麼不同呢?

JS 裝飾器和屬性描述符

python把傳入的需要裝飾的任何函數當做參數,但因爲對象在js中的特殊工作方式,js裝飾器可以獲取到更多信息。

對象在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特性一樣,是你工具箱中很有用的工具,他很大程度的簡化了不同和不相關的類的行爲共享。然而過早的採用總需要一些成本。所以使用裝飾器,也需要了解它對你代碼庫的影響。

原文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章