TS 设计模式05 - 装饰者模式

2020-09-07 01:10:31 浏览数 (1)

1. 简介

在 oop 中,继承是实现多态最简单的方案。同一类的对象会有不同表现时,我们基于此基类去写派生类即可。但有时候,过度使用继承会导致程序无法维护。比如说,人有一个展示自己外观的方法,穿上不同的衣服这个展现形式就不一样。一个人可以选择穿 T-shirt,裤子,裙子,外套等等,它的顺序和搭配是不固定的,如果使用继承,我们对每种组合都需要去定义一个类,比如穿裤子的人,穿裙子的人,穿裤子和裙子的人,先穿裤子再穿外套的人......这样会是我们的程序变得非常庞大而难以维护。

事实上,不管穿什么衣服,本质上仍然是人,衣服只是基于人类的装饰而已。装饰器模式允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。

2. 装饰器模式

image.png

代码语言:javascript复制
interface People {
    show(): void;
}

class Boy implements People {
    private name: string;
    constructor(name) {
        this.name = name;
    }
    show(): void {
        console.log(`I'm a boy. My name is ${this.name}`);
    }
}

class Girl implements People {
    private name: string;
    constructor(name) {
        this.name = name;
    }
    show(): void {
        console.log(`I'm a girl. My name is ${this.name}`);
    }
}

abstract class PeopleDecorator implements People {
    protected people: People;
    constructor(people: People) {
        this.people = people;
    }
    show(): void {
        this.people.show();
    }
}

class PeopleDecoratorTShirt extends PeopleDecorator {
    show(): void {
        console.log('I wear my T-shirt');
        super.show();
        console.log('My T-shirt is beautiful');
    }
}

class PeopleDecoratorTrousers extends PeopleDecorator {
    show(): void {
        console.log('I wear my trousers');
        super.show();
        console.log('My trousers are beautiful');
    }
}

class PeopleDecoratorSkirt extends PeopleDecorator {
    show(): void {
        console.log('I wear my skirt');
        super.show();
        console.log('My skirt are beautiful');
    }
}

const boy = new Boy('LiLei');
boy.show();
console.log('======');
const boyWithTShirt = new PeopleDecoratorTShirt(boy);
boyWithTShirt.show();
console.log('======');
const boyWithTShirtAndTrousers = new PeopleDecoratorTrousers(boyWithTShirt);
boyWithTShirtAndTrousers.show();
console.log('======');

const girl = new Girl('HanMeiMei');
girl.show();
console.log('======');
const girlWithSkirt = new PeopleDecoratorSkirt(girl);
girlWithSkirt.show();
console.log('======');
const girlWithSkirtAndTShirt = new PeopleDecoratorTShirt(girlWithSkirt);
girlWithSkirtAndTShirt.show();

image.png

3. ES7 的 decorators

在一些场景下我们需要额外的特性来支持标注或修改类及其成员,ES7 提出了 decorators 概念,为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。 JS 里的装饰器目前处在 stag2,但在 TS 里已做为一项实验性特性予以支持,另外我们也可以用 babel 进行使用,目前,通过 babel 这类编译器,已经获得了广泛应用,比如core-decorators, ember-decorators, Angular, Stencil, 和 MobX decorators 等。

这个概念其实借鉴自 Python。在 Python 里,decorator 实际上是一个 wrapper,它作用于一个目标函数,对这个目标函数做一些额外的操作,然后返回一个新的函数。这其实是一种函数定义时的语法糖,和装饰者模式并不一样,但是目的可以说是类似的。ES7 中的 decorator 同样借鉴了这个语法糖,不过依赖于 ES5 的 Object.defineProperty

方法来实现。

下面我们用 TS 的装饰器来进行讲解。

3.1 装饰器类型

3.1.1 类装饰器

类装饰器在类声明之前被声明(紧靠着类声明)。 类装饰器应用于类构造函数,可以用来监视,修改或替换类定义。 类装饰器不能用在声明文件中( .d.ts),也不能用在任何外部上下文中(比如declare的类)。

类装饰器表达式会在运行时当作函数被调用,类的构造函数是其唯一的参数。如果类装饰器返回一个值,它会使用提供的构造函数来替换类的声明。

ps: 如果你要返回一个新的构造函数,你必须注意处理好原来的原型链。 在运行时的装饰器调用逻辑中,不会为你做这些。

代码语言:javascript复制
function debugMode(target) {
    target.debugMode = true;
}

