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

TypeScript装饰器最佳实践:如何编写高效且可维护的装饰器

2021-03-311.7k 阅读

一、理解装饰器的基本概念

在 TypeScript 中,装饰器是一种特殊类型的声明,它能够被附加到类声明、方法、属性或参数上,以对它们进行修改或添加额外的行为。装饰器本质上是一个函数,当被装饰的目标被定义时,这个函数就会被调用。

1.1 装饰器工厂函数 装饰器本身是一个函数,但有时候我们需要更灵活地配置装饰器的行为,这就引入了装饰器工厂函数的概念。装饰器工厂函数是一个返回装饰器函数的函数。

// 装饰器工厂函数
function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.log(`调用方法 ${propertyKey} 之前`);
        const result = originalMethod.apply(this, args);
        console.log(`调用方法 ${propertyKey} 之后`);
        return result;
    };
    return descriptor;
}

class MyClass {
    @log
    myMethod() {
        console.log('执行 myMethod');
    }
}

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

在上述代码中,log 函数就是一个装饰器,它接收目标对象 target、属性名 propertyKey 和属性描述符 descriptor。通过修改 descriptor.value,我们在方法执行前后添加了日志输出。

1.2 不同类型的装饰器

  • 类装饰器:类装饰器应用于类的定义。它接收类的构造函数作为参数,可以用于修改类的行为,比如添加属性、方法,或者修改类的继承结构。
function classDecorator<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        newProperty = '新属性';
        hello() {
            console.log('来自装饰器添加的方法');
        }
    };
}

@classDecorator
class MyBaseClass {
    baseMethod() {
        console.log('基础方法');
    }
}

const myBaseObj = new MyBaseClass();
console.log(myBaseObj.newProperty);
myBaseObj.hello();
myBaseObj.baseMethod();
  • 方法装饰器:方法装饰器应用于类的方法。除了目标对象、属性名,还会接收属性描述符,这样就可以方便地修改方法的行为,如上述 log 装饰器对方法的修改。
  • 属性装饰器:属性装饰器应用于类的属性。它接收目标对象和属性名,通常用于添加元数据到属性上。
function metadata(target: any, propertyKey: string) {
    Reflect.defineMetadata('description', '这是一个示例属性', target, propertyKey);
}

class MyMetadataClass {
    @metadata
    myProperty: string;
}

const metadataObj = new MyMetadataClass();
const description = Reflect.getMetadata('description', metadataObj, 'myProperty');
console.log(description);
  • 参数装饰器:参数装饰器应用于方法的参数。它接收目标对象、属性名和参数在参数列表中的索引,可用于验证参数等操作。
function validate(target: any, propertyKey: string, parameterIndex: number) {
    const method = target[propertyKey];
    target[propertyKey] = function(...args: any[]) {
        if (typeof args[parameterIndex]!=='string') {
            throw new Error('参数必须是字符串');
        }
        return method.apply(this, args);
    };
}

class ValidateClass {
    @validate
    myMethod(param: string) {
        console.log('接收到参数:', param);
    }
}

const validateObj = new ValidateClass();
validateObj.myMethod('测试字符串');
// validateObj.myMethod(123); // 会抛出错误

二、编写高效的装饰器

2.1 性能优化

  • 避免不必要的计算:在装饰器中,尤其是在频繁调用的方法装饰器中,要避免进行复杂且不必要的计算。例如,如果装饰器只是用于记录日志,那么就不要在日志记录过程中进行大量的数据处理或复杂的数据库查询。
function simpleLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.time('方法执行时间');
        const result = originalMethod.apply(this, args);
        console.timeEnd('方法执行时间');
        return result;
    };
    return descriptor;
}

class PerformanceClass {
    @simpleLog
    performTask() {
        // 模拟一些任务
        for (let i = 0; i < 1000000; i++);
    }
}

const performanceObj = new PerformanceClass();
performanceObj.performTask();

在上述 simpleLog 装饰器中,只是简单地记录了方法的执行时间,没有进行额外的复杂计算。

  • 缓存结果:如果装饰器的计算结果在多次调用中不会改变,可以考虑缓存这些结果。例如,对于一些验证装饰器,如果验证规则不会改变,并且验证结果是固定的,可以缓存验证结果。
function cachedValidate(target: any, propertyKey: string, parameterIndex: number) {
    let cache: boolean | undefined;
    const method = target[propertyKey];
    target[propertyKey] = function(...args: any[]) {
        if (cache === undefined) {
            cache = typeof args[parameterIndex] ==='string';
        }
        if (!cache) {
            throw new Error('参数必须是字符串');
        }
        return method.apply(this, args);
    };
}

