深入浅出decorator

栏目: 后端 · 发布时间: 5年前

内容简介:在Mobx中是使用装饰器设计模式来实现观察值的,因此为了进一步了解Mobx需要对装饰器模式有一定的认识。此文从设计模式触发到ES7中装饰器语法的应用再到通过babel观察转换@语法,了解es7装饰器语法的具体实现细节。首先阐述下什么是装饰者模式定义:在不改变原对象的基础上,在程序运行期间动态地给对象添加一些额外职责。试原有对象可以满足用户更复杂的需求。

在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 方法,接下来重点解析下此方法。

深入浅出decorator

该函数签名为:

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 这个方法,而这个方法主要做的就是将装饰器循环应用至目标属性。

小结一下,@语法的原理就是:

Object.defineProperty

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Namo Webeditor5.5一看就懂.

Namo Webeditor5.5一看就懂.

吳聲毅 / 金禾資訊 / 20040214 / NT$ 169

一看就懂系列書全以初學者的角度切入,全書以STEP BY STEP方式撰寫,並以豐富的圖片搭配教學,在最後更加上日常生活實例運用講解,一路學來一氣呵成。為了增進學習的效率更採用高級紙品全彩印刷,這麼好的書,您還在等什麼,一看就懂系列書保證是您最佳入門學習好伙伴。 本書特色: 1、一看就懂:Step by Step操作詳盡說明、讓您一看就懂 2、精選範例:精彩實務範例生動活......一起来看看 《Namo Webeditor5.5一看就懂.》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器