都2020年了,你還不會 JavaScript 裝飾器?

俗話說,人靠衣裝,佛靠金裝。大街上的小姐姐都喜歡把本身打扮得美美的,讓你忍不住多看幾眼,這就是裝飾的做用。

image_1e41h0m6hs19r6dcqvam1cog1t.png-414.6kB

1. 前言

裝飾器是最新的 ECMA 中的一個提案,是一種與類(class)相關的語法,用來註釋或修改類和類方法。裝飾器在 Python 和 Java 等語言中也被大量使用。裝飾器是實現 AOP(面向切面)編程的一種重要方式。html

code.png-98.4kB

下面是一個使用裝飾器的簡單例子,這個 @readonly 能夠將 count 屬性設置爲只讀。能夠看出來,裝飾器大大提升了代碼的簡潔性和可讀性。前端

class Person {
    @readonly count = 0;
}

因爲瀏覽器還未支持裝飾器,爲了讓你們可以正常看到效果,這裏我使用 Parcel 進行了一下簡單的配置,能夠去 clone 這個倉庫後再來運行本文涉及到的全部例子,倉庫地址:learn es6react

本文涉及到 Object.defineProperty、高階函數等知識,若是以前沒有了解過相關概念,建議先了解後再來閱讀本文。

2. 裝飾器模式

在開始講解裝飾器以前,先從經典的裝飾器模式提及。裝飾器模式是一種結構型設計模式,它容許向一個現有的對象添加新的功能,同時又不改變其結構,是做爲對現有類的一個包裝。git

通常來講,在代碼設計中,咱們應當遵循「多用組合,少用繼承」的原則。經過裝飾器模式動態地給一個對象添加一些額外的職責。就增長功能來講,裝飾器模式相比生成子類更爲靈活。es6

2.1 一個英雄聯盟的例子

下班回去和朋友愉快地開黑,當我正在用亞索「面對疾風吧」的時候,忽然想到,若是讓我設計亞索英雄,我該怎麼實現呢?github

image_1e41h3e4e62nnd0hv0f02q5136.png-242.1kB

我靈光一閃,那確定會先設計一個英雄的類。面試

class Hero {
    attack() {}
}

而後,再實現一個 Yasuo 的類來繼承這個 Hero 類。編程

class Yasuo extends Hero {
    attack() {
        console.log("斬鋼閃");
    }
}

我還在想這個問題的時候,隊友已經打了大龍,個人亞索身上就出現了大龍 buff 的印記。我忽然想到,那該怎麼給英雄增長大龍 buff 呢?那增長個大龍 buff 的屬性不行嗎?redux

固然不太行,要知道,英雄聯盟裏面的大龍 buff 是會增長收益的。設計模式

嗯,聰明的我已經想到辦法了,再繼承一次不就行了嗎?

class BaronYasuo extends Yasuo {}

厲害了,可是若是亞索身上還有其餘 buff 呢?畢竟 LOL 裏面是有紅 buff、藍 buff、大龍 buff 等等存在,那豈不是有多少種就要增長多少個類嗎?

image_1e3brvbln129jcn7bo111jfal09.png-37.6kB

能夠換種思路來思考這個問題,若是把 buff 當作咱們身上的衣服。在不一樣的季節,咱們會換上不一樣的衣服,到了冬天,甚至還會疊加多件衣服。當 buff 消失了,就至關於把這件衣服脫了下來。以下圖所示:

image.png-27.3kB

衣服對人來講起到裝飾的做用,buff 對於亞索來講也只是加強效果。那麼,你是否是有思路了呢?
沒錯,能夠建立 Buff 類,傳入英雄類後得到一個新的加強後的英雄類。

class RedBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 紅buff形成額外傷害
    extraDamage() {
    }
    attack() {
        return this.hero.attack() + this.extraDamage();
    }
}
class BlueBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 技能CD(減10%)
    CDR() {
        return this.hero.CDR() * 0.9;
    }
}
class BaronBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 回城速度縮短一半
    backSpeed() {
        return this.hero.backSpeed * 0.5;
    }
}

定義好全部的 buff 類以後,就能夠直接套用到英雄身上,這樣看起來是否是清爽了不少呢?這種寫法看起來很像函數組合。

const yasuo = new Yasuo();
const redYasuo = new RedBuff(yasuo); // 紅buff亞索
const blueYasuo = new BlueBuff(yasuo); // 藍buff亞索
const redBlueYasuo = new BlueBuff(redYasuo); // 紅藍buff亞索

