Bootstrap

typescript装饰器

一、什么是装饰器?

TypeScript 中的装饰器是一种特殊类型的声明,它可以被附加到 类声明、方法、访问器、属性或参数 上,从而在不修改原始类定义的情况下,动态地修改或增强类和类成员 的功能。

装饰器本质上是一个函数,它在运行时被调用,接收一些特定的参数(如目标类的构造函数、属性描述符等),并返回一个修改后的版本或者原始版本。

1. 装饰器的语法

在语法上,装饰器有如下几个特征。

  1. 第一个字符(或者说前缀)是 @,后面是一个表达式

  2. @ 后面的表达式,必须是一个函数(或者执行后可以得到一个函数)。

  3. 这个函数接受所修饰对象的一些相关值作为参数。

  4. 这个函数要么不返回值,要么返回一个新对象取代所修饰的目标对象。

装饰器有多种形式,基本上只要在 @ 符号后面添加表达式都是可以的。下面都是合法的装饰器。

@myFunc
@myFuncFactory(arg1, arg2)
​
@libraryModule.prop
@someObj.method(123)
​
@(wrap(dict['prop']))

注意,@ 后面的表达式,最终执行后得到的应该是一个函数。 相比使用子类改变父类,装饰器更加简洁优雅,缺点是不那么直观,功能也受到一些限制。所以,装饰器一般只用来为类添加某种特定行为。

2. 一个简单的装饰器

// typescript
function logClz(value : any, constructor : any) {
    console.log(`create a instance of ${constructor.name}`);
}
​
@logClz
class Student {
    constructor() {
        console.log('Student constructor called');
    }
}
​
const stone = new Student();
// 输出: 
// create a instance of Student
// Student constructor called

Student 在执行前会先执行装饰器 logClz,并且会向装饰器自动传入参数。

3. 装饰器的版本

TypeScript 从早期开始,就支持装饰器。但是,装饰器的语法后来发生了变化。ECMAScript 标准委员会最终通过的语法标准,与 TypeScript 早期使用的语法有很大差异。

目前,TypeScript 5.0 同时支持两种装饰器语法。标准语法可以直接使用,传统语法需要打开 --experimentalDecorators 编译参数。

> tsc --target ES5 --experimentalDecorators

这里只谈标准语法。

4. 装饰器的结构

装饰器函数的类型定义如下。

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

上面代码中,Decorator 是装饰器的类型定义。它是一个函数,使用时会接收到 valuecontext 两个参数。

  • value:所装饰的对象。

  • context:上下文对象,TypeScript 提供一个原生接口 ClassMethodDecoratorContext,描述这个对象。

function decorator(
    value : any,
    context : ClassMethodDecoratorContext
){
    // ...
}

上面是一个装饰器函数,其中第二个参数 context 的类型就可以写成 ClassMethodDecoratorContext

context 对象的属性,根据所装饰对象的不同而不同,其中只有两个属性(kindname)是必有的,其他都是可选的。

  1. kind:字符串,表示所装饰对象的类型,可能取以下的值,这表示一共有六种类型的装饰器。

    • class

    • field

    • method

    • getter

    • setter

    • accessor

  2. name:字符串或者 Symbol 值,所装饰对象的名字,比如类名、属性名等。

  3. addInitializer():函数,用来添加类的初始化逻辑。以前,这些逻辑通常放在构造函数里面,对方法进行初始化,现在改成以函数形式传入 addInitializer() 方法。注意,addInitializer() 没有返回值。

  4. private:布尔值,表示所装饰的对象是否为类的私有成员。

  5. static:布尔值,表示所装饰的对象是否为类的静态成员。

  6. access:一个对象,包含了某个值的 getset 方法。

二、类装饰器

类装饰器在类声明之前声明,用于修改类的行为或添加额外的职责。它们接收类的构造函数作为参数,并可以修改类定义或者返回一个新的类来替换原始类。

