Skip to main content

装饰器

原理

TypeScript 装饰器是一种特殊类型的声明,它可以附加到类声明、方法、属性或参数上,以修改类的行为

装饰器的实现原理其实是基于 JavaScript 的原型和闭包机制。

装饰器函数会接收被装饰的函数或类作为参数,并返回一个新的函数或类。在返回的函数或类中,可以对被装饰的函数或类进行修改或增强。

然后通过原型链和闭包机制,将返回的新函数或类与原函数或类关联起来。

在使用装饰器时,编译器会将装饰器函数转换为 JavaScript 代码,并插入到被装饰的函数或类中,从而实现装饰器的功能。

decorator 是一个函数,用来修改类的行为

修饰器在什么时候执行? 装饰器本质就是编译时执行的函数。

原型

在 JavaScript 中,每个对象都有一个原型链,通过原型链可以访问到该对象的原型。在使用装饰器时,我们通常会创建一个新的函数或类,并将其与被装饰的函数或类关联起来。这种关联通常是通过原型链实现的。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Method ${propertyKey} called with args: ${JSON.stringify(args)}`);
const result = originalMethod.apply(this, args);
console.log(`Method ${propertyKey} returned ${JSON.stringify(result)}`);
return result;
};
return descriptor;
}

class Calculator {
@log
add(x: number, y: number) {
return x + y;
}
}

在上面的例子中,我们定义了一个名为 log 的装饰器函数,并将其应用到 add 方法上。当 TypeScript 编译器编译这段代码时,它会将 @log 装饰器转换为以下代码:

class Calculator {
add(x: number, y: number) {
// ...
}
}
Calculator.prototype.add = log(Calculator.prototype, "add", Object.getOwnPropertyDescriptor(Calculator.prototype, "add"));

可以看到,编译器将 log 函数应用到 add 方法上,并通过原型链将 log 函数与 add 方法关联起来。

闭包

在 JavaScript 中,函数可以作为返回值返回,也可以作为参数传递。在使用装饰器时,我们通常会定义一个装饰器函数,并返回一个新的函数或类,用于对被装饰的函数或类进行增强或修改。这种返回值通常是一个闭包。

例如,下面是一个简单的装饰器示例:

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`Method ${propertyKey} called with args: ${JSON.stringify(args)}`);
const result = originalMethod.apply(this, args);
console.log(`Method ${propertyKey} returned ${JSON.stringify(result)}`);
return result;
};
return descriptor;
}

在上面的例子中,log 函数返回一个闭包,这个闭包包含了对 originalMethod 变量的引用。这个闭包在被调用时,可以访问到 originalMethod 变量,并对其进行修改或增强。通过闭包,我们可以实现对被装饰的函数或类进行动态修改的功能。

定义

装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

注意:由于存在函数提升,使得修饰器不能用于函数

类是不会提升的,所以就没有这方面的问题。

// 如例子所示,给函数foo()定义了修饰器@add,作用是想将counter++
// 预计的结果counter为1,但实际上却还是为0
var counter = 0;

var add = function () {
counter++;
};

@add
function foo() {
}


// 原因:
// 定义的函数foo()会被提升至最上层,定义的变量counter和add也会被提升,效果如下:
@add
function foo() {
}

var counter;
var add;

counter = 0;

add = function () {
counter++;
};

解析和理解注解

饰器其实是个很“黑科技”的东西,只要运行时修改类能实现的功能,都能用装饰器实现。所以,在这里说一下我对装饰器的抽象理解,也是我判断什么时候该使用装饰器的选型标准: 装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。 装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。 通俗的理解可以认为就是在原有代码外层包装了一层处理逻辑。

js的整个部署运行过程,可以分为构建期和运行期。而装饰器在构建期和运行期之间,给开发者开辟了一个“后构建期”、或者叫“前运行期”的空间,允许你在业务真正开始运行之前,加工你的代码。

可能有些时候,我们会对传入参数的类型判断、对返回值的排序、过滤,对函数添加节流、防抖或其他的功能性代码,基于多个类的继承,各种各样的与函数逻辑本身无关的、重复性的代码。

所以,对于装饰器,可以简单地理解为是非侵入式的行为修改。

java中的注解 和 ts装饰器

Java的Annotation因为相当于多加了一层(标记 + 处理逻辑),是一把双刃剑。好处是,在不动代码的情况下你可以通过外部配置来修改程序的行为。比如给一个函数打上@Test标。如果通过UT框架运行,这些打标的函数会被当作是测试用例;但如果外部直接用普通的main启动,这些@Test就会没有一样,不会影响代码本身的逻辑。但反过来,也容易引来一些问题。比如有的时候,你很难知道那个根据标记执行的逻辑是不是真的跑了。也许你哪里配置拼错一个字,或者classpath少依赖一个包,就造成那个逻辑并没有真的执行。这时从表面上也许很难看出来出错了。

装饰器是一个有逻辑的,可以执行的函数,只不过其写法有些特殊要求;而Java里面的Annotation只是个标记,需要其他代码来“根据标记执行“。 但java世界里做类似decorator的事情,希望动态魔改一个函数的行为,可以用动态代理或者AOP。

Java中的注解则不同,它是从语言层面为代码中的类,函数,字段增加一些运行时可以读到的元数据,而注解的提供者要在运行时对这些元数据进行读取,并做相应的处理。 Java语言层面只提供定义注解类,编译时解析注解并保存到类文件中,在运行时提供反射机制供注解开发者读取这些元数据。

但注解的具体用法就八仙过海,各显神通了,不限于装饰器模式。

装饰器执行时机

修饰器对类的行为的改变,是代码编译时发生的(不是TypeScript编译,而是js在执行机中编译阶段),而不是在运行时。这意味着,修饰器能在编译阶段运行代码。也就是说,修饰器本质就是编译时执行的函数。

在Node.js环境中模块一加载时就会执行

装饰器本身其实就是一个函数,理论上忽略参数的话,任何函数都可以当做装饰器使用。

使用

使用多个装饰器,在这个模型下,当复合Contorller和Contorller1时,复合的结果Contorller(Contorller1(Admin))。

function Contorller (target) {  
// 可以通过target(类的构造函数)去做些事情
}

@Contorller @Contorller1
class Admin {}

装饰器的类型

  • 类装饰器
  • 访问器装饰器
  • 属性装饰器
  • 方法装饰器
  • 参数装饰器,但是没有函数装饰器(function)

根据装饰器是否有参数

  • 无参装饰器(一般装饰器)
  • 有参装饰器(装饰器工厂)

根据装饰器是否有参数分类:工厂函数的装饰器

函数,它返回一个表达式,以供装饰器在运行时调用。也可以说是一个函数柯里化

function Contorller (path) {
// 返回一个装饰器函数
return function (target) {
target.prototype.root = path
}
}

@Contorller('//www.test.com')
class Admin {
getRoot () {
console.log(this.root)
}
}

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

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

类装饰器

类装饰器在类声明之前被声明(紧靠着类声明)。类装饰器应用于类构造函数,可以用来监视,修改或替换类定义。

当Admin类被声明的时候,会执行Contorller装饰器函数,然后我们在装饰器函数内向构造函数的原型上添加了一个getName方法;

当类被实例化后,当然就可以去调用我们通过装饰器注入进去的方法

// 定义一个类装饰器
function Contorller (target) {
target.prototype.getName = function () {
console.log('test')
}
}

// 使用类装饰器
@Contorller
class Admin {}

// 实例化类
const admin = new Admin()
admin.getName() // 打印 test

应用于类构造函数,其参数是类的构造函数。注意class并不是像Java那种强类型语言中的类,而是JavaScript构造函数的语法糖。

function helloWord(target: any) {
console.log('hello Word!');
}

@helloWord
class HelloWordClass {

}

// 使用tsc编译后,执行命令node helloword.js,输出结果如下:hello Word!
function addAge(args: number) {
return function (target: Function) {
target.prototype.age = args;
};
}

@addAge(18)
class Hello {
name: string;
age: number;
constructor() {
console.log('hello');
this.name = 'yugo';
}
}

console.log(Hello.prototype.age);//18
let hello = new Hello();

console.log(hello.age);//18

类修饰器:是一个对类进行处理的函数 它的第一个参数target就是函数要处理的目标类

@addSkill("hello world")
class Person { }
function addSkill(text) {
return function(target) {
target.say = text;
}
}

console.log(Person.say) //'hello world'

一个参数:

第一个参数 target,指向类本身。

function testable(target) {
target.isTestable = true;
}

@testable
class Example {}
Example.isTestable; // true

多个参数——嵌套实现

function testable(isTestable) {
return function(target) {
target.isTestable=isTestable;
}
}

@testable(true)
class Example {}
Example.isTestable; // true

方法装饰器

它会被应用到方法的 属性描述符上,可以用来监视,修改或者替换方法定义。 方法装饰会在运行时传入下列3个参数: 3个参数:target(类的原型对象)、name(修饰的属性名)、descriptor(该属性的描述对象)

  • target: 类的原型对象,上例是Person.prototype
  • key: 所要修饰的属性名 name
  • descriptor: 该属性的描述对象
function addAge(constructor: Function) {
constructor.prototype.age = 18;
}
function method(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log(target);
console.log("prop " + propertyKey);
console.log("desc " + JSON.stringify(descriptor) + "\n\n");
};
@addAge
class Hello{
name: string;
age: number;
constructor() {
console.log('hello');
this.name = 'yugo';
}
@method
hello(){
return 'instance method';
}
@method
static shello(){
return 'static method';
}
}

我们得到的结果是:
Hello { hello: [Function] }
prop hello
desc {"writable":true,"enumerable":true,"configurable":true}
{ [Function: Hello] shello: [Function] }
prop shello
desc {"writable":true,"enumerable":true,"configurable":true}

假如我们修饰的是 hello 这个实例方法,第一个参数将是原型对象,也就是 Hello.prototype。 假如是 shello 这个静态方法,则第一个参数是构造器 constructor。第二个参数分别是属性名,第三个参数是属性修饰对象。

修饰器执行顺序: 若是同一个方法上有多个修饰器,会像剥洋葱一样,先从外到内进入,然后由内向外执行。由外向内进入,由内向外执行。

// 外层修饰器dec(1)先进入,但是内层修饰器dec(2)先执行。
class Person {
constructor() {}
@dec(1)
@dec(2)
name() {
console.log('test')
}
}
function dec(id) {
console.log('out', id);
return function(target, key, descriptor) {
console.log(id);
}
}

var person = new Person()
person.name()
//结果
out 1
out 2
2
1
test

方法装饰器声明在一个方法的声明之前(紧靠着方法声明)。它会被应用到方法的 属性描述符上,可以用来监视,修改或者替换方法定义。方法装饰器表达式会在运行时当作函数被调用:

function Get (path) {
return function (target, methodName, descriptor) {
/* 这里是可以改写方法的
let fn = attributes.value
attributes.value = function () {
console.log(`改写了了${methodName}方法`)
将path传入
fn.call(target, path)
}
*/
console.log(target)
console.log(`method:${methodName}`)
console.log(`descriptor:${JSON.stringify(descriptor)}`)
}
}

class Admin {
@Get('/setname')
static setName () {}
@Get('/getName')
getName () {}
}

// 输出结果
/*
Admin { getName: [Function] }
method:getName
descriptor:{"writable":true,"enumerable":true,"configurable":true}

{ [Function: Admin] setName: [Function] }
method:setName
desc {"writable":true,"enumerable":true,"configurable":true}
*/

根据打印的结果,可以看到,如果装饰的是静态方法,第一个参数将是一个构造函数;如果装饰的不是一个静态方法,那么第一个参数将会是一个原型对象。 装饰器的实现使用了ES5的 Object.defineProperty 方法,这三个参数也和这个方法的参数一致。装饰器的本质就是一个函数语法糖,通过Object.defineProperty来修改类中一些属性,descriptor参数也是一个对象,是针对key属性的描述符,里面有控制目标对象的该属性是否可写的writable属性等

访问器装饰器

访问器装饰器应用于访问器的属性描述符,可用于观察,修改或替换访问者的定义。 访问器装饰器不能在声明文件中使用,也不能在任何其他环境上下文中使用(例如在声明类中)。 注意: TypeScript不允许为单个成员装饰get和set访问器。相反,该成员的所有装饰器必须应用于按文档顺序指定的第一个访问器。这是因为装饰器适用于属性描述符,它结合了get和set访问器,而不是单独的每个声明。

访问器装饰器表达式会在运行时当作函数被调用,传入下列3个参数: 1.对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。 2.成员的名字。 3.成员的属性描述符。

下面是使用了访问器装饰器(@configurable)的例子,应用于Point类的成员上:
class Point {
private _x: number;
private _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}

@configurable(false)
get x() { return this._x; }

@configurable(false)
get y() { return this._y; }
}

function configurable(value: boolean) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.configurable = value;
};
}

访问器装饰器声明在一个访问器的声明之前(紧靠着访问器声明)。访问器装饰器应用于访问器的属性描述符并且可以用来监视,修改或替换一个访问器的定义。

装饰器表达式会在运行时当作函数被调用,它的参数与方法访问器参数一样

class Point {
private _x: number;
private _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}

@configurable(false)
get x() { return this._x; }

@configurable(false)
get y() { return this._y; }
}

function configurable(value: boolean) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
descriptor.configurable = value;
};
}

在声明x,y访问器的时候,调用了configurable装饰器,通过装饰器设置了描述符对象中configurable属性的值

属性装饰器

属性装饰器表达式会在运行时当作函数被调用,传入下列2个参数: 1、对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。 2、成员的名字。

function log(target: any, propertyKey: string) {
let value = target[propertyKey];
// 用来替换的getter
const getter = function () {
console.log(`Getter for ${propertyKey} returned ${value}`);
return value;
}
// 用来替换的setter
const setter = function (newVal) {
console.log(`Set ${propertyKey} to ${newVal}`);
value = newVal;
};
// 替换属性,先删除原先的属性,再重新定义属性
if (delete this[propertyKey]) {
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
}
class Calculator {
@log
public num: number;
square() {
return this.num * this.num;
}
}
let cal = new Calculator();
cal.num = 2;
console.log(cal.square());
// Set num to 2
// Getter for num returned 2
// Getter for num returned 2
// 4

属性装饰器声明在一个属性声明之前(紧靠着属性声明)。

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

属性装饰器表达式会在运行时当作函数被调用,它有两个参数:

  1. 对于静态属性来说是类的构造函数,对于原型属性来说是类的原型对象。
  2. 属性的名字。
function DefaultValue(value: string) {
return function (target: any, propertyName: string) {
target[propertyName] = value;
}
}

class Hello {
@DefaultValue("world")
greeting: string;
}
console.log(new Hello().greeting); // 输出: world

在上面代码中,我们给greeting属性添加了一个工厂装饰器DefaultValue,装饰中通过第一参数原型对象和第二参数属性名称给greeting属性做了赋值操作,所以在最后就打印出了world。

方法参数装饰器

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

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 参数的名字。
  3. 参数在函数参数列表中的索引。
const parseConf = [];
class Modal {
@parseFunc
public addOne(@parse('number') num) {
console.log('num:', num);
return num + 1;
}
}

// 在函数调用前执行格式化操作
function parseFunc(target, name, descriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
for (let index = 0; index < parseConf.length; index++) {
const type = parseConf[index];
console.log(type);
switch (type) {
case 'number':
args[index] = Number(args[index]);
break;
case 'string':
args[index] = String(args[index]);
break;
case 'boolean':
args[index] = String(args[index]) === 'true';
break;
}
return originalMethod.apply(this, args);
}
};
return descriptor;
}

// 向全局对象中添加对应的格式化信息
function parse(type) {
return function (target, name, index) {
parseConf[index] = type;
console.log('parseConf[index]:', type);
};
}
let modal = new Modal();
console.log(modal.addOne('10')); // 11

参数装饰器声明在一个参数声明之前(紧靠着参数声明)。参数装饰器应用于类构造函数或方法声明。 参数装饰器只能用来监视一个方法的参数是否被传入。

参数装饰器表达式会在运行时当作函数被调用,它有三个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 参数的名字。
  3. 参数在函数参数列表中的索引。
function PathParam(paramName: string) {
return function (target, methodName: string, paramIndex: number) {
!target.meta && (target.meta = {});
target.meta[paramIndex] = paramName;
}
}

class HelloService {
constructor() { }
getUser( @PathParam("userId") userId: string) { }
}

console.log(HelloService.prototype.meta); // {'0':'userId'}

在getUser方法中使用了PathParam装饰器,在PathParam装饰器中,通过原型对象去设置了一个meta对象,然后对这个meta对象中通过参数下标和参数名称去添加键值,这样就形成了一个参数map。