装饰者模式

184 阅读7分钟

装饰者模式的作用

为类或方法增添特性或职责

用简单的例子说明,假如需要为一个类或普通函数添加功能,我们可以选择

  • 直接修改代码
  • 用一个函数再嵌套一层,相当于再包装一下,比如下面的例子
  • 如果是类,可以通过派生子类进行拓展
function f1 (name) { console.log(name) }

// 通过b函数为a函数添加功能,进行包装
function f2() {
    return (name) => {
        f1(name);
        console.log('f1 done'); // 这里添加的功能,就是打印'done'
    }
}
// 调用b函数对a函数添加功能
const f3 = f2();

// 使用增强后的函数
f3('zhangsan');

具有装饰作用的,但不具备通用性,一般不认为是装饰器。比如上面的b函数就不算是装饰器。

假如把f2函数再抽象一下使得它可以公用,为任意或特定某些函数增强功能,它就变成了装饰器。如下:

function f2(fn) {
    return () => {
        fn.apply(this, arguments);
        console.log(`${fn.name} done`); // 这里添加的功能,就是打印'done'
    }
}

const test1 = (name) => { console.log('name:', name) }
const test2 = () => {}

f2(test1)('zhangsan');
f2(test2)();

如果使用非装饰器的其他的办法(比如直接改源码),可能会存在功能耦合太高、不灵活、不方便测试等弊端

装饰者模式的特点

  • 可以嵌套装饰,装饰后,可以再次对其装饰
  • 透明,使用者自行选择使用哪些装饰,很容易了解增强了什么功能,并且可以按之前方式继续使用装饰后的内容
  • 动态,增强功能不是固定的,不是写死的,使用者可以自由的在合适的时机对函数进行合适的装饰
  • 装饰者可以直接改变被装饰者的内容,也可以返回一个新的东西替代被装饰者

装饰者模式的应用

装饰器的应用例子:处理异常、控制函数的执行时机(防抖节流)、获取函数的执行时长等等。

处理异常

这里使用function而不是箭头函数,并且需要使用apply来调用fn,是因为参数fn如果是普通函数的话需要对其改变this指向。将fn的this指向始终设为装饰器的this并不总是对的,这里只是说明存在this这个问题。

// 定义一个错误捕获装饰器
function errorDealDecorator(fn) {
  return function() {
    try {
      fn.apply(this, arguments);
    } catch (error) {
      // Do something
      console.warn('errorDecorator: ', error);
      // 将错误储存到localStorage
      localStorage.setItem(`[${fn.name}]functionError`, JSON.stringify(error.message));
    }
  };
};

// 下方两个函数是被装饰对象
const o1 = () => {
  throw new Error('o1 error')
};
function o2(name1, name2) {
  console.log('o2:::', `${name1}${name2}`);
};

// 对上面函数进行装饰,得到新的函数
const f1 = errorDealDecorator(o1);
const f2 = errorDealDecorator(o2);

// 使用装饰后的得到函数
f1();
f2('zhangsan', 'lisi');

EcmaScript装饰器提案

基本介绍

提案内容:github.com/tc39/propos…

(tc39 是EcmaScript里面的一个专门管理 EcmaScript 提案与标准的一个小组 )

目前在Stage: 3阶段,还没有成为EcmaScript标准。

根据提案内容,可以应用于以下场景

  • 类(class)
  • 类属性(公共、私有、静态)
  • 类方法(公共、私有、静态)
  • 类访问器(公共、私有、静态)

该提案同时还增加了新的class 元素: 自动访问器 (Class auto accessors)。如下,这里暂不细究。

class C {
  accessor x = 1; // 自动访问器前面有accessor关键字
}

// 相当于是:
class C {
  #x = 1;

  get x() {
    return this.#x;
  }

  set x(val) {
    this.#x = val;
  }
}

装饰器的类型声明

type Decorator = (value: Input, context: {
  kind: string;
  name: string | symbol;
  access: {
    get?(): unknown;
    set?(value: unknown): void;
  };
  private?: boolean;
  static?: boolean;
  addInitializer?(initializer: () => void): void;
}) => Output | void;