1. 类装饰器的类型描述

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

类装饰器接受两个参数:value(当前类本身)和 context(上下文对象)。其中,context 对象的 kind 属性固定为字符串 class

2. 类装饰器一般用来对类进行操作,可以不返回任何值

请看下面的例子:

function Greeter(value, context){
    if(context.kind === 'class'){
        value.prototype.greet = function(){
            console.log("Hello, ", this.name , "!");
        }
    }
}
​
@Greeter
class User{
    name : string;
    constructor(name : string){
        this.name = name;
    }
}
​
const lao8 = new User('老八');
lao8['greet']();    // 必须这样调用,否则,无法调用类中的方法

上面示例中,类装饰器 @Greeter 在类 User 的原型对象上,添加了一个 greet() 方法,实例就可以直接使用该方法。

3. 类装饰器可以返回一个函数,替代当前类的构造方法

function countInstances(value, context) {
    let instanceCount = 0;
    let wrapper = function (...args: any[]) {
        let instance = new value(...args);
        instanceCount++;
        value.prototype.count = instanceCount;
        return instance;
    } as typeof value;
    wrapper.prototype = value.prototype;    // A
    return wrapper;
}
​
@countInstances
class User { }
​
const lao8 = new User();
console.log(lao8['count']);         // 1
const stone = new User();
console.log(lao8['count']);         // 2
console.log(lao8 instanceof User);  // true

上面示例中,类装饰器 @countInstances 返回一个函数,替换了类 User 的构造方法。新的构造方法实现了实例的计数,每新建一个实例,计数器就会加一,并且在原型中添加 count 属性,表示实例的个数。

注意,上例为了确保新构造方法继承定义在 User 的原型之上的成员,特别加入行 A,确保两者的原型对象是一致的。否则,新的构造函数 wrapper 的原型对象,与 User 不同,通不过 instanceof 运算符。

4. 类装饰器也可以返回一个新的类,替代原来所装饰的类

function countInstances(value, context) {
    let instanceCount = 0;
    return class clz extends value {
        constructor(...args){
            super(args);
            instanceCount++;
            value.prototype.count = instanceCount;
        }
    };
}

@countInstances
class User { }

const lao8 = new User();
console.log(lao8['count']);         // 1
const stone = new User();
console.log(lao8['count']);         // 2
console.log(lao8 instanceof User);  // true

上面示例中,@countInstances 返回一个 User 的子类。

5. addInitializer() 方法

类装饰器的上下文对象 contextaddInitializer() 方法,用来定义一个类的初始化函数,在类完全定义结束 后执行。

function clzInit(value : Function, context : any) : any{
    context.addInitializer(() => {
        console.log('initialized');
    });
}

@clzInit
class User {
    constructor(){
        console.log('User()');
    }
}

let lao8 = new User();
let stone = new User();

上面示例中,类 User 定义完成后,会自动执行类装饰器 @clzInit 给出的初始化函数。

三、方法装饰器

方法装饰器在方法声明之前声明,可以修改方法的行为或添加额外的操作。

1. 方法装饰器类型描述

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

根据上面的类型,方法装饰器是一个函数,接受两个参数:valuecontext。</p>

参数`value`是方法本身,参数`context`是上下文对象,有以下属性。

  • `kind`:值固定为字符串`method`,表示当前为方法装饰器。
  • `name`:所装饰的方法名,类型为字符串或 Symbol 值。
  • `static`:布尔值,表示是否为静态方法。该属性为只读属性。
  • `private`:布尔值,表示是否为私有方法。该属性为只读属性。
  • `access`:对象,包含了方法的存取器,但是只有`get()`方法用来取值,没有`set()`方法进行赋值。
  • `addInitializer()`:为方法增加初始化函数。

2. 基本用法

方法装饰器会改写类的原始方法,实质等同于下面的操作。

function trace(value : Function, context : any) : any{
    console.log('trace()');
    return value;
}