@debugMode
class Test {
    private name: string;
    constructor(name: string) {
        this.name = name;
    }
}
console.log(Test.debugMode); // true
const test = new Test('demo');
console.log(test.debugMode); // undefined

可以看到,debugMode 装饰器能够为它修饰的对象添加一个属性 debugMode: true。

3.1.2 方法装饰器

方法装饰器声明在一个方法的声明之前(紧靠着方法声明)。 它会被应用到方法的属性描述符上,可以用来监视,修改或者替换方法定义。 方法装饰器不能用在声明文件( .d.ts),重载或者任何外部上下文(比如declare的类)中。

方法装饰器表达式会在运行时当作函数被调用,传入下列3个参数:

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  • 成员的名字。
  • 成员的属性描述符(数据属性)。

如果方法装饰器返回一个值,它会被用作方法的属性描述符。

代码语言:javascript复制
function log(target, name, descriptor) {
    const oldValue = descriptor.value;
    console.log('decorate', descriptor.value)
    descriptor.value = function (...args) {
        console.log(`Calling "${name}" with`, ...args);
        return oldValue.apply(this, args);
    };
}

class Math {
    @log
    add(a, b) {
        return a   b;
    }
}
console.log('start')
const math = new Math();

console.log(math.add(2, 4));
console.log(math.add(1, 4));

image.png

注意看,这里多次调用方法,但是修饰器 log 只会执行一次,且是在编译而不是运行时就已经执行,装饰器函数返回的值会作为函数的属性描述符。

3.1.3 访问器装饰器

访问器装饰器声明在一个访问器的声明之前(紧靠着访问器声明)。 访问器装饰器应用于访问器的 属性描述符并且可以用来监视,修改或替换一个访问器的定义。 访问器装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如 declare的类)里。

ps: TypeScript不允许同时装饰一个成员的get和set访问器。取而代之的是,一个成员的所有装饰的必须应用在文档顺序的第一个访问器上。这是因为,在装饰器应用于一个属性描述符时,它联合了get和set访问器,而不是分开声明的。

访问器装饰器表达式会在运行时当作函数被调用,传入下列3个参数:

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  • 成员的名字。
  • 成员的属性描述符(访问器属性)。

如果访问器装饰器返回一个值,它会被用作方法的属性描述符。

代码语言:javascript复制
function prev(target, name, descriptor) {
    const oldValue = descriptor.get;
    descriptor.get = function (...args) {
        return `Uppercase: ${oldValue.apply(this, args)}`;
    };
}
class Accessor {
    private innername;
    constructor(name) {
        this.innername = name;
    }
    @prev
    get name() {
        return this.innername.toUpperCase();
    }
    show() {
        console.log(this.name);
    }
}

const accessor = new Accessor('demo');
accessor.show();

这里可以在获取name时为其添加一个前缀。其实访问器和方法修饰符唯一的不同在于属性描述符,前者是访问器属性,后者是数据属性。

3.1.4 属性装饰器

属性装饰器声明在一个属性声明之前(紧靠着属性声明)。 属性装饰器不能用在声明文件中(.d.ts),或者任何外部上下文(比如 declare的类)里。

属性装饰器表达式会在运行时当作函数被调用,传入下列2个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。

ps: 属性描述符不会做为参数传入属性装饰器,这与 TypeScript 是如何初始化属性装饰器的有关。 因为目前没有办法在定义一个原型对象的成员时描述一个实例属性,并且没办法监视或修改一个属性的初始化方法。返回值也会被忽略。因此,属性描述符只能用来监视类中是否声明了某个名字的属性。

我们可以用它来记录这个属性的元数据,如下面例子所示:

代码语言:javascript复制
import 'reflect-metadata';

const formatMetadataKey = Symbol('format');

function format(formatString: string) {
    return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
    return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}
class Greeter {
    @format('Hello, %s')
    greeting: string;

    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        const formatString = getFormat(this, 'greeting');
        return formatString.replace('%s', this.greeting);
    }
}

const greeter = new Greeter('world');
console.log(greeter.greet()); // Hello, world

我们也可以使用元数据键获取元数据:

代码语言:javascript复制
import 'reflect-metadata';

function logType(target : any, key : string) {
    const t = Reflect.getMetadata('design:type', target, key);
    console.log(`${key} type: ${t.name}`); // attr type: String
}

class Demo {
    @logType // apply property decorator
    public attr : string;
}

使用元数据键需要将编译参数 emitDecoratorMetadata 设为 true。我们也必须包含对 reflect-metadata.d.ts 的引用并加载 Reflect.js 文件。

