深入淺出decorator

胡洋®發表於2018-12-28

前言

在Mobx中是使用裝飾器設計模式來實現觀察值的,因此為了進一步瞭解Mobx需要對裝飾器模式有一定的認識。此文從設計模式觸發到ES7中裝飾器語法的應用再到通過babel觀察轉換@語法,瞭解es7裝飾器語法的具體實現細節。

javascript設計模式之裝飾者模式

首先闡述下什麼是裝飾者模式

1.1裝飾者模式定義及特點

定義:在不改變原物件的基礎上,在程式執行期間動態地給物件新增一些額外職責。試原有物件可以滿足使用者更復雜的需求。

特點:

  • 在不改變原物件的原本結構的情況下進行功能新增
  • 裝飾物件和原物件具有相同的介面,可以使使用者以與原物件相同的方式使用裝飾物件
  • 裝飾物件是原物件經過包裝後的物件

1.2 要解決的問題

要正確的理解設計模式,首先要明白它是因為什麼問題被提出來的。

在傳統的面嚮物件語言中,我們給物件新增職責通常是通過繼承來實現,而繼承有很多缺點:

  • 父類和子類強耦合,父類改變會導致子類改變
  • 父類內部細節對子類可見,破壞了封裝性
  • 在實現功能複用的同時,可能會創造過多子類

舉個例子:一個咖啡店有四種型別的咖啡豆,假如我們為四種不同型別的咖啡豆定義了四個類,但是我們還需要給它們新增不同的口味(一共有五種口味),因此如果通過繼承來將不同種類的咖啡展示出來需要建立4x5(20)個類(還不包括混合口味),而通過裝飾者模式只需要定義五種不同的口味類將它們動態新增到咖啡豆類即可實現。

通過上述例子,我們可以發現通過裝飾者模式可以實現動態靈活地向物件新增職責而沒有顯式地修改原有程式碼,大大減少了需要建立的類數量,它彌補了繼承的不足,解決了不同類之間共享方法的問題

它的具體使用場景如下:

  1. 需要擴充套件一個物件的功能,或者給一個物件增加附加責任
  2. 需要動態的給一個物件增加功能,並動態地撤銷這些功能
  3. 需要將一些基本的功能通過排列組合成一個巨大的功能,使得通過繼承變得不現實

1.3 簡單實現

以遊戲中的角色為例,眾所周知,遊戲中的角色都有初始屬性(hp、def、attack),而我們通過給角色裝配裝備來增強角色的屬性值。

var role = {
	showAttribute: function() {
    	  console.log(`初始屬性:hp: 100 def: 100 attack: 100`)
        }
}
複製程式碼

這時我們通過給角色穿戴裝飾裝備提高他的屬性,我們可以這樣做。

var showAttribute = role.showAttribute
var wearArmor = function() {
    console.log(`裝備後盔甲屬性:hp: 200 def: 200 attack: 100`)
}

role.showAttribute = function() {
    showAttribute()
    wearArmor()
}
var showAttributeUpgrade = role.showAttribute

var wearWepeon = function() {
    console.log(`裝備武器後屬性:hp: 200 def: 200 attack: 200`)
}
role.showAttribute = function() {
    showAttributeUpgrade()
    wearWepeon()
}
複製程式碼

通過這樣將一個物件放入另一個物件,動態地給物件新增職責而沒有改變物件自身,其中wearArmor和wearWepeon為裝飾函式,它們裝飾了role物件的showAttribute這個方法形成了一條裝飾鏈,當函式執行到此時,會自動將請求轉發至下一個物件。

除此之外,我們還可以觀察出,在裝飾者模式中,我們不可以在不瞭解showAttribute這個原有方法的具體實現細節就可以對其進行擴充套件,並且原有物件的方法照樣可以原封不動地進行呼叫。

裝飾模式的場景 -- AOP程式設計

在JS中,我們可以很容易地給物件擴充套件屬性和方法,但如果我們想給函式新增額外功能的話,就不可避免地需要更改函式的原始碼,比如說:

function test() {
    console.log('Hello foo')
}
複製程式碼
function test() {
    console.log('Hello foo')
    console.log('Hello bar')
}
複製程式碼

這種方式違背了物件導向設計原則中的開放封閉原則,通過侵犯模組的原始碼以實現功能的擴充是一個糟糕的做法。

針對上述問題,一種常見的解決方法是設定一箇中間變數快取函式引用,可以對上述函式做如下改動:

