一文讀懂 JS 裝飾器,這是一個會打扮的裝飾器
- 2020 年 3 月 30 日
- 筆記
1. 前言
裝飾器是最新的 ECMA 中的一個提案,是一種與類(class)相關的語法,用來注釋或修改類和類方法。裝飾器在 Python 和 Java 等語言中也被大量使用。裝飾器是實現 AOP(面向切面)編程的一種重要方式。

下面是一個使用裝飾器的簡單例子,這個 @readonly
可以將 count
屬性設置為只讀。可以看出來,裝飾器大大提高了代碼的簡潔性和可讀性。
class Person { @readonly count = 0; }
由於瀏覽器還未支持裝飾器,為了讓大家能夠正常看到效果,這裡我使用 Parcel 進行了一下簡單的配置,可以去 clone 這個倉庫後再來運行本文涉及到的所有例子,倉庫地址:[learn es6][https://github.com/yinguangyao/ES6]
本文涉及到 [Object.defineProperty][https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty]、高階函數等知識,如果之前沒有了解過相關概念,建議先了解後再來閱讀本文。
2. 裝飾器模式
在開始講解裝飾器之前,先從經典的裝飾器模式說起。裝飾器模式是一種結構型設計模式,它允許向一個現有的對象添加新的功能,同時又不改變其結構,是作為對現有類的一個包裝。一般來說,在代碼設計中,我們應當遵循「多用組合,少用繼承」的原則。通過裝飾器模式動態地給一個對象添加一些額外的職責。就增加功能來說,裝飾器模式相比生成子類更為靈活。
2.1 一個英雄聯盟的例子
下班回去和朋友愉快地開黑,當我正在用亞索「面對疾風吧」的時候,突然想到,如果讓我設計亞索英雄,我該怎麼實現呢?

我想了想,肯定會先設計一個英雄的類。
class Hero { attack() {} }
然後,再實現一個 Yasuo 的類來繼承這個 Hero
類。
class Yasuo extends Hero { attack() { console.log("斬鋼閃"); } }
我還在想這個問題的時候,隊友已經打了大龍,我的亞索身上就出現了大龍 buff 的印記。我突然想到,那該怎麼給英雄增加大龍 buff 呢?那增加個大龍 buff 的屬性不行嗎?當然不太行,要知道,英雄聯盟裏面的大龍 buff 是會增加收益的。嗯,聰明的我已經想到辦法了,再繼承一次不就好了嗎?
class BaronYasuo extends Yasuo { }
厲害了,但是如果亞索身上還有其他 buff 呢?畢竟 LOL 裏面是有紅 buff、藍 buff、大龍 buff等等存在,那豈不是有多少種就要增加多少個類嗎?

可以換種思路來思考這個問題,如果把 buff 當做我們身上的衣服。在不同的季節,我們會換上不同的衣服,到了冬天,甚至還會疊加多件衣服。當 buff 消失了,就相當於把這件衣服脫了下來。如下圖所示:

衣服對人來說起到裝飾的作用,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亞索

3. ES7 裝飾器
decorator(裝飾器)是 ES7 中的一個提案,目前處於 stage-2 階段,提案地址:[JavaScript Decorators][https://github.com/tc39/proposal-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})

這個 auth
裝飾器是通過檢查 cookie
來判斷用戶是否登錄的。auth
函數是一個高階函數,它接收了一個 func
函數作為參數,返回了一個新的 inner
函數。在 inner
函數中進行 cookie
的檢查,由此來判斷是跳回登錄頁面還是繼續執行 func
函數。在所有需要權限驗證的函數上,都可以使用這個 auth
裝飾器,很簡潔明了且無侵入。
3.2 JavaScript 裝飾器
JavaScript 中的裝飾器和 Python 的裝飾器類似,依賴於 Object.defineProperty
,一般是用來裝飾類、類屬性、類方法。使用裝飾器可以做到不直接修改代碼,就實現某些功能,做到真正的面向切面編程。這在一定程度上和 Proxy 很相似,但使用起來比 Proxy 會更加簡潔。
注意:裝飾器目前還處於 stage-2,意味着語法之後也許會有變動。裝飾器用於函數、對象等等已經有一些規劃,請看:[Future built-in decorators][https://github.com/tc39/proposal-decorators/blob/master/NEXTBUILTINS.md#applying-built-in-decorators-to-other-syntactic-forms]
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 Chinese
利用高階函數的屬性,還可以給裝飾器傳參,通過參數來判斷對類進行什麼處理。
const withLanguage = (language) => (targetClass) => { targetClass.prototype.language = language; } @withLanguage('Chinese') class Student { } const student = new Student(); student.language; // 'Chinese'
如果你經常編寫 react-redux 的代碼,那麼也會遇到需要將 store 中的數據映射到組件中的情況。connect 是一個高階組件,它接收了兩個函數 mapStateToProps
和 mapDispatchToProps
以及一個組件 App
,最終返回了一個增強版的組件。
class App extends React.Component { } connect(mapStateToProps, mapDispatchToProps)(App)
有了裝飾器之後,connect 的寫法可以變得更加優雅。
@connect(mapStateToProps, mapDispatchToProps) class App extends React.Component { }
3.4 類屬性裝飾器
類屬性裝飾器可以用在類的屬性、方法、get/set 函數中,一般會接收三個參數:
- target:被修飾的類
- name:類成員的名字
- 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
類,來將 mixins
和 targetClass
上面的所有屬性都拷貝過去。
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
類呢?為什麼不能直接修改 targetClass
的 constructor
呢?前面不是講過 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) } } } }
如果對節流和防抖函數比較感興趣,那麼可以去閱讀一下這篇文章:[函數節流與函數防抖][https://juejin.im/entry/58c0379e44d9040068dc952f]
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') name = 111 // Error: attribute name must be ${type} type }
如果你覺得對屬性一個個手動去校驗太過麻煩,也可以通過編寫校驗規則,來對整個類進行校驗。

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 庫,它歸納了下面這些裝飾器(只列舉了部分)。
- autobind:自動綁定
this
,告別箭頭函數和bind
- readonly:將類屬性設置為只讀
- override:檢查子類的方法是否正確覆蓋了父類的同名方法
- debounce:防抖函數
- throttle:節流函數
- enumerable:讓一個類方法變得可枚舉
- nonenumerable:讓一個類屬性變得不可枚舉
- time:打印函數執行耗時
- mixin:將多個對象混入類(和我們上面的 mixin 不太一樣)