深刻浅出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. 若是有多个装饰器就重复以上流程。
相关文章
相关标签/搜索