var test = function() {
    console.log('Hello foo')
}
var _test = test
test = function() {
    _test()
    console.log('Hello bar')
}
複製程式碼

通過快取函式引用實現了函式的擴充,但是這種方式還是存在問題:除了會在裝飾鏈過長的情況下引入過多中間變數難以維護,還會造成this劫持發生導致不易察覺的bug,雖然this劫持問題可以通過call修正this指向,但還是過於麻煩。

為了解決上述痛點,我們可以引入AOP(面向切面程式設計)這種模式。那麼什麼是面向切面程式設計呢,簡而言之就是將一些與核心業務邏輯無關的功能抽離出來,以動態的方式加入業務模組,通過這種方式保持核心業務模組的程式碼純淨和高內聚性以及可複用性。這種模式廣泛被應用在日誌系統、錯誤處理。

而實現它們也非常簡單,只需要給函式原型擴充套件兩個函式即可:

Function.prototype.before = function(beforeFunc) {
    let that = this
    return function() {
		beforeFunc.apply(this,arguments)
		return that.apply(this,arguments)
    }
}

Function.prototype.after = function(afterFunc) {
    let that = this
    return function() {
        let ret = that.apply(this,arguments)
        afterFunc.apply(this,arguments)
        return ret
    }
}
複製程式碼

假設有個需求需要在更新資料庫前後都列印相應日誌,運用AOP我們可以這樣做:

function updateDb() {
    console.log(`update db`)
}
function beforeUpdateDb() {
    console.log(`before update db`)
}
function afterUpdateDb() {
    console.log(`updated db`)
}
updateDb = updateDb.before(beforeUpdateDb).after(afterUpdateDb)
複製程式碼

通過這種方式我們可以靈活地實現了對函式的擴充套件,避免了函式被和業務無關的程式碼侵入,增加了程式碼的耦合度。

裝飾者模式本身的設計理念是非常可取的,但還是可以發現上述程式碼的實現方式還是過於臃腫,不如python這類語言從語言層面支援裝飾器實現裝飾模式來得簡潔明瞭,所幸javascript現在也引入了這個概念,我們可以通過babel使用它。

探索ECMAScript中的裝飾器Decorator

ES7中也引入了decorator這個概念,並且通過babel可以得到很好的支援。本質上來說,decorator和class一樣只是一個語法糖而已,但是卻非常有用,任何裝飾者模式的程式碼通過decorator都可以以更加清晰明瞭的方式得以實現。

工具準備

首先需要安裝babel:

npm install babel-loader  babal-core  babel-preset-es2015 babel-plugin-transform-decorators-legacy
複製程式碼

在工作區目錄下新建.babelrc檔案

{
  "presets": [
    // 把es6轉成es5
    'es2015'
  ],
  // 處理裝飾器語法
  "plugins": ['transform-decorators-legacy']
}
複製程式碼

這樣準備工作就完成了,就可以使用babel來將帶decorator的程式碼轉換成es5程式碼了

babel index.js > index.es5.js
複製程式碼

或者我們也可以通過babel-node index.js直接執行程式碼輸出結果

背後原理

decorator使我們能夠在編碼時對類、屬性進行修改提供了可能,它的原理是利用了ES5當中的

Object.defineProperty(target,key,descriptor)
複製程式碼

其中最核心的就是descriptor——屬性描述符。

屬性描述符分為兩種:資料描述符訪問器描述符,描述符必須是兩者之一,但不能同時包含兩者。我們可以通過ES5中的Object.getOwnPropertyDescriptor來獲取物件某個具體屬性的描述符:

資料描述符:

var user = {name:'Bob'}
Object.getOwnPropertyDescriptor(user,'name')

// 輸出
/**
{
  "value": "Bob",
  "writable": true,
  "enumerable": true,
  "configurable": true
}
**/
複製程式碼

訪問器描述符:

var user = {
    get name() {
        return name
    },
    set name(val) {
		name = val 
    }
}

// 輸出
/**
{
  "get": f name(),
  "set": f name(val),
  "enumerable": true,
  "configurable": true
}
**/

複製程式碼

來觀察一個簡單的ES6類:

class Coffee {
    toString() {
        return `sweetness:${this.sweetness} concentration:${this.concentration}`
    }
}
複製程式碼

執行這段程式碼,給Coffee.prototype註冊一個toString屬性,功能與下述程式碼相似:

Object.defineProperty(Coffee.prototype, 'toString', {
  value: [Function],
  enumerable: false,
  configurable: true,
  writable: true
})
複製程式碼

