MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript装饰器原理:编译阶段的代码转换过程

2021-03-022.4k 阅读

装饰器概述

在深入探讨TypeScript装饰器在编译阶段的代码转换过程之前,我们先来了解一下装饰器是什么。装饰器是一种特殊类型的声明,它能够附加到类声明、方法、属性或参数上,用于在不改变原有代码结构的情况下,为目标对象添加额外的行为或元数据。

在JavaScript中,装饰器提案目前处于第2阶段,而TypeScript已经支持使用装饰器(需开启experimentalDecorators编译选项)。例如,我们可以定义一个简单的装饰器来记录方法的调用次数:

function logCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    let callCount = 0;
    descriptor.value = function(...args: any[]) {
        callCount++;
        console.log(`${propertyKey} has been called ${callCount} times`);
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

class MyClass {
    @logCall
    myMethod() {
        console.log('This is my method');
    }
}

const instance = new MyClass();
instance.myMethod();
instance.myMethod();

在上述代码中,logCall 装饰器被应用到 MyClass 类的 myMethod 方法上。每次调用 myMethod 时,都会打印出该方法被调用的次数。

装饰器类型

TypeScript 支持几种不同类型的装饰器,每种装饰器在编译阶段的代码转换方式都有其特点。

类装饰器

类装饰器应用于类的声明。它接收一个参数,即目标类的构造函数。类装饰器可以用于修改类的定义,比如添加新的属性或方法,甚至替换整个类的定义。

function classDecorator<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        newProperty = 'New property added by decorator';
        newMethod() {
            console.log('This is a new method added by decorator');
        }
    };
}

@classDecorator
class MyOriginalClass {
    originalMethod() {
        console.log('This is the original method');
    }
}

const instance = new MyOriginalClass();
console.log(instance.newProperty);
instance.newMethod();
instance.originalMethod();

在这个例子中,classDecorator 装饰器接收 MyOriginalClass 的构造函数,并返回一个新的类,这个新类继承自 MyOriginalClass,同时添加了新的属性 newProperty 和新的方法 newMethod

方法装饰器

方法装饰器应用于类的方法。它接收三个参数:目标对象(通常是类的原型)、方法名以及描述符对象。方法装饰器可以用于修改方法的行为,如上述 logCall 装饰器的例子,它修改了方法的执行逻辑,在方法执行前后添加了日志记录。

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

class MyClass {
    @enumerable(false)
    myMethod() {
        console.log('This is my method');
    }
}

const instance = new MyClass();
const keys = Object.keys(instance);
console.log(keys); // 不会包含'myMethod',因为enumerable设置为false

在这个例子中,enumerable 装饰器通过修改描述符对象的 enumerable 属性,控制 myMethod 是否可枚举。

属性装饰器

属性装饰器应用于类的属性。它接收两个参数:目标对象(通常是类的原型)和属性名。属性装饰器可以用于为属性添加元数据,或者对属性的访问进行拦截等操作。

function metadata(key: string, value: any) {
    return function(target: any, propertyKey: string) {
        if (!Reflect.hasMetadata(key, target)) {
            Reflect.defineMetadata(key, {}, target);
        }
        const existingMetadata = Reflect.getMetadata(key, target);
        existingMetadata[propertyKey] = value;
        Reflect.defineMetadata(key, existingMetadata, target);
    };
}

class MyClass {
    @metadata('info', 'This is an important property')
    myProperty: string;
}

const instance = new MyClass();
const info = Reflect.getMetadata('info', instance);
console.log(info.myProperty); // 输出 'This is an important property'

在上述代码中,metadata 装饰器使用 Reflect API 为 myProperty 属性添加了元数据。

参数装饰器

参数装饰器应用于类方法的参数。它接收三个参数:目标对象(通常是类的原型)、方法名以及参数在参数列表中的索引。参数装饰器通常用于收集参数相关的元数据。

function paramMetadata(key: string, value: any) {
    return function(target: any, propertyKey: string, parameterIndex: number) {
        if (!Reflect.hasMetadata(key, target, propertyKey)) {
            Reflect.defineMetadata(key, [], target, propertyKey);
        }
        const existingMetadata = Reflect.getMetadata(key, target, propertyKey);
        existingMetadata[parameterIndex] = value;
        Reflect.defineMetadata(key, existingMetadata, target, propertyKey);
    };
}