随后我们可以实现我们自己的装饰器并且使用一个可用的元数据设计键。到目前为止,只有三个可用的键:

  • 类型元数据使用元数据键"design:type"
  • 参数类型元数据使用元数据键"design:paramtypes"
  • 返回值类型元数据使用元数据键"design:returntype"

3.1.5参数装饰器

参数装饰器声明在一个参数声明之前(紧靠着参数声明)。 参数装饰器应用于类构造函数或方法声明。 参数装饰器不能用在声明文件(.d.ts),重载或其它外部上下文(比如 declare的类)里。

参数装饰器表达式会在运行时当作函数被调用,传入下列3个参数:

  • 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  • 成员的名字。
  • 参数在函数参数列表中的索引。

参数装饰器的返回值会被忽略。

代码语言:javascript复制
import 'reflect-metadata';

function logParamTypes(target : any, key : string) {
    const types = Reflect.getMetadata('design:paramtypes', target, key);
    const s = types.map(a => a.name).join();
    console.log(`${key} param types: ${s}`); // count param types: String,Number,Foo,Object,Object,Function,Function
}

class Foo {}
interface IFoo {}

class Demo {
    @logParamTypes
    count(
        param1 : string,
        param2 : number,
        param3 : Foo,
        param4 : { test : string },
        param5 : IFoo,
        param6 : Function,
        param7 : (a : number) => void,
    ) : number {
        return 1;
    }
}

3.2 装饰器工厂

装饰器是一个函数,有时候有很多功能相似的装饰器,我们可以使用一个装饰器工厂,根据传入的参数返回所需的装饰器。

代码语言:javascript复制
function enumerable(value: boolean) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = value;
    };
}

class Demo {
    public name: string;

    constructor(name) {
        this.name = name;
    }

    // 类的方法,默认是非 enumerable 的
    @enumerable(true)
    show() {
        console.log(this.name);
    }
}

console.log(Object.keys(Demo.prototype)); // ["show"]

3.3 装饰器组合

多个装饰器可以同时应用到一个声明上,就像下面的示例:

  • 书写在同一行上:
代码语言:javascript复制
@f @g x
  • 书写在多行上:
代码语言:javascript复制
@f
@g
x

当多个装饰器应用于一个声明上,它们求值方式与复合函数相似。在这个模型下,当复合fg时,复合的结果(fg)(x)等同于f(g(x))。

同样的,在 TypeScript 里,当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 由上至下依次对装饰器表达式求值。
  2. 求值的结果会被当作函数,由下至上依次调用。

如果我们使用装饰器工厂的话,可以通过下面的例子来观察它们求值的顺序:

代码语言:javascript复制
function f() {
    console.log("f(): evaluated");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("f(): called");
    }
}

function g() {
    console.log("g(): evaluated");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("g(): called");
    }
}

class C {
    @f()
    @g()
    method() {}
}

在控制台里会打印出如下结果:

代码语言:javascript复制
f(): evaluated
g(): evaluated
g(): called
f(): called

3.4 装饰器求值

类中不同声明上的装饰器将按以下规定的顺序应用:

  1. 参数装饰器,然后依次是方法装饰器,访问符装饰器,或属性装饰器应用到每个实例成员。
  2. 参数装饰器,然后依次是方法装饰器,访问符装饰器,或属性装饰器应用到每个静态成员。
  3. 参数装饰器应用到构造函数。
  4. 类装饰器应用到类。

4. 小结

装饰器模式是一个非常重要的设计模式,在很多场景下可以用来替代继承,增加代码的可维护性。

参考

装饰器模式 | 菜鸟教程

图解23种设计模式(TypeScript版)——前端必修内功心法

装饰模式_百度百科

装饰者模式

从ES6重新认识JavaScript设计模式: 装饰器模式

ES6装饰器Decorator基本用法

JavaScript设计模式(五)-装饰器模式

装饰器 · TypeScript中文网

book - 大话设计模式

ts/decorators.html

js基石之---es7的decorator修饰器

ES7 Decorator 装饰器 | 淘宝前端团队

ES7装饰器 Decorator

Decorators in ES7

tc39/proposal-decorators

精读TC39 与 ECMAScript 提案

探寻 ECMAScript 中的装饰器

TypeScript 中的 Decorator & 元数据反射:从小白到专家(部分 IV)

TypeScript学习笔记(九):装饰器(Decorators)

JavaScript Reflect Metadata 详解

详解学习Reflect Metadata

装饰器与元数据反射(4)元数据反射

0 人点赞