當我們通過裝飾器給Coffee類標註一個屬性讓其變成一個只讀屬性時,可以這樣做:

class Coffee {
    @readonly
    toString() {
        return `sweetness:${this.sweetness} concentration:${this.concentration}`
    }
}
複製程式碼

這段程式碼等價於:

let descriptor = {
  value: [Function],
  enumerable: false,
  configurable: true,
  writable: true
};
descriptor = readonly(Coffee.prototype, 'toString', descriptor) || descriptor;
Object.defineProperty(Coffee.prototype, 'toString', descriptor);
複製程式碼

從上面程式碼可以看出,裝飾器是在Object.defineProperty為Coffee.prototype註冊toString屬性前對其進行攔截,執行一個函式名為readonly的裝飾函式,這個裝飾函式接收是三個引數,它的函式簽名和Object.defineProperty一致,分別表示:

  • 需要定義屬性的物件——被裝飾的類
  • 許定義或修改的屬性的名字——被裝飾的屬性名
  • 被定義和修改屬性的描述符——屬性的描述物件

這個函式的作用就是將descroptor這個引數的資料描述屬性writable由true改為false,從而使得目標物件的屬性不可被更改。

具體用法

假設我們需要給咖啡類增加一個增加甜度和增加濃度的方法,可以這樣實現:

作用在類方法上

function addSweetness(target, key, descriptor) {
	const method = descriptor.value
	descriptor.value = (...args) => {
		args[0] += 10
		const ret = method.apply(target, args);
		return ret
	}
	return descriptor
}

function addConcentration(target, key, descriptor) {
	const method = descriptor.value
	descriptor.value = (...args) => {
		args[1] += 10
		const ret = method.apply(target, args)
		return ret
	}
	return descriptor
}

class Coffee {
	constructor(sweetness = 0, concentration=10) {
		this.init(sweetness, concentration)
	}
	@addSweetness
	@addConcentration
	init(sweetness, concentration) {
		this.sweetness = sweetness // 甜度
		this.concentration = concentration; // 濃度
	}
	toString() {
		return `sweetness:${this.sweetness} concentration:${this.concentration}`
    }
}

const coff = new Coffee()
console.log(`${coff}`)

複製程式碼

首先看看輸出結果sweetness:10 concentration:20,可以看出通過addSweetnessaddConcentration這兩個裝飾器方法裝飾在init方法,通過descriptor.value獲得init方法並用中間變數快取,然後重新給descriptor.value賦值一個代理函式,在代理函式內部通過arguments接收init方法傳來的實參並進行改動後重新執行之前的快取函式得到計算結果。至此我們便通過decorator的形式成功實現了需求。

從這裡我們可以看出裝飾器模式的優勢了,可以對某個方法進行疊加使用,而不對原有程式碼有過強的侵入性,方便複用又可以快速增刪。

作用在類上

當需要給咖啡類加冰塊時,相當於賦予了它一個新的屬性,這時可以通過將decorator作用在類上面,對類進行增強。

function addIce(target) {
	target.prototype.iced = true
}

@addIce
class Coffee {
  constructor(sweetness = 0, concentration = 10) {
    this.init(sweetness, concentration);
  }

  init(sweetness, concentration) {
    this.sweetness = sweetness; // 甜度
    this.concentration = concentration; // 濃度
  }
  toString() {
    return `sweetness:${this.sweetness} concentration:${this.concentration} iced:${this.iced}`;
  }
}

const coff = new Coffee()
console.log(`${coff}`)
複製程式碼

先看看輸出結果sweetness:0 concentration:10 iced:true,通過作用在類上的裝飾器成功給類的原型新增了屬性。 當decorator作用在類上時,只會傳入一個引數,就是類本身,在裝飾方法中通過變更類的原型給其增加屬性。

decorator也可以是工廠函式

當想要通過一個decorator作用在不同的目標上有不同的表現時,我們可以將decorator用工廠模式實現:

function decorateTaste(taste) {
    return function(target) {
        target.taste = taste;
    }
}

@decorateTaste('bitter')
class Coffee {
    toString() {
        return `taste:${Coffee.taste}`;
    }
}

@decorateTaste('sweet')
class Milk {
    toString() {
        return `taste:${Milk.taste}`;
    }
}
複製程式碼

實際應用

decorator雖然只是語法糖,但卻有非常多的應用場景,這裡簡單提一個AOP的應用場景,也和前面提到的ES5實現的版本有一個對比。

