深刻淺出decorator

前言

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

javascript設計模式之裝飾者模式

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

1.1裝飾者模式定義及特色

定義:在不改變原對象的基礎上,在程序運行期間動態地給對象添加一些額外職責。試原有對象能夠知足用戶更復雜的需求。java

特色:node

  • 在不改變原對象的本來結構的狀況下進行功能添加
  • 裝飾對象和原對象具備相同的接口,可使用戶以與原對象相同的方式使用裝飾對象
  • 裝飾對象是原對象通過包裝後的對象

1.2 要解決的問題

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

在傳統的面嚮對象語言中,咱們給對象添加職責一般是經過繼承來實現,而繼承有不少缺點:es6

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

舉個例子:一個咖啡店有四種類型的咖啡豆,假如咱們爲四種不一樣類型的咖啡豆定義了四個類,可是咱們還須要給它們添加不一樣的口味(一共有五種口味),所以若是經過繼承來將不一樣種類的咖啡展現出來須要建立4x5(20)個類(還不包括混合口味),而經過裝飾者模式只須要定義五種不一樣的口味類將它們動態添加到咖啡豆類便可實現。數據庫

經過上述例子,咱們能夠發現經過裝飾者模式能夠實現動態靈活地向對象添加職責而沒有顯式地修改原有代碼,大大減小了須要建立的類數量,它彌補了繼承的不足,解決了不一樣類之間共享方法的問題npm

它的具體使用場景以下:編程

  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. 若是有多個裝飾器就重複以上流程。
相關文章
相關標籤/搜索