image_1e41h45cn12r220all5mos1pet3j.png-324.3kB

3. ES7 裝飾器

decorator(裝飾器)是 ES7 中的一個提案,目前處於 stage-2 階段,提案地址:JavaScript Decorators

裝飾器與以前講過的函數組合(compose)以及高階函數很類似。裝飾器使用 @ 做爲標識符,被放置在被裝飾代碼前面。在其餘語言中,早就已經有了比較成熟的裝飾器方案。

3.1 Python 中的裝飾器

先來看一下 Python 中的一個裝飾器的例子:

def auth(func):
    def inner(request,*args,**kwargs):
        v = request.COOKIES.get('user')
        if not v:
            return redirect('/login')
        return func(request, *args,**kwargs)
    return inner

@auth
def index(request):
    v = request.COOKIES.get("user")
    return render(request,"index.html",{"current_user":v})

image_1e3c0hva03om1v6im6gjne5lj9.png-32.2kB

這個 auth 裝飾器是經過檢查 cookie 來判斷用戶是否登陸的。auth 函數是一個高階函數,它接收了一個 func 函數做爲參數,返回了一個新的 inner 函數。

inner 函數中進行 cookie 的檢查,由此來判斷是跳回登陸頁面仍是繼續執行 func 函數。

在全部須要權限驗證的函數上,均可以使用這個 auth 裝飾器,很簡潔明瞭且無侵入。

3.2 JavaScript 裝飾器

JavaScript 中的裝飾器和 Python 的裝飾器相似,依賴於 Object.defineProperty,通常是用來裝飾類、類屬性、類方法。

使用裝飾器能夠作到不直接修改代碼,就實現某些功能,作到真正的面向切面編程。這在必定程度上和 Proxy 很類似,但使用起來比 Proxy 會更加簡潔。

注意:裝飾器目前還處於 stage-2,意味着語法以後也許會有變更。裝飾器用於函數、對象等等已經有一些規劃,請看: Future built-in decorators

3.3 類裝飾器

裝飾類的時候,裝飾器方法通常會接收一個目標類做爲參數。下面是一個給目標類增長靜態屬性 test 的例子:

const decoratorClass = (targetClass) => {
    targetClass.test = '123'
}
@decoratorClass
class Test {}
Test.test; // '123'

除了能夠修改類自己,還能夠經過修改原型,給實例增長新屬性。下面是給目標類增長 speak 方法的例子:

const withSpeak = (targetClass) => {
    const prototype = targetClass.prototype;
    prototype.speak = function() {
        console.log('I can speak ', this.language);
    }
}
@withSpeak
class Student {
    constructor(language) {
        this.language = language;
    }
}
const student1 = new Student('Chinese');
const student2 = new Student('English');
student1.speak(); // I can speak  Chinese

student2.speak(); // I can speak  Englist

利用高階函數的屬性,還能夠給裝飾器傳參,經過參數來判斷對類進行什麼處理。

const withLanguage = (language) => (targetClass) => {
    targetClass.prototype.language = language;
}
@withLanguage('Chinese')
class Student {
}
const student = new Student();
student.language; // 'Chinese'

若是你常常編寫 react-redux 的代碼,那麼也會遇到須要將 store 中的數據映射到組件中的狀況。connect 是一個高階組件,它接收了兩個函數 mapStateToPropsmapDispatchToProps 以及一個組件 App,最終返回了一個加強版的組件。

class App extends React.Component {
}
connect(mapStateToProps, mapDispatchToProps)(App)

有了裝飾器以後,connect 的寫法能夠變得更加優雅。

@connect(mapStateToProps, mapDispatchToProps)
class App extends React.Component {
}

3.4 類屬性裝飾器

類屬性裝飾器能夠用在類的屬性、方法、get/set 函數中,通常會接收三個參數:

  1. target:被修飾的類
  2. name:類成員的名字
  3. descriptor:屬性描述符,對象會將這個參數傳給 Object.defineProperty

使用類屬性裝飾器能夠作到不少有意思的事情,好比最開始舉的那個 readonly 的例子:

function readonly(target, name, descriptor) {
  descriptor.writable = false;
  return descriptor;
}
class Person {
    @readonly name = 'person'
}
const person = new Person();
person.name = 'tom';

還能夠用來統計一個函數的執行時間,以便於後期作一些性能優化。