class CachedValidateClass {
    @cachedValidate
    myCachedMethod(param: string) {
        console.log('接收到参数:', param);
    }
}

const cachedValidateObj = new CachedValidateClass();
cachedValidateObj.myCachedMethod('测试字符串');

2.2 复用性设计

  • 单一职责原则:每个装饰器应该只负责一项主要功能。比如,一个装饰器用于日志记录,另一个装饰器用于权限验证。这样可以提高装饰器的复用性,当在其他项目或模块中需要日志记录功能时,就可以直接复用这个日志装饰器。
function logging(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.log(`开始调用 ${propertyKey}`);
        const result = originalMethod.apply(this, args);
        console.log(`结束调用 ${propertyKey}`);
        return result;
    };
    return descriptor;
}

function authorization(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        // 简单模拟权限验证
        if (!hasPermission()) {
            throw new Error('没有权限');
        }
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

function hasPermission() {
    // 实际实现权限验证逻辑
    return true;
}

class MultiDecoratorClass {
    @logging
    @authorization
    restrictedMethod() {
        console.log('这是一个受限方法');
    }
}

const multiDecoratorObj = new MultiDecoratorClass();
multiDecoratorObj.restrictedMethod();
  • 参数化配置:通过装饰器工厂函数来实现参数化配置,使得装饰器可以根据不同的需求进行定制。例如,一个日志装饰器可以通过参数来指定日志的级别。
function leveledLog(level: string) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function(...args: any[]) {
            console.log(`${level}: 开始调用 ${propertyKey}`);
            const result = originalMethod.apply(this, args);
            console.log(`${level}: 结束调用 ${propertyKey}`);
            return result;
        };
        return descriptor;
    };
}

class LeveledLogClass {
    @leveledLog('INFO')
    infoMethod() {
        console.log('这是一个信息方法');
    }

    @leveledLog('WARN')
    warnMethod() {
        console.log('这是一个警告方法');
    }
}

const leveledLogObj = new LeveledLogClass();
leveledLogObj.infoMethod();
leveledLogObj.warnMethod();

三、确保装饰器的可维护性

3.1 清晰的命名

  • 装饰器命名:装饰器的命名应该清晰地反映其功能。例如,logMethod 这个命名就很明确地表示该装饰器用于记录方法的相关信息,而不是使用像 funcMod1 这样模糊不清的命名。
  • 参数命名:如果装饰器工厂函数接收参数,参数的命名也应该具有描述性。比如在 leveledLog(level: string) 中,level 这个参数名就清楚地表明了它用于指定日志级别。

3.2 文档化

  • 内联注释:在装饰器代码内部,使用内联注释来解释复杂的逻辑。例如,在权限验证装饰器中,如果有复杂的权限计算逻辑,可以在相关代码行添加注释说明计算的依据。
function complexAuthorization(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        // 根据用户角色和操作类型计算权限
        const userRole = getCurrentUserRole();
        const operationType = getOperationType(propertyKey);
        const hasPerm = calculatePermission(userRole, operationType);
        if (!hasPerm) {
            throw new Error('没有权限');
        }
        return originalMethod.apply(this, args);
    };
    return descriptor;
}
  • JSDoc 风格注释:在装饰器定义的上方,使用 JSDoc 风格的注释来描述装饰器的功能、参数以及返回值(如果有)。
/**
 * 用于验证方法参数是否为数字的装饰器
 * @param target 目标对象
 * @param propertyKey 方法名
 * @param parameterIndex 参数在参数列表中的索引
 */
function numberParamValidate(target: any, propertyKey: string, parameterIndex: number) {
    const method = target[propertyKey];
    target[propertyKey] = function(...args: any[]) {
        if (typeof args[parameterIndex]!== 'number') {
            throw new Error('参数必须是数字');
        }
        return method.apply(this, args);
    };
}

3.3 错误处理

  • 装饰器内部错误处理:在装饰器内部,要对可能出现的错误进行适当处理。例如,在属性装饰器中,如果添加元数据失败,应该抛出一个有意义的错误,而不是让错误在运行时以不可预测的方式出现。
