JS 裝飾器,一篇就夠

PinesCheng發表於2018-08-01

更多文章,請在Github blog檢視

在 ES6 中增加了對類物件的相關定義和操作(比如 class 和 extends ),這就使得我們在多個不同類之間共享或者擴充套件一些方法或者行為的時候,變得並不是那麼優雅。這個時候,我們就需要一種更優雅的方法來幫助我們完成這些事情。

什麼是裝飾器

Python 的裝飾器

在物件導向(OOP)的設計模式中,decorator被稱為裝飾模式。OOP的裝飾模式需要通過繼承和組合來實現,而Python除了能支援 OOP 的 decorator 外,直接從語法層次支援 decorator。

如果你熟悉 python 的話,對它一定不會陌生。那麼我們先來看一下 python 裡的裝飾器是什麼樣子的吧:

def decorator(f):
    print "my decorator"
    return f
@decorator
def myfunc():
    print "my function"
myfunc()
# my decorator
# my function
複製程式碼

這裡的 @decorator 就是我們說的裝飾器。在上面的程式碼中,我們利用裝飾器給我們的目標方法執行前列印出了一行文字,並且並沒有對原方法做任何的修改。程式碼基本等同於:

def decorator(f):
    def wrapper():
        print "my decorator"
        return f()
    return wrapper
def myfunc():
    print "my function"
myfunc = decorator(myfuc)
複製程式碼

通過程式碼我們也不難看出,裝飾器 decorator 接收一個引數,也就是我們被裝飾的目標方法,處理完擴充套件的內容以後再返回一個方法,供以後呼叫,同時也失去了對原方法物件的訪問。當我們對某個應用了裝飾以後,其實就改變了被裝飾方法的入口引用,使其重新指向了裝飾器返回的方法的入口點,從而來實現我們對原函式的擴充套件、修改等操作。

ES7 的裝飾器

ES7 中的 decorator 同樣借鑑了這個語法糖,不過依賴於 ES5 的 Object.defineProperty 方法 。

Object.defineProperty

Object.defineProperty()方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。

該方法允許精確新增或修改物件的屬性。通過賦值來新增的普通屬性會建立在屬性列舉期間顯示的屬性(for...in 或 Object.keys 方法), 這些值可以被改變,也可以被刪除。這種方法允許這些額外的細節從預設值改變。預設情況下,使用 Object.defineProperty() 新增的屬性值是不可變的。

語法

Object.defineProperty(obj, prop, descriptor)
複製程式碼
  • obj:要在其上定義屬性的物件。
  • prop:要定義或修改的屬性的名稱。
  • descriptor:將被定義或修改的屬性描述符。
  • 返回值:被傳遞給函式的物件。

在ES6中,由於 Symbol型別 的特殊性,用 Symbol型別 的值來做物件的key與常規的定義或修改不同,而Object.defineProperty 是定義 key為 Symbol 的屬性的方法之一。

屬性描述符

物件裡目前存在的屬性描述符有兩種主要形式:資料描述符存取描述符

  • 資料描述符是一個具有值的屬性,該值可能是可寫的,也可能不是可寫的。
  • 存取描述符是由 getter-setter 函式對描述的屬性。

描述符必須是這兩種形式之一;不能同時是兩者。

資料描述符和存取描述符均具有以下可選鍵值:

configurable

當且僅當該屬性的 configurable 為 true 時,該屬性描述符才能夠被改變,同時該屬性也能從對應的物件上被刪除。預設為 false。

enumerable

enumerable定義了物件的屬性是否可以在 for...in 迴圈和 Object.keys() 中被列舉。

當且僅當該屬性的 enumerable 為 true 時,該屬性才能夠出現在物件的列舉屬性中。預設為 false。 資料描述符同時具有以下可選鍵值:

value

該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。預設為 undefined。

writable

當且僅當該屬性的 writable 為 true 時,value 才能被賦值運算子改變。預設為 false。

存取描述符同時具有以下可選鍵值:

get

一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。該方法返回值被用作屬性值。預設為 undefined。

set

一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。該方法將接受唯一引數,並將該引數的新值分配給該屬性。預設為 undefined。

如果一個描述符不具有value,writable,get 和 set 任意一個關鍵字,那麼它將被認為是一個資料描述符。如果一個描述符同時有(value或writable)和(get或set)關鍵字,將會產生一個異常。