function AOP(beforeFn, afterFn) {
    return function(target, key, descriptor) {
        const method = descriptor.value
        descriptor.value = (...args) => {
            let ret
            beforeFn && beforeFn(...args)
            ret = method.apply(target, args)
            if (afterFn) {
                ret = afterFn(ret)
            }
            return ret
        }
    }
}

// 給sum函式每個引數進行+1操作
function before(...args) {
    return args.map(item => item + 1)
}

// 接收sum函式求的和再執行後置操作
function after(sum) {
    return sum + 66
}

class Calculate {
    @AOP(before, after)
    static sum(...args) {
        return args.reduce((a, b) => a + b)
    }
}

console.log(Calculate.sum(1, 2, 3, 4, 5, 6))
複製程式碼

通過將AOP的裝飾器函式作用在類方法上可以實現對函式的引數進行前置處理,再對目標函式輸出結果進行 後置處理。與ES5實現相比,避免了汙染函式原型,通過一種清晰靈活的方式實現,減少了程式碼量。

babel如何實現裝飾器的@語法

在瞭解裝飾器模式和decorator的基本知識後,終於進入正題了,babel內部是如何裝飾器@語法呢。

簡單看官網上的一個示例:

import { observable, computed, action } from "mobx";

class OrderLine {
    @observable price = 0;   
    @observable amount = 1;

    @computed get total() {
        return this.price * this.amount;
    }
    
    @action.bound
    increment() {
        this.amount++
    }
}
複製程式碼

通過babel裝飾器外掛將其轉換為ES5程式碼,觀察@語法被轉換的結果,分析下轉換之後的程式碼邏輯。(轉換這段程式碼需要安裝babel-preset-mobx這個預設)

首先來看針對price屬性的裝飾器語法:

@observable price = 0;   
複製程式碼

這段程式碼主要做的事情就是宣告一個屬性成員price,然後將裝飾器函式應用至該屬性從而起到了裝飾的作用,具體虛擬碼如下:

// _initializerDefineProperty方法的作用就是通過Object.defineProperty為orderLine這個類定義屬性成員,
// 而其中的_descriptor為經過裝飾後的屬性描述符,該值由_applyDecoratedDescriptor方法根據入參返回
// 經過特定裝飾器裝飾的修飾符
_initializerDefineProperty(this, "price", _descriptor, this);
_descriptor = _applyDecoratedDescriptor(_class.prototype, "price", [observable], {
      configurable: true,
      enumerable: true,
      writable: true,
      initializer: function () {
        return 0;
      }
})

複製程式碼

可以看出babel轉換@語法的關鍵是通過_applyDecoratedDescriptor方法,接下來重點解析下此方法。

image

該函式簽名為:

function _applyDecoratedDescriptor(target, property, decorators, descriptor, context)
複製程式碼

該函式形參各自的含義如下所示:

  • target: OrderLine.prototype
  • property: 具體屬性名
  • decorators: 裝飾器——不同的修飾符裝飾器是不一樣的,比如通過@observerable修飾的裝飾器就是[observable],通過@computed修飾符修飾的裝飾器就是[computed]
  • descriptor: 屬性描述符,這裡需要注意的是類可分為屬性成員和方法成員,其中屬性成員會有initializer這個屬性定義初始值,而方法成員沒有這個屬性,因此可通過此屬性區分屬性成員和方法成員,在函式內部邏輯有所體現
  • context: 執行上下文

解釋完函式簽名後,開始進入函式邏輯。

首先要明確這個函式的作用就是根據傳入引數返回裝飾後的屬性描述符,其中最核心的邏輯就是將裝飾器迴圈應用至原有屬性,程式碼如下:

desc = decorators.slice().reverse().reduce(function (desc, decorator) { 
    return decorator(target, property, desc) || desc; 
  }, desc); 
複製程式碼

假設我們傳入的decorators是[a, b, c],那麼上面程式碼就相當於應用公式a(b(c(property))),即裝飾器c、b、a先後作用與目標屬性,而decorator的函式簽名與Object.defineProperty一致,它的作用就是修改目標屬性的描述符。

至此babel轉換成@語法的精髓已解釋完,它的核心就是_applyDecoratedDescriptor這個方法,而這個方法主要做的就是將裝飾器迴圈應用至目標屬性。

小結一下,@語法的原理就是:

  1. 先從物件中獲取屬性成員的原始描述符、
  2. 將原始描述符傳入裝飾器方法,獲取修改後的屬性描述符、
  3. 通過Object.defineProperty將修改後的屬性描述符運用至目標屬性、
  4. 如果有多個裝飾器就重複以上流程。