function metadata(target: any, propertyKey: string) {
    try {
        Reflect.defineMetadata('description', '这是一个示例属性', target, propertyKey);
    } catch (error) {
        console.error('添加元数据失败:', error);
        throw new Error('装饰器执行失败');
    }
}
  • 被装饰方法的错误传播:当装饰器修改了方法的行为后,要确保方法内部的错误能够正确传播。例如,在日志装饰器中,不能因为添加了日志记录而掩盖了方法本身抛出的错误。
function loggingWithErrorPropagation(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        try {
            console.log(`开始调用 ${propertyKey}`);
            const result = originalMethod.apply(this, args);
            console.log(`结束调用 ${propertyKey}`);
            return result;
        } catch (error) {
            console.error(`调用 ${propertyKey} 时发生错误`, error);
            throw error;
        }
    };
    return descriptor;
}

class ErrorPropagationClass {
    @loggingWithErrorPropagation
    errorMethod() {
        throw new Error('方法内部错误');
    }
}

const errorPropagationObj = new ErrorPropagationClass();
try {
    errorPropagationObj.errorMethod();
} catch (error) {
    console.log('捕获到错误:', error.message);
}

四、装饰器与依赖注入

4.1 依赖注入的概念 依赖注入(Dependency Injection,简称 DI)是一种设计模式,它允许将对象所依赖的其他对象通过外部传入,而不是在对象内部自行创建。这样可以提高代码的可测试性和可维护性。

4.2 装饰器实现依赖注入

  • 属性注入:通过属性装饰器,可以将依赖注入到类的属性中。
interface Logger {
    log(message: string): void;
}

class ConsoleLogger implements Logger {
    log(message: string) {
        console.log(message);
    }
}

function injectLogger(target: any, propertyKey: string) {
    const logger = new ConsoleLogger();
    Object.defineProperty(target, propertyKey, {
        value: logger,
        writable: true,
        enumerable: true,
        configurable: true
    });
}

class MyService {
    @injectLogger
    logger: Logger;

    doWork() {
        this.logger.log('执行工作');
    }
}

const myService = new MyService();
myService.doWork();
  • 方法注入:方法装饰器也可以用于依赖注入,通过修改方法的参数列表,将依赖注入到方法中。
function injectDatabase(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    const database = getDatabaseConnection();
    descriptor.value = function(...args: any[]) {
        return originalMethod.apply(this, [database,...args]);
    };
    return descriptor;
}

function getDatabaseConnection() {
    // 实际实现数据库连接逻辑
    return {
        query: (sql: string) => {
            console.log(`执行 SQL: ${sql}`);
        }
    };
}

class DatabaseService {
    @injectDatabase
    queryDatabase(sql: string) {
        // 这里的第一个参数会是注入的数据库连接对象
        const db = arguments[0];
        db.query(sql);
    }
}

const databaseService = new DatabaseService();
databaseService.queryDatabase('SELECT * FROM users');

五、装饰器与 AOP(面向切面编程)

5.1 AOP 简介 面向切面编程(Aspect - Oriented Programming,简称 AOP)是一种编程范式,它允许将横切关注点(如日志记录、权限验证、事务管理等)从业务逻辑中分离出来,以提高代码的模块化和可维护性。

5.2 装饰器实现 AOP

  • 横切关注点分离:在 TypeScript 中,装饰器是实现 AOP 的一种有效方式。例如,日志记录是一个典型的横切关注点,可以通过装饰器将其从业务方法中分离出来。
function logAspect(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.log(`日志记录: 开始调用 ${propertyKey}`);
        const result = originalMethod.apply(this, args);
        console.log(`日志记录: 结束调用 ${propertyKey}`);
        return result;
    };
    return descriptor;
}

class BusinessLogic {
    @logAspect
    businessMethod() {
        console.log('执行核心业务逻辑');
    }
}

const businessLogic = new BusinessLogic();
businessLogic.businessMethod();
  • 组合多个切面:可以将多个装饰器组合使用,实现多个横切关注点的叠加。比如,同时应用日志记录和权限验证装饰器到一个方法上。
function authorizationAspect(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        if (!hasPermission()) {
            throw new Error('没有权限');
        }
        return originalMethod.apply(this, args);
    };
    return descriptor;
}

function hasPermission() {
    // 实际权限验证逻辑
    return true;
}

class MultiAspectClass {
    @logAspect
    @authorizationAspect
    multiAspectMethod() {
        console.log('执行多切面方法');
    }
}

const multiAspectObj = new MultiAspectClass();
multiAspectObj.multiAspectMethod();

六、装饰器在大型项目中的应用场景