用法

類的裝飾

@testable
class MyTestableClass {
  // ...
}

function testable(target) {
  target.isTestable = true;
}

MyTestableClass.isTestable // true
複製程式碼

上面程式碼中,@testable 就是一個裝飾器。它修改了 MyTestableClass這 個類的行為,為它加上了靜態屬性isTestable。testable 函式的引數 target 是 MyTestableClass 類本身。

基本上,裝飾器的行為就是下面這樣。

@decorator
class A {}

// 等同於

class A {}
A = decorator(A) || A;
複製程式碼

也就是說,裝飾器是一個對類進行處理的函式。裝飾器函式的第一個引數,就是所要裝飾的目標類

如果覺得一個引數不夠用,可以在裝飾器外面再封裝一層函式。

function testable(isTestable) {
  return function(target) {
    target.isTestable = isTestable;
  }
}

@testable(true)
class MyTestableClass {}
MyTestableClass.isTestable // true

@testable(false)
class MyClass {}
MyClass.isTestable // false
複製程式碼

上面程式碼中,裝飾器 testable 可以接受引數,這就等於可以修改裝飾器的行為。

注意,裝飾器對類的行為的改變,是程式碼編譯時發生的,而不是在執行時。這意味著,裝飾器能在編譯階段執行程式碼。也就是說,裝飾器本質就是編譯時執行的函式

前面的例子是為類新增一個靜態屬性,如果想新增例項屬性,可以通過目標類的 prototype 物件操作。

下面是另外一個例子。

// mixins.js
export function mixins(...list) {
  return function (target) {
    Object.assign(target.prototype, ...list)
  }
}

// main.js
import { mixins } from './mixins'

const Foo = {
  foo() { console.log('foo') }
};

@mixins(Foo)
class MyClass {}

let obj = new MyClass();
obj.foo() // 'foo'
複製程式碼

上面程式碼通過裝飾器 mixins,把Foo物件的方法新增到了 MyClass 的例項上面。

方法的裝飾

裝飾器不僅可以裝飾類,還可以裝飾類的屬性。

class Person {
  @readonly
  name() { return `${this.first} ${this.last}` }
}
複製程式碼

上面程式碼中,裝飾器 readonly 用來裝飾“類”的name方法。

裝飾器函式 readonly 一共可以接受三個引數。

function readonly(target, name, descriptor){
  // descriptor物件原來的值如下
  // {
  //   value: specifiedFunction,
  //   enumerable: false,
  //   configurable: true,
  //   writable: true
  // };
  descriptor.writable = false;
  return descriptor;
}

readonly(Person.prototype, 'name', descriptor);
// 類似於
Object.defineProperty(Person.prototype, 'name', descriptor);
複製程式碼
  • 裝飾器第一個引數是 類的原型物件,上例是 Person.prototype,裝飾器的本意是要“裝飾”類的例項,但是這個時候例項還沒生成,所以只能去裝飾原型(這不同於類的裝飾,那種情況時target引數指的是類本身);
  • 第二個引數是 所要裝飾的屬性名
  • 第三個引數是 該屬性的描述物件

另外,上面程式碼說明,裝飾器(readonly)會修改屬性的 描述物件(descriptor),然後被修改的描述物件再用來定義屬性。

函式方法的裝飾

裝飾器只能用於類和類的方法,不能用於函式,因為存在函式提升

另一方面,如果一定要裝飾函式,可以採用高階函式的形式直接執行。

function doSomething(name) {
  console.log('Hello, ' + name);
}

function loggingDecorator(wrapped) {
  return function() {
    console.log('Starting');
    const result = wrapped.apply(this, arguments);
    console.log('Finished');
    return result;
  }
}

const wrapped = loggingDecorator(doSomething);
複製程式碼

core-decorators.js

core-decorators.js是一個第三方模組,提供了幾個常見的裝飾器,通過它可以更好地理解裝飾器。

@autobind

autobind 裝飾器使得方法中的this物件,繫結原始物件。

@readonly

readonly 裝飾器使得屬性或方法不可寫。

@override

override 裝飾器檢查子類的方法,是否正確覆蓋了父類的同名方法,如果不正確會報錯。

import { override } from 'core-decorators';