从类型声明可以看出:

  • 装饰器的参数接收一个被装饰对象和一个context对象作为参数,返回一个新的东西(取决于被装饰对象的类型)或不返回任何东西
  • 装饰器的参数和返回都是固定的,不能为其增添额外参数或改变参数顺序。但是可以再套一层,请看后面的例子。

context执行上下文对象(可以先跳过这块内容,回过头来再看)

装饰器都有一个名叫context参数,从名字可以看出,它是一个执行上下文对象,里面有一些被装饰者的的信息。

  • kind:可以理解为是被装饰的类型,它可能是这些值中的一个:'class'(类)、'method'(方法)、'getter'(访问器属性)、'setter'(访问器属性)、'field'(属性)、'accessor'(访问器属性,被装饰的是自动访问器才是这个值)
  • name:被装饰对象的名称,比如被装饰对象是class,那他就是class的名称,如果被装饰对象是属性,那它的值是属性名称
  • access:一个对象,里面是被装饰对象对应的方法,比如被装饰的对象是属性,那对象里面有get和set方法
  • static:被装饰对象是否为静态类属性
  • private:被装饰对象是否为私有类属性
  • addInitializer:注册初始化钩子的函数,参数是一个函数。执行addInitializer函数,目的是把参数(函数)放到一个池子里面,然后在初始化后池子里面的函数会被执行。比如对一个class进行装饰,装饰完成后(装饰器执行完毕后),池子里面的函数会被执行。不同的被装饰对象执行时机略有差异。

类装饰器

类型声明

type ClassDecorator = (value: Function, context: {
  kind: "class";
  name: string | undefined;
  addInitializer(initializer: () => void): void;
}) => Function | void;

简单的例子

// 定义一个类装饰器
const component = (targetClass, context) => {
  targetClass.name = 'toast'; // 给类增加一个静态属性
  targetClass.prototype.clear = function() {}; // 给原型添加一个方法
};

// 使用类装饰器
@component
class Toast {}

// 使用效果相当于下面这样
// class Toast {}
// Toast = component(Toast, context);

// 验证
console.log(Toast.name);
console.log((new Toast()).clear);

装饰器可以封装到一个工厂函数里面,这样就能给装饰器传递额外的参数

// 定义一个类装饰器工厂函数(高阶函数)
const component = ({ name }) => {
    // 在这个地方,还可以进行很多的处理,比如根据参数,返回不同的装饰器
    
    // 严格来说,返回的东西才是装饰器
    return function(targetClass, context) {
        targetClass.name = name; // 给类增加一个静态属性
        targetClass.prototype.clear = function() {}; // 给原型添加一个方法
    }
};

// 使用类装饰器,可以传参了
@component({ name: 'toast' })
class Toast {}

// 验证
console.log(Toast.name);
console.log((new Toast()).clear);

属性装饰器

类型声明

type ClassFieldDecorator = (value: undefined, context: {
  kind: "field";
  name: string | symbol;
  access: { get(): unknown, set(value: unknown): void };
  static: boolean;
  private: boolean;
}) => (initialValue: unknown) => unknown | void;

*注意它的返回,它只能返回一个函数,或者不返回任何东西

简单的例子

// 定义一个属性装饰器
const prop = function (value, context) {
    return function(initValue) {
        if (typeof initValue !== 'string') {
            // 校验属性值类型
            console.error(`【${content.name}】期望类型是字符串,得到值的类型却是${typeof initValue}`);
        }
        
        return initValue;
    }
}

// 使用属性装饰器
class Toast {
    @prop value = 1;
}

// 使用效果相当于下面这样:
// class Toast {
//     value = prop.call(this, 1);
// }

// 验证
console.log(new Toast());

跟前面class装饰器一样,也可以再套一层,通过参数进行更多类型的校验。这里就不详细举例了。

// 尝试实现一个装饰器,使得可以像下面这样使用
class Toast {
    @prop({ type: String }) value = 1;
}

类方法装饰器

type ClassMethodDecorator = (value: Function, context: {
  kind: "method";
  name: string | symbol;
  access: { get(): unknown };
  static: boolean;
  private: boolean;
  addInitializer(initializer: () => void): void;
}) => Function | void;
// 定义一个方法装饰器
const finishTime = function (value, context) {
    return function (...args) {
        // 为目标函数添加一个打印执行时长的功能
        console.time(`${context.name} finish time`);
        const ret = value.call(this, ...args);
        console.timeEnd(`${context.name} finish time`);
        return ret;
    };
}