class MyClass {
    myMethod(@paramMetadata('paramInfo', 'First parameter') param1: string) {
        // 方法逻辑
    }
}

const instance = new MyClass();
const paramInfo = Reflect.getMetadata('paramInfo', instance,'myMethod');
console.log(paramInfo[0]); // 输出 'First parameter'

这里 paramMetadata 装饰器为 myMethod 方法的第一个参数添加了元数据。

编译阶段的代码转换基础

在理解装饰器在编译阶段的代码转换过程之前,我们需要先了解一些TypeScript编译的基础知识。TypeScript编译器会将TypeScript代码转换为JavaScript代码,这个过程涉及到词法分析、语法分析、语义分析以及代码生成等步骤。

当开启 experimentalDecorators 编译选项时,编译器会对装饰器进行特殊处理。装饰器本质上是一种语法糖,在编译时会被转换为普通的JavaScript函数调用。例如,上述的 logCall 装饰器应用到 myMethod 方法上的代码:

class MyClass {
    @logCall
    myMethod() {
        console.log('This is my method');
    }
}

在编译后,大致会转换为如下的JavaScript代码(简化示例,实际可能会因编译器版本等因素略有不同):

function logCall(target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;
    let callCount = 0;
    descriptor.value = function(...args) {
        callCount++;
        console.log(`${propertyKey} has been called ${callCount} times`);
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

class MyClass {
    myMethod() {
        console.log('This is my method');
    }
}
const myClassProto = MyClass.prototype;
const myMethodDescriptor = Object.getOwnPropertyDescriptor(myClassProto,'myMethod');
const newMyMethodDescriptor = logCall(myClassProto,'myMethod', myMethodDescriptor);
Object.defineProperty(myClassProto,'myMethod', newMyMethodDescriptor);

从这个转换过程可以看出,装饰器函数在编译时被直接调用,并且传入了目标对象、属性名以及属性描述符等参数,通过对描述符的修改,实现了对方法行为的改变。

类装饰器的编译转换过程

对于类装饰器,其编译转换过程相对复杂一些。以之前的 classDecorator 为例:

function classDecorator<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        newProperty = 'New property added by decorator';
        newMethod() {
            console.log('This is a new method added by decorator');
        }
    };
}

@classDecorator
class MyOriginalClass {
    originalMethod() {
        console.log('This is the original method');
    }
}

在编译时,TypeScript编译器会将上述代码转换为类似如下的JavaScript代码:

function classDecorator(constructor) {
    return class extends constructor {
        newProperty = 'New property added by decorator';
        newMethod() {
            console.log('This is a new method added by decorator');
        }
    };
}

class MyOriginalClass {
    originalMethod() {
        console.log('This is the original method');
    }
}
MyOriginalClass = classDecorator(MyOriginalClass);

可以看到,类装饰器在编译时,classDecorator 函数被调用,并传入 MyOriginalClass 的构造函数。然后,MyOriginalClass 被重新赋值为 classDecorator 函数的返回值,也就是一个新的类,这个新类继承自原来的 MyOriginalClass 并添加了新的属性和方法。

方法装饰器的编译转换细节

方法装饰器在编译阶段的转换主要围绕着对方法描述符的操作。再看 logCall 装饰器的例子:

function logCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    let callCount = 0;
    descriptor.value = function(...args: any[]) {
        callCount++;
        console.log(`${propertyKey} has been called ${callCount} times`);
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

class MyClass {
    @logCall
    myMethod() {
        console.log('This is my method');
    }
}

编译后的JavaScript代码为:

function logCall(target, propertyKey, descriptor) {
    const originalMethod = descriptor.value;
    let callCount = 0;
    descriptor.value = function(...args) {
        callCount++;
        console.log(`${propertyKey} has been called ${callCount} times`);
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

class MyClass {
    myMethod() {
        console.log('This is my method');
    }
}
const myClassProto = MyClass.prototype;
const myMethodDescriptor = Object.getOwnPropertyDescriptor(myClassProto,'myMethod');
const newMyMethodDescriptor = logCall(myClassProto,'myMethod', myMethodDescriptor);
Object.defineProperty(myClassProto,'myMethod', newMyMethodDescriptor);

在编译过程中,首先获取 MyClass 原型上 myMethod 的属性描述符 myMethodDescriptor,然后调用 logCall 装饰器函数,并传入 myClassProto'myMethod' 以及 myMethodDescriptorlogCall 函数返回一个新的描述符 newMyMethodDescriptor,最后通过 Object.defineProperty 将新的描述符重新定义到 MyClass 的原型上,从而实现对 myMethod 方法行为的修改。

属性装饰器的编译转换剖析

属性装饰器主要用于为属性添加元数据或进行一些与属性相关的预处理操作。以 metadata 装饰器为例:

function metadata(key: string, value: any) {
    return function(target: any, propertyKey: string) {
        if (!Reflect.hasMetadata(key, target)) {
            Reflect.defineMetadata(key, {}, target);
        }
        const existingMetadata = Reflect.getMetadata(key, target);
        existingMetadata[propertyKey] = value;
        Reflect.defineMetadata(key, existingMetadata, target);
    };
}

class MyClass {
    @metadata('info', 'This is an important property')
    myProperty: string;
}

编译后的JavaScript代码大致如下:

function metadata(key, value) {
    return function(target, propertyKey) {
        if (!Reflect.hasMetadata(key, target)) {
            Reflect.defineMetadata(key, {}, target);
        }
        const existingMetadata = Reflect.getMetadata(key, target);
        existingMetadata[propertyKey] = value;
        Reflect.defineMetadata(key, existingMetadata, target);
    };
}

class MyClass {
    myProperty;
}
const myClassProto = MyClass.prototype;
metadata('info', 'This is an important property')(myClassProto,'myProperty');

在编译时,属性装饰器函数 metadata 被调用,并传入 'info''This is an important property',返回一个新的函数。这个新函数再被调用,传入 MyClass 的原型 myClassProto 和属性名 'myProperty',从而完成对属性元数据的添加操作。

参数装饰器的编译转换过程

参数装饰器用于为方法参数添加元数据。以 paramMetadata 装饰器为例:

function paramMetadata(key: string, value: any) {
    return function(target: any, propertyKey: string, parameterIndex: number) {
        if (!Reflect.hasMetadata(key, target, propertyKey)) {
            Reflect.defineMetadata(key, [], target, propertyKey);
        }
        const existingMetadata = Reflect.getMetadata(key, target, propertyKey);
        existingMetadata[parameterIndex] = value;
        Reflect.defineMetadata(key, existingMetadata, target, propertyKey);
    };
}

class MyClass {
    myMethod(@paramMetadata('paramInfo', 'First parameter') param1: string) {
        // 方法逻辑
    }
}

编译后的JavaScript代码类似如下:

function paramMetadata(key, value) {
    return function(target, propertyKey, parameterIndex) {
        if (!Reflect.hasMetadata(key, target, propertyKey)) {
            Reflect.defineMetadata(key, [], target, propertyKey);
        }
        const existingMetadata = Reflect.getMetadata(key, target, propertyKey);
        existingMetadata[parameterIndex] = value;
        Reflect.defineMetadata(key, existingMetadata, target, propertyKey);
    };
}

class MyClass {
    myMethod(param1) {
        // 方法逻辑
    }
}
const myClassProto = MyClass.prototype;
paramMetadata('paramInfo', 'First parameter')(myClassProto,'myMethod', 0);

在编译过程中,paramMetadata 装饰器函数首先被调用并传入 'paramInfo''First parameter',返回一个新函数。这个新函数再被调用,传入 MyClass 的原型 myClassProto、方法名 'myMethod' 以及参数索引 0,实现为 myMethod 方法的第一个参数添加元数据。

装饰器与元数据

在装饰器的实现中,元数据起着重要的作用。特别是在属性装饰器和参数装饰器中,通过 Reflect API 来操作元数据。Reflect.hasMetadata 用于检查是否存在特定的元数据,Reflect.defineMetadata 用于定义元数据,Reflect.getMetadata 用于获取元数据。

function metadata(key: string, value: any) {
    return function(target: any, propertyKey: string) {
        if (!Reflect.hasMetadata(key, target)) {
            Reflect.defineMetadata(key, {}, target);
        }
        const existingMetadata = Reflect.getMetadata(key, target);
        existingMetadata[propertyKey] = value;
        Reflect.defineMetadata(key, existingMetadata, target);
    };
}

class MyClass {
    @metadata('info', 'This is an important property')
    myProperty: string;
}

const instance = new MyClass();
const info = Reflect.getMetadata('info', instance);
console.log(info.myProperty); // 输出 'This is an important property'

通过这种方式,我们可以在不改变属性或参数本身逻辑的情况下,为它们添加额外的信息,这些信息可以在运行时通过 Reflect API 进行获取和使用,为代码的扩展和定制提供了很大的灵活性。

装饰器的组合使用

在实际开发中,我们可能会对一个目标对象应用多个装饰器。例如,我们可以对一个方法同时应用 logCallenumerable 装饰器:

function logCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    let callCount = 0;
    descriptor.value = function(...args: any[]) {
        callCount++;
        console.log(`${propertyKey} has been called ${callCount} times`);
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

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

class MyClass {
    @logCall
    @enumerable(false)
    myMethod() {
        console.log('This is my method');
    }
}

const instance = new MyClass();
const keys = Object.keys(instance);
console.log(keys); // 不会包含'myMethod',因为enumerable设置为false
instance.myMethod();
instance.myMethod();

在编译时,装饰器会按照从下到上(或者说从内到外)的顺序依次执行。首先,enumerable(false) 装饰器会修改 myMethod 的描述符,将 enumerable 设置为 false。然后,logCall 装饰器再对已经修改过的描述符进行进一步修改,添加方法调用计数的逻辑。

装饰器的局限性与注意事项

虽然装饰器提供了强大的功能,但也存在一些局限性和需要注意的地方。

  1. 兼容性:目前装饰器提案处于第2阶段,不同的JavaScript运行环境对装饰器的支持程度不同。在生产环境中使用时,需要考虑兼容性问题,可能需要借助工具如Babel进行额外的转换。
  2. 调试难度:由于装饰器在编译阶段进行转换,调试时可能不太直观。特别是当装饰器逻辑复杂或者多个装饰器组合使用时,定位问题可能会比较困难。
  3. 性能影响:过多地使用装饰器,尤其是在性能敏感的代码段,可能会对性能产生一定的影响。因为装饰器本质上是函数调用,会增加额外的执行开销。

在使用装饰器时,需要权衡其带来的便利性和可能的负面影响,确保代码的健壮性和可维护性。

装饰器在实际项目中的应用场景

  1. 日志记录:如 logCall 装饰器的例子,在方法调用前后记录日志,方便调试和监控系统运行状态。
  2. 权限控制:可以定义一个装饰器,在方法调用前检查用户权限,只有具备相应权限的用户才能执行该方法。
function requirePermission(permission: string) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function(...args: any[]) {
            // 模拟权限检查
            const hasPermission = checkPermission(this.user, permission);
            if (hasPermission) {
                return originalMethod.apply(this, args);
            } else {
                throw new Error('Permission denied');
            }
        };
        return descriptor;
    };
}

function checkPermission(user: any, permission: string) {
    // 实际的权限检查逻辑
    return user.permissions.includes(permission);
}

class MyService {
    user = { permissions: ['read'] };

    @requirePermission('write')
    writeData(data: string) {
        console.log(`Writing data: ${data}`);
    }
}

const service = new MyService();
try {
    service.writeData('Some data');
} catch (error) {
    console.error(error.message);
}
  1. 缓存机制:对于一些计算开销较大的方法,可以使用装饰器实现缓存机制,避免重复计算。
function cache(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    const cache = new Map();
    descriptor.value = function(...args: any[]) {
        const key = args.toString();
        if (cache.has(key)) {
            return cache.get(key);
        }
        const result = originalMethod.apply(this, args);
        cache.set(key, result);
        return result;
    };
    return descriptor;
}

class MathService {
    @cache
    calculateFactorial(n: number) {
        if (n === 0 || n === 1) {
            return 1;
        }
        return n * this.calculateFactorial(n - 1);
    }
}

const mathService = new MathService();
console.log(mathService.calculateFactorial(5));
console.log(mathService.calculateFactorial(5)); // 第二次调用会从缓存中获取结果

通过以上对TypeScript装饰器原理以及编译阶段代码转换过程的详细介绍,我们可以看到装饰器为前端开发带来了极大的灵活性和可扩展性。合理地使用装饰器,可以使代码更加简洁、易于维护,同时也能实现一些强大的功能。但在使用过程中,需要充分了解其特性、注意事项以及应用场景,以确保代码的质量和性能。