6.1 日志记录与监控 在大型项目中,日志记录对于调试和性能分析至关重要。通过方法装饰器,可以方便地在每个关键方法的调用前后记录日志,记录方法的输入参数、执行时间以及返回结果等信息。

function detailedLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.time(`方法 ${propertyKey} 执行时间`);
        console.log(`调用方法 ${propertyKey},参数:`, args);
        const result = originalMethod.apply(this, args);
        console.log(`方法 ${propertyKey} 返回结果:`, result);
        console.timeEnd(`方法 ${propertyKey} 执行时间`);
        return result;
    };
    return descriptor;
}

class BigProjectService {
    @detailedLog
    complexOperation(data: any) {
        // 模拟复杂操作
        return data.length;
    }
}

const bigProjectService = new BigProjectService();
bigProjectService.complexOperation([1, 2, 3]);

6.2 权限控制 在企业级应用中,不同的用户角色需要访问不同的功能。通过方法装饰器,可以在方法调用前验证用户的权限,确保只有具有相应权限的用户才能执行该方法。

function roleBasedAuthorization(requiredRole: string) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function(...args: any[]) {
            const currentUserRole = getCurrentUserRole();
            if (currentUserRole!== requiredRole) {
                throw new Error('没有权限');
            }
            return originalMethod.apply(this, args);
        };
        return descriptor;
    };
}

function getCurrentUserRole() {
    // 实际获取当前用户角色逻辑
    return 'admin';
}

class AdminService {
    @roleBasedAuthorization('admin')
    adminOnlyMethod() {
        console.log('这是只有管理员能执行的方法');
    }
}

const adminService = new AdminService();
adminService.adminOnlyMethod();

6.3 数据验证 在处理用户输入或外部数据时,数据验证是必不可少的。参数装饰器可以用于在方法参数传入前进行验证,确保数据的合法性。

function validateEmail(target: any, propertyKey: string, parameterIndex: number) {
    const method = target[propertyKey];
    target[propertyKey] = function(...args: any[]) {
        const email = args[parameterIndex];
        const emailRegex = /^[a-zA - Z0 - 9_.+-]+@[a-zA - Z0 - 9 -]+\.[a-zA - Z0 - 9-.]+$/;
        if (!emailRegex.test(email)) {
            throw new Error('无效的电子邮件地址');
        }
        return method.apply(this, args);
    };
}

class UserService {
    @validateEmail
    registerUser(email: string) {
        console.log(`注册用户: ${email}`);
    }
}

const userService = new UserService();
userService.registerUser('test@example.com');
// userService.registerUser('invalid - email'); // 会抛出错误

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

7.1 兼容性问题

  • 运行环境支持:装饰器是 ES7 的提案,虽然 TypeScript 对其有较好的支持,但在一些较老的 JavaScript 运行环境(如旧版本的 Node.js 或某些浏览器)中可能不支持。在实际项目中,需要考虑目标运行环境是否支持装饰器,或者使用转译工具(如 Babel)来确保兼容性。
  • 版本兼容性:不同版本的 TypeScript 对装饰器的支持可能存在差异。在升级 TypeScript 版本时,要注意装饰器相关的行为是否发生了变化,及时调整代码以适应新版本的特性。

7.2 装饰器顺序 当一个目标(如方法)应用了多个装饰器时,装饰器的顺序非常重要。装饰器的执行顺序是从下往上(从最接近目标的装饰器开始)。例如:

function decorator1(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('decorator1 执行');
    return descriptor;
}

function decorator2(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('decorator2 执行');
    return descriptor;
}

class DecoratorOrderClass {
    @decorator1
    @decorator2
    myMethod() {}
}

在上述代码中,decorator2 会先执行,然后 decorator1 执行。这种顺序可能会影响最终的行为,特别是当装饰器之间存在依赖关系时。

7.3 调试困难 由于装饰器在编译时就对代码进行了修改,调试装饰器相关的问题可能会比较困难。在调试时,可以通过在装饰器内部添加详细的日志输出,或者使用调试工具来跟踪装饰器的执行过程。例如,在 Node.js 环境中,可以使用 node --inspect 命令来启用调试模式,结合调试工具(如 Chrome DevTools)来调试装饰器代码。

通过遵循上述最佳实践,开发者可以在 TypeScript 项目中高效地编写可维护的装饰器,充分发挥装饰器在代码结构优化、功能增强等方面的优势,提升项目的整体质量和开发效率。无论是小型项目还是大型企业级应用,合理使用装饰器都能够带来显著的收益。