function time(target, name, descriptor) {
    const func = descriptor.value;
    if (typeof func === 'function') {
        descriptor.value = function(...args) {
            console.time();
            const results = func.apply(this, args);
            console.timeEnd();
            return results;
        }
    }
}
class Person {
    @time
    say() {
        console.log('hello')
    }
}
const person = new Person();
person.say();

在 react 知名的狀態管理庫 mobx 中,也經過裝飾器來將類屬性置爲可觀察屬性,以此來實現響應式編程。

import {
    observable,
    action,
    autorun
} from 'mobx'

class Store {
    @observable count = 1;
    @action
    changeCount(count) {
        this.count = count;
    }
}

const store = new Store();
autorun(() => {
    console.log('count is ', store.count);
})
store.changeCount(10); // 修改 count 的值,會引發 autorun 中的函數自動執行。

3.5 裝飾器組合

若是你想要使用多個裝飾器,那麼該怎麼辦呢?裝飾器是能夠疊加的,根據離被裝飾類/屬性的距離來依次執行。

class Person {
    @time
    @log
    say() {}
}

除此以外,在裝飾器的提案中,還出現了一種組合了多種裝飾器的裝飾器例子。目前還沒見到被使用。

經過使用 decorator 來聲明一個組合裝飾器 xyz,這個裝飾器組合了多種裝飾器。

decorator @xyz(arg, arg2 {
  @foo @bar(arg) @baz(arg2)
}
@xyz(1, 2) class C { }

和下面這種寫法是同樣的。

@foo @bar(1) @baz(2)
class C { }

4. 裝飾器能夠作哪些有意思的事情?

4.1 多重繼承

在實現 JavaScript 多重繼承的時候,可使用 mixin 的方式,這裏結合裝飾器甚至還能更進一步簡化 mixin 的使用。

mixin 方法將會接收一個父類列表,用其裝飾目標類。咱們理想中的用法應該是這樣:

@mixin(Parent1, Parent2, Parent3)
class Child {}

和以前實現多重繼承的時候實現原理一致,只須要拷貝父類的原型屬性和實例屬性就能夠實現了。

這裏建立了一個新的 Mixin 類,來將 mixinstargetClass 上面的全部屬性都拷貝過去。

const mixin = (...mixins) => (targetClass) => {
  mixins = [targetClass, ...mixins];
  function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      if (key !== 'constructor'
        && key !== 'prototype'
        && key !== 'name'
      ) {
        let desc = Object.getOwnPropertyDescriptor(source, key);
        Object.defineProperty(target, key, desc);
      }
    }
  }
  class Mixin {
    constructor(...args) {
      for (let mixin of mixins) {
        copyProperties(this, new mixin(...args)); // 拷貝實例屬性
      }
    }
  }

  for (let mixin of mixins) {
    copyProperties(Mixin, mixin); // 拷貝靜態屬性
    copyProperties(Mixin.prototype, mixin.prototype); // 拷貝原型屬性
  }
  return Mixin;
}

export default mixin

咱們來測試一下這個 mixin 方法是否可以正常工做吧。

class Parent1 {
    p1() {
        console.log('this is parent1')
    }
}
class Parent2 {
    p2() {
        console.log('this is parent2')
    }
}
class Parent3 {
    p3() {
        console.log('this is parent3')
    }
}
@mixin(Parent1, Parent2, Parent3)
class Child {
    c1 = () => {
        console.log('this is child')
    }
}
const child = new Child();
console.log(child);

最終在瀏覽器中打印出來的 child 對象是這樣的,證實了這個 mixin 是能夠正常工做的。

注意:這裏的 Child 類就是前面的 Mixin 類。

image.png-69.4kB

也許你會問,爲何還要多建立一個多餘的 Mixin 類呢?爲何不能直接修改 targetClassconstructor 呢?前面不是講過 Proxy 能夠攔截 constructor 嗎?

恭喜你,你已經想到了 Proxy 的一種使用場景。沒錯,這裏用 Proxy 的確會更加優雅。

const mixin = (...mixins) => (targetClass) => {
    function copyProperties(target, source) {
        for (let key of Reflect.ownKeys(source)) {
          if ( key !== 'constructor'
            && key !== 'prototype'
            && key !== 'name'
          ) {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
          }
        }
      }
    
      for (let mixin of mixins) {
        copyProperties(targetClass, mixin); // 拷貝靜態屬性
        copyProperties(targetClass.prototype, mixin.prototype); // 拷貝原型屬性
      }
      // 攔截 construct 方法,進行實例屬性的拷貝
      return new Proxy(targetClass, {
        construct(target, args) {
          const obj = new target(...args);
          for (let mixin of mixins) {
              copyProperties(obj, new mixin()); // 拷貝實例屬性
          }
          return obj;
        }
      });
}