// 使用方法装饰器
class Toast {
    @finishTime
    dealData(data1, data2) {
        console.log(data1, data2);
        return data1 + data2;
    }
    
    // 嵌套使用装饰器,在提案中没看到关于这个的描述,暂不细究
    // @finishTime1
    // @finishTime2
    // test(data1) {}
}

// 使用效果大致相当于下面这样:
// class Toast {
//    dealData(data1, data2) {}
// }
// Toast.prototype.dealData = finishTime(Toast.prototype.dealData, context) ?? Toast.prototype.dealData;


// 验证
console.log((new Toast()).dealData(1, 2));

跟前面的装饰器一样,也可以再套一层,通过参数做更多的功能。这里就不详细举例了。

// 尝试实现一个装饰器,使得可以像下面这样使用
class Toast {
    // 如果函数dealData在200毫秒内执行完毕,则打印一个信息
    @finishTime(200)
    dealData(data1, data2) {
        console.log(data1, data2);
        return data1 + data2;
    }
}

类访问器装饰器

类型声明

type ClassGetterDecorator = (value: Function, context: {
  kind: "getter";
  name: string | symbol;
  access: { get(): unknown };
  static: boolean;
  private: boolean;
  addInitializer(initializer: () => void): void;
}) => Function | void;

type ClassSetterDecorator = (value: Function, context: {
  kind: "setter";
  name: string | symbol;
  access: { set(value: unknown): void };
  static: boolean;
  private: boolean;
  addInitializer(initializer: () => void): void;
}) => Function | void;

类访问器装饰器与类方法装饰器是很相似的,因为访问器的本质也是函数。

所以可以用上面的类方法装饰器例子改一下,支持访问器属性。

// 定义一个装饰器
const finishTime = function (value, context) {
    // 判断是否是类方法或访问器
    if (context.kind === 'method' || context.kind === 'getter' || context.kind === 'setter') {
        return function (...args) {
            // 为目标函数添加一个打印执行时长的功能
            console.time(`${context.name} finish time`);
            const ret = value.call(this, ...args);
            console.timeEnd(`${context.name} finish time`);
            return ret;
        }
    }
}

// 使用装饰器
class Toast {
    total = 0;
    
    @finishTime
    set count(data1) {
        console.log(data1);
        this.total = data1 * 2;
    }
}

// 验证
console.log((new Toast()).count = 100);

自动访问器装饰器

这里直接搬运提案中的例子,因为还没完全明白自动访问器的作用,就先不折腾了

function logged(value, { kind, name }) {
  if (kind === "accessor") {
    let { get, set } = value;

    return {
      get() {
        console.log(`getting ${name}`);

        return get.call(this);
      },

      set(val) {
        console.log(`setting ${name} to ${val}`);

        return set.call(this, val);
      },

      init(initialValue) {
        console.log(`initializing ${name} with value ${initialValue}`);
        return initialValue;
      }
    };
  }

  // ...
}

class C {
  @logged accessor x = 1;
}

let c = new C();
// initializing x with value 1
c.x;
// getting x
c.x = 123;
// setting x to 123

其他

差异

最新的ES提案、Babel支持的装饰器特性、ts支持的装饰器特性、浏览器支持的特性(如果有的话),是存在差异的。ts提案在成为标准之前,还可能会被多次修改甚至删除。目前tabel、ts、各浏览器似乎都还没有完整实现ES最新的提案,所以上面的例子可能也没办法实际运行起来。

Babel支持的装饰器特性,是根据旧提案实现的。

Typescript在很久之前特地为装饰器做了支持,那时候ES提案还处于 Stage-0阶段。

而TypeScript 原则上只会对 Stage-3 以上的语言提案提供支持,可以说,当时ts因为某些原因特地加了装饰器特性,这也进一步导致差异的产生。 ts装饰器文档:www.tslang.cn/docs/handbo…

但是并不会对学习和使用装饰者模式产生很大的干扰,因为不管何种形式,它的核心思想和使用方式,并没有很大区别