class Parent {
  speak(first, second) {}
}

class Child extends Parent {
  @override
  speak() {}
  // SyntaxError: Child#speak() does not properly override Parent#speak(first, second)
}

// or

class Child extends Parent {
  @override
  speaks() {}
  // SyntaxError: No descriptor matching Child#speaks() was found on the prototype chain.
  //
  //   Did you mean "speak"?
}
複製程式碼

@deprecate (別名@deprecated)

deprecate 或 deprecated 裝飾器在控制檯顯示一條警告,表示該方法將廢除。

import { deprecate } from 'core-decorators';

class Person {
  @deprecate
  facepalm() {}

  @deprecate('We stopped facepalming')
  facepalmHard() {}

  @deprecate('We stopped facepalming', { url: 'http://knowyourmeme.com/memes/facepalm' })
  facepalmHarder() {}
}

let person = new Person();

person.facepalm();
// DEPRECATION Person#facepalm: This function will be removed in future versions.

person.facepalmHard();
// DEPRECATION Person#facepalmHard: We stopped facepalming

person.facepalmHarder();
// DEPRECATION Person#facepalmHarder: We stopped facepalming
//
//     See http://knowyourmeme.com/memes/facepalm for more details.
//
複製程式碼

@suppressWarnings

suppressWarnings 裝飾器抑制 deprecated 裝飾器導致的 console.warn() 呼叫。但是,非同步程式碼發出的呼叫除外。

使用場景

裝飾器有註釋的作用

@testable
class Person {
  @readonly
  @nonenumerable
  name() { return `${this.first} ${this.last}` }
}
複製程式碼

從上面程式碼中,我們一眼就能看出,Person類是可測試的,而name方法是隻讀和不可列舉的。

React 的 connect

實際開發中,React 與 Redux 庫結合使用時,常常需要寫成下面這樣。

class MyReactComponent extends React.Component {}

export default connect(mapStateToProps, mapDispatchToProps)(MyReactComponent);
複製程式碼

有了裝飾器,就可以改寫上面的程式碼。裝飾

@connect(mapStateToProps, mapDispatchToProps)
export default class MyReactComponent extends React.Component {}
複製程式碼

相對來說,後一種寫法看上去更容易理解。

新功能提醒或許可權

選單點選時,進行事件攔截,若該選單有新功能更新,則彈窗顯示。

/**
 * @description 在點選時,如果有新功能提醒,則彈窗顯示
 * @param code 新功能的code
 * @returns {function(*, *, *)}
 */
 const checkRecommandFunc = (code) => (target, property, descriptor) => {
    let desF = descriptor.value; 
    descriptor.value = function (...args) {
      let recommandFuncModalData = SYSTEM.recommandFuncCodeMap[code];

      if (recommandFuncModalData && recommandFuncModalData.id) {
        setTimeout(() => {
          this.props.dispatch({type: 'global/setRecommandFuncModalData', recommandFuncModalData});
        }, 1000);
      }
      desF.apply(this, args);
    };
    return descriptor;
  };

複製程式碼

loading

在 React 專案中,我們可能需要在向後臺請求資料時,頁面出現 loading 動畫。這個時候,你就可以使用裝飾器,優雅地實現功能。

@autobind
@loadingWrap(true)
async handleSelect(params) {
  await this.props.dispatch({
    type: 'product_list/setQuerypParams',
    querypParams: params
  });
}
複製程式碼

loadingWrap 函式如下:

export function loadingWrap(needHide) {

  const defaultLoading = (
    <div className="toast-loading">
      <Loading className="loading-icon"/>
      <div>載入中...</div>
    </div>
  );

  return function (target, property, descriptor) {
    const raw = descriptor.value;
    
    descriptor.value = function (...args) {
      Toast.info(text || defaultLoading, 0, null, true);
      const res = raw.apply(this, args);
      
      if (needHide) {
        if (get('finally')(res)) {
          res.finally(() => {
            Toast.hide();
          });
        } else {
          Toast.hide();
        }
      }
    };
    return descriptor;
  };
}
複製程式碼

問題:這裡大家可以想想看,如果我們不希望每次請求資料時都出現 loading,而是要求只要後臺請求時間大於 300ms 時,才顯示loading,這裡需要怎麼改?

參考

相關文章