class C{
    @trace
    toString(){
        return 'C';
    }
}
let c = new C();
console.log(c.toString());

// `@trace` 等价于
// C.prototype.toString = trace(C.prototype.toString);

上面示例中,@trace是方法toString()的装饰器,它的效果等同于最后一行对toString()的改写。</p>

3. 返回函数

如果方法装饰器返回一个新的函数,就会替代所装饰的原始函数。

function replaceMethod(value : Function, context : ClassMethodDecoratorContext) : 
    (() => string) | void {
    return function(){
        return `How are you, ${this.name}?`;
    }
}

class Person {
    name : string;
    constructor(name){
        this.name = name;
    }

    @replaceMethod
    hello(){
        return `Hi ${this.name}!`;
    }
}

const robin = new Person('Robin');
console.log(robin.hello()); // 'How are you, Robin?'

上面示例中,装饰器@replaceMethod返回的函数,就成为了新的hello()方法。</p>

4. 利用方法装饰器实现代理方法

class Person{
    name: string;
    constructor(name: string){
        this.name = name;
    }

    @log
    greet(){
        console.log(`Hello, my name is ${this.name}.`);
    }
}

function log(originalMethod: any, context: ClassMethodDecoratorContext){
    const methodName = String(context.name);
    function replacementMethod(this: any, ...args: any[]){
        console.log(`LOG: Entering method '${methodName}'.`);
        const result = originalMethod.call(this, ...args);
        console.log(`LOG: Exiting method '${methodName}'.`);
        return result;
    }
    return replacementMethod;
}

const person = new Person('老八');
person.greet();

上面示例中,装饰器@log的返回值是一个函数replacementMethod,替代了原始方法greet()。在replacementMethod()内部,通过执行originalMethod.call()完成了对原始方法的调用。</p>

5. 利用方法装饰器,延迟方法的执行

function delay(milliseconds: number = 0){
    return function(value, context){
        if(context.kind === 'method') {
            return function(...args: any[]){
                setTimeout(() => {
                    vlaue.apply(this, args);
                }, milliseconds);
            };
        }
    };
}

class Logger{
    @delay(1000)
    log(msg: string){
        console.log(`${msg}`);
    }
}

let logger = new Logger();
logger.log('Hello, world');

上面示例中,方法装饰器@delay(1000)将方法log()的执行推迟了1秒(1000毫秒)。这里真正的方法装饰器,是delay()执行后返回的函数,delay()的作用是接收参数,用来设置推迟执行的时间。这种通过高阶函数返回装饰器的做法,称为“工厂模式”,即可以像工厂那样生产出一个模子的装饰器。</p>

6. context.addInitializer()

方法装饰器的参数`context`对象里面,有一个`addInitializer()`方法。它是一个钩子方法,用来在类的初始化阶段,添加回调函数。这个回调函数就是作为`addInitializer()`的参数传入的,它会 **在构造方法执行期间执行**,早于属性(`field`)的初始化。

​​

下面一个例子,通过`addInitializer()`将选定的方法名,放入一个集合。

function collect(
    value,
    {name, addInitializer}
){
    addInitializer(function(){
        if(!this.collectMethodKeys){
            this.collectMethodKeys = new Set();
        }
        this.collectMethodKeys.add(name);
    });
}

class C{
    @collect
    toString(){}

    @collect
    [Symbol.iterator](){}
}

const instance = new C();
console.log(instance['collectMethodKeys'])

上面示例中,方法装饰器@collect会将所装饰的成员名字,加入一个 Set 集合collectedMethodKeys

四、属性装饰器

1. 类型描述

属性装饰器用来装饰定义在类顶部的属性(field)。它的类型描述如下。

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

注意,装饰器的第一个参数value的类型是undefined,这意味着这个参数实际上没用的,装饰器不能从value获取所装饰属性的值。另外,第二个参数context对象的kind属性的值为字符串field,而不是 “property” 或 “attribute”,这一点是需要注意的。</p>