4.2 防抖和節流

以往咱們在頻繁觸發的場景下,爲了優化性能,常常會使用到節流函數。下面以 React 組件綁定滾動事件爲例子:

class App extends React.Component {
    componentDidMount() {   
        this.handleScroll = _.throttle(this.scroll, 500);
        window.addEveneListener('scroll', this.handleScroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.handleScroll);
    }
    scroll() {}
}

在組件中綁定事件須要注意應當在組件銷燬的時候進行解綁。而因爲節流函數返回了一個新的匿名函數,因此爲了以後可以有效解綁,不得不將這個匿名函數存起來,以便於以後使用。

可是在有了裝飾器以後,咱們就沒必要在每一個綁定事件的地方都手動設置 throttle 方法,只須要在 scroll 函數添加一個 throttle 的裝飾器就好了。

const throttle = (time) => {
    let prev = new Date();
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
                const now = new Date();
                if (now - prev > wait) {
                    fn.apply(this, args);
                    prev = new Date();
                }
            }
        }
    }
}

使用起來比原來要簡潔不少。

class App extends React.Component {
    componentDidMount() {
        window.addEveneListener('scroll', this.scroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.scroll);
    }
    @throttle(50)
    scroll() {}
}

而實現防抖(debounce)函數裝飾器和節流函數相似,這裏也再也不多說。

const debounce = (time) => {
    let timer;
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
                if(timer) clearTimeout(timer)
                timer = setTimeout(()=> {
                    fn.apply(this, args)
                }, wait)
            }
        }
    }
}

若是對節流和防抖函數比較感興趣,那麼能夠去閱讀一下這篇文章:函數節流與函數防抖

4.3 數據格式驗證

經過類屬性裝飾器來對類的屬性進行類型的校驗。

const validate = (type) => (target, name) => {
    if (typeof target[name] !== type) {
        throw new Error(`attribute ${name} must be ${type} type`)
    }
}
class Form {
    @validate('string')
    static name = 111 // Error: attribute name must be ${type} type
}

若是你以爲對屬性一個個手動去校驗太過麻煩,也能夠經過編寫校驗規則,來對整個類進行校驗。

image_1e3c1khlm169r8ei7j4s25qjfm.png-44.7kB

const rules = {
    name: 'string',
    password: 'string',
    age: 'number'
}
const validator = rules => targetClass => {
    return new Proxy(targetClass, {
        construct(target, args) {
            const obj = new target(...args);
            for (let [name, type] of Object.entries(rules)) {
                if (typeof obj[name] !== type) {
                    throw new Error(`${name} must be ${type}`)
                }
            }
            return obj;
        }
    })
}

@validator(rules)
class Person {
    name = 'tom'
    password = '123'
    age = '21'
}
const person = new Person();

4.4 core-decorators.js

core-decorators 是一個封裝了經常使用裝飾器的 JS 庫,它概括了下面這些裝飾器(只列舉了部分)。

  1. autobind:自動綁定 this,告別箭頭函數和 bind
  2. readonly:將類屬性設置爲只讀
  3. override:檢查子類的方法是否正確覆蓋了父類的同名方法
  4. debounce:防抖函數
  5. throttle:節流函數
  6. enumerable:讓一個類方法變得可枚舉
  7. nonenumerable:讓一個類屬性變得不可枚舉
  8. time:打印函數執行耗時
  9. mixin:將多個對象混入類(和咱們上面的 mixin 不太同樣)

5. 總結

裝飾器雖然還屬於不穩定的語法,但在不少框架中都已經普遍使用,例如 Angular、Nestjs 等等,和 Java 中的註解用法很是類似。
裝飾器在 TypeScript 中結合反射後還有一些更高級的應用,下篇文章會進行深刻講解。

推薦閱讀:

  1. 裝飾器 —— 阮一峯
  2. JS 裝飾器(Decorator)場景實戰
  3. 探索JavaScript中的裝飾器模式
  4. 王者榮耀之「裝飾者模式」

❤️ 看完三件事

若是你以爲這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點贊,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓 -_-)
  2. 關注公衆號「前端小館」,或者加我微信號「testygy」拉你進羣,按期分享原創知識。
  3. 也看看其它文章

相關文章
相關標籤/搜索