2. 基本使用方式

属性装饰器要么不返回值,要么返回一个函数,该函数会自动执行,用来对所装饰属性进行初始化。该函数的参数是所装饰属性的初始值,该函数的返回值是该属性的最终值。

function logged(value, context){
    const {kind, name } = context;
    if(kind === 'field'){
        return function (initialValue){
            console.log(`initializing ${name} with value ${initialValue}`);
            return initialValue;
        }
    }
}

class Color {
    @logged
    name = 'green';
}

const color = new Color();
// initializing name with value green

上面示例中,属性装饰器@logged装饰属性name@logged的返回值是一个函数,该函数用来对属性name进行初始化,它的参数initialValue就是属性name的初始值green新建实例对象color时,该函数会自动执行。

2. 属性装饰器的返回值函数,可以用来更改属性的初始值

function twice(value : any, context : any){
    return initialValue => initialValue * 2;
}

class C {
    @twice
    field: number = 3;
}
const c = new C();
console.log(c.field); // 6

上面示例中,属性装饰器@twice返回一个函数,该函数的返回值是属性field的初始值乘以 2,所以属性field的最终值是 6。</p>

3. 属性装饰器的上下文对象 context

context.access属性,提供所装饰属性的存取器,请看下面的例子。

let acc;

function exposeAccess(value, {access}) {
    acc = access;
}

class Color {
    @exposeAccess
    name = 'green';
}

const green = new Color();
console.log(green.name);        // green        

console.log(acc.get(green));    // green
acc.set(green, 'red');
console.log(green.name);        // red

上面示例中,access包含了属性name的存取器,可以对该属性进行取值和赋值。

五、getter/setter 装饰器

getter 装饰器和 setter 装饰器,是分别针对类的取值器(getter)和存值器(setter)的装饰器。

1. 类型描述

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

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

注意,getter 装饰器的上下文对象 contextaccess 属性,只包含 get() 方法;setter 装饰器的 access 属性,只包含 set() 方法。

这两个装饰器要么不返回值,要么返回一个函数,取代原来的取值器或存值器。

2. 基本用法

下面的例子是将取值器的结果,保存为一个属性,加快后面的读取。

class C {
    @lazy
    get value() {
        console.log('正在计算...');
        return '开销大的计算结果';
    }
}

function lazy(value: any, { kind, name }: any) {
    if (kind === 'getter') {
        return function (this: any) {
            const result = value.call(this);
            Object.defineProperty(
                this, name,
                { value: result,  writable: false } // 定义同名属性且不可修改
            );
            return result;
        };
    }
    return;
}

const instance = new C();
console.log(instance.value)
// 正在计算...
// 开销大的计算结果
console.log(instance.value);
// 开销大的计算结果

上面示例中,第一次读取instance.value,会进行计算,然后装饰器@lazy将结果存入只读属性value,后面再读取这个属性,就不会进行计算了。

六、accessor 装饰器

1. accessor 关键字

装饰器语法(ES2015)引入了一个新的属性修饰符accessor

class C {
    accessor x = 1;
}
var c = new C();
console.log(c.x);
c.x = 2;
console.log(c.x);

上面示例中,accessor修饰符等同于为公开属性x自动生成取值器和存值器,它们作用于私有属性x。(注意,公开的x与私有的x不是同一个属性。)也就是说,上面的代码等同于下面的代码。

class C {
    #x = 1;
    get x() { return this.#x; }
    set x(value) { this.#x = value; }
}
// 使用命令 tsc --target ES2022 文件名.ts 编译

accessor 也可以与静态属性和私有属性一起使用。

class C {
    static accessor x = 1;
    accessor #y = 2;
}

2. accessor 装饰器的类型

accessor 装饰器的类型如下:

type ClassAutoAccessorDecorator = (
    value : {
        get: () => unknown;
        set: (value: unknown) => void;
    },
    context: {
        kind : 'accessor';
        name : string | symbol;
        access : {get(): unknown; set(value: unknown): void};
        static : boolean;
        private : boolean;
        addInitializer(initializer: () => void): void;
    }
 ) => {
    get?: () => unknown;
    set?: (value: unknown) => void;
    init?: (initialValue: unknown) => unknown;
} | void;

accessor 装饰器的value参数,是一个包含get()方法和set()方法的对象。该装饰器可以不返回值,或者返回一个新的对象,用来取代原来的get()方法和set()方法。此外,装饰器返回的对象还可以包括一个init()方法,用来改变私有属性的初始值。</p>

3. 一般应用

下面是一个例子。

class C {
    @logged
    accessor x = 1;
}

function logged(value, {kind, name}) {
    if(kind === 'accessor') {
        let {get, set} = value;
        return {
            get() {
                console.log(`getting ${name}`);
                return get.call(this);
            },
            set(value){
                console.log(`setting ${name} to ${value}`);
                return set.call(this, value);
            },
            init(initialValue){
                console.log(`initializing ${name} with value ${initialValue}`);
                return initialValue;
            }
        }
    }
}

let c = new C();
console.log(c.x);
// getting x
// 1
c.x = 2;
// setting x to 2
console.log(c.x);
// getting x
// 2

上面示例中,装饰器@logged为属性x的存值器和取值器,加上了日志输出。

七、装饰器的执行顺序

装饰器的执行分为两个阶段。

  1. 评估(evaluation):计算@符号后面的表达式的值,得到的应该是函数。

  2. 应用(application):将评估装饰器后得到的函数,应用于所装饰对象。

也就是说,装饰器的执行顺序是,先评估所有装饰器表达式的值,再将其应用于当前类。

应用装饰器时,顺序依次为方法装饰器和属性装饰器,然后是类装饰器。 请看下面的例子:

function d(str : string) {
    console.log(`评估 @d(): ${str}`);
    return (value: any, context: any) => console.log(`应用 @d(): ${str}`);
}

function log(str: string){
    console.log(str);
    return str;
}

@d('类装饰器')
class T {
    @d('静态属性装饰器')
    static staticField = log('静态属性值');

    @d('原型方法')
    [log('计算方法名')](){}

    @d('实例属性')
    instanceField = log('实例属性');

    @d('静态方法装饰器')
    static fn(){}
}

上面示例中,类T有四种装饰器:类装饰器、静态属性装饰器、方法装饰器、属性装饰器。</p>

它的运行结果如下。

评估 @d(): 类装饰器
评估 @d(): 静态属性装饰器
评估 @d(): 原型方法
计算方法名
评估 @d(): 实例属性
评估 @d(): 静态方法装饰器
应用 @d(): 静态方法装饰器
应用 @d(): 原型方法
应用 @d(): 静态属性装饰器
应用 @d(): 实例属性
应用 @d(): 类装饰器
静态属性值

可以看到,类载入的时候,代码按照以下顺序执行。

  1. 装饰器评估:这一步计算装饰器的值,首先是类装饰器,然后是类内部的装饰器,按照它们出现的顺序。注意,如果属性名或方法名是计算值(本例是“计算方法名”),则它们在对应的装饰器评估之后,也会进行自身的评估。

  2. 装饰器应用:实际执行装饰器函数,将它们与对应的方法和属性进行结合。静态方法装饰器首先应用,然后是原型方法的装饰器和静态属性装饰器,接下来是实例属性装饰器,最后是类装饰器。注意,“实例属性值”在类初始化的阶段并不执行,直到类实例化时才会执行。

如果一个方法或属性有多个装饰器,则内层的装饰器先执行,外层的装饰器后执行。

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    @bound
    @log
    greet(){
        console.log(`Hello, ${this.name}!`);
    }
}

上面示例中,greet()有两个装饰器,内层的@log先执行,外层的@bound针对得到的结果再执行。

总结

;