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

TypeScript装饰器在框架中的应用:以NestJS为例

2022-12-305.0k 阅读

1. 前置知识:TypeScript 装饰器基础

1.1 什么是装饰器

装饰器是一种特殊类型的声明,它能够被附加到类声明、方法、属性或参数上。它本质上是一个函数,在运行时会对被装饰的目标进行一些额外的操作。装饰器的语法使用 @ 符号,紧接着装饰器函数名,后面跟着可选的参数。

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

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

class Example {
    @log
    greet(name: string) {
        return `Hello, ${name}!`;
    }
}

const example = new Example();
example.greet('John');

在上述代码中,log 装饰器函数接收三个参数:target 表示被装饰的目标(这里是 Example 类的原型对象),propertyKey 是被装饰的属性名(greet 方法名),descriptor 是属性描述符,通过修改 descriptor.value 来对原方法进行增强,在方法调用前后打印日志。

1.2 装饰器类型

  • 类装饰器:应用于类的定义。类装饰器函数接收一个参数,即类的构造函数。它可以用来修改类的定义,比如添加新的属性或方法,修改类的原型等。
function classDecorator(constructor: Function) {
    constructor.prototype.newMethod = function () {
        console.log('这是新添加的方法');
    };
}

@classDecorator
class MyClass {}

const myObj = new MyClass();
(myObj as any).newMethod();
  • 方法装饰器:应用于类的方法。如前面 log 装饰器的例子,方法装饰器函数接收三个参数:类的原型对象,方法名,以及方法的属性描述符。它可以修改方法的行为,比如添加权限验证、日志记录等功能。
  • 属性装饰器:应用于类的属性。属性装饰器函数接收两个参数:类的原型对象和属性名。虽然不能直接修改属性的值,但可以在类的构造函数中通过 this 来初始化属性时进行一些额外操作。
function propertyDecorator(target: any, propertyKey: string) {
    let value;
    Object.defineProperty(target, propertyKey, {
        get: function () {
            return value;
        },
        set: function (newValue) {
            console.log(`设置 ${propertyKey} 属性为:`, newValue);
            value = newValue;
        }
    });
}

class MyPropertyClass {
    @propertyDecorator
    myProperty;
}

const propertyObj = new MyPropertyClass();
propertyObj.myProperty = 'new value';
console.log(propertyObj.myProperty);
  • 参数装饰器:应用于方法的参数。参数装饰器函数接收三个参数:类的原型对象,方法名,以及参数在参数列表中的索引位置。它通常用于验证参数、记录参数等操作。
function parameterDecorator(target: any, propertyKey: string, parameterIndex: number) {
    console.log(`在 ${propertyKey} 方法中,第 ${parameterIndex} 个参数被装饰`);
}

class ParameterExample {
    greet(@parameterDecorator name: string) {
        return `Hello, ${name}!`;
    }
}

const paramExample = new ParameterExample();
paramExample.greet('Alice');

2. NestJS 框架简介

2.1 NestJS 的设计理念

NestJS 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的框架。它建立在 Express 之上(也可以使用 Fastify),采用了模块化架构,使得代码易于维护和扩展。NestJS 的设计理念深受 Angular 的影响,它使用了依赖注入(Dependency Injection,DI)和面向对象编程(Object - Oriented Programming,OOP)的原则,以提高代码的可测试性和可重用性。

2.2 核心概念

  • 模块(Module):NestJS 应用由模块组成。模块是一个具有特定功能的代码块,它将相关的组件(如控制器、服务、提供者等)组织在一起。一个模块可以导入其他模块,也可以被其他模块导入,形成一个模块树结构。例如,一个典型的 NestJS 应用可能有 AppModule 作为根模块,以及一些功能模块如 UserModuleProductModule 等。
import { Module } from '@nestjs/common';

@Module({})
export class AppModule {}
  • 控制器(Controller):负责处理传入的 HTTP 请求,并返回响应。控制器定义了应用的路由,通过装饰器来映射不同的 HTTP 方法(如 GETPOSTPUTDELETE 等)到相应的处理函数。
import { Controller, Get } from '@nestjs/common';

@Controller('users')
export class UsersController {
    @Get()
    findAll() {
        return '所有用户';
    }
}
  • 服务(Service):包含应用的业务逻辑。服务通常由控制器调用,用于执行复杂的业务操作,如数据库查询、文件处理等。服务是可注入的,这意味着它们可以在其他组件(如控制器、其他服务)中被依赖注入。
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
    findAll() {
        // 这里可以实现数据库查询等业务逻辑
        return ['user1', 'user2'];
    }
}
  • 提供者(Provider):是一个可以被注入到其他组件中的类。服务是最常见的提供者类型,但其他如数据库连接实例、配置对象等也可以是提供者。通过依赖注入,NestJS 会负责创建和管理提供者的实例,并将它们注入到需要的地方。

3. TypeScript 装饰器在 NestJS 中的应用

3.1 模块装饰器

在 NestJS 中,@Module 是一个类装饰器,用于定义模块。它接收一个元数据对象,通过这个元数据对象可以配置模块的各种属性。

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UserService } from './user.service';

@Module({
    controllers: [UsersController],
    providers: [UserService],
    exports: [UserService]
})
export class UserModule {}

在上述代码中,@Module 装饰器的元数据对象中:

  • controllers 数组指定了该模块包含的控制器。
  • providers 数组指定了该模块提供的服务。
  • exports 数组指定了哪些服务可以被其他模块导入使用。

3.2 控制器装饰器

  • @Controller:这是一个类装饰器,用于定义控制器。它接收一个可选的路径参数,用于指定控制器的基础路由路径。所有该控制器中的路由处理函数都会基于这个基础路径。
import { Controller } from '@nestjs/common';

@Controller('products')
export class ProductsController {}

在这个例子中,ProductsController 的所有路由处理函数都会以 /products 为基础路径。

  • HTTP 方法装饰器(@Get@Post@Put@Delete 等):这些是方法装饰器,用于将类中的方法映射到特定的 HTTP 方法。它们接收一个可选的路径参数,用于指定该方法对应的路由路径,该路径会附加在控制器的基础路径之后。
import { Controller, Get, Post } from '@nestjs/common';

@Controller('orders')
export class OrdersController {
    @Get()
    findAll() {
        return '所有订单';
    }

    @Post()
    create() {
        return '创建订单';
    }
}

在上述代码中,findAll 方法映射到 GET /orders 路由,create 方法映射到 POST /orders 路由。

3.3 服务装饰器

@Injectable 是一个类装饰器,用于将一个类标记为可注入的服务。这意味着 NestJS 可以在需要的地方创建该服务的实例并注入。

import { Injectable } from '@nestjs/common';

@Injectable()
export class CartService {
    getCartItems() {
        // 实现获取购物车商品的逻辑
        return ['item1', 'item2'];
    }
}

当其他组件(如控制器)需要使用 CartService 时,NestJS 会自动创建 CartService 的实例并注入。

3.4 自定义装饰器在 NestJS 中的应用

在 NestJS 中,我们还可以创建自定义装饰器来满足特定的业务需求。例如,假设我们需要对某些路由进行权限验证,我们可以创建一个自定义的权限验证装饰器。

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
    (data: unknown, ctx: ExecutionContext) => {
        const request = ctx.switchToHttp().getRequest();
        return request.user;
    }
);

上述代码创建了一个 CurrentUser 装饰器,它可以从请求上下文中获取当前用户信息。在控制器中使用如下:

import { Controller, Get } from '@nestjs/common';
import { CurrentUser } from './current - user.decorator';

@Controller('profile')
export class ProfileController {
    @Get()
    getProfile(@CurrentUser() user) {
        return user;
    }
}

这样,在 getProfile 方法中,通过 @CurrentUser() 装饰器可以方便地获取当前用户信息并进行处理。

4. 深入理解 NestJS 中装饰器的工作原理

4.1 依赖注入与装饰器

NestJS 中的依赖注入机制与装饰器紧密结合。当一个类被标记为 @Injectable 时,NestJS 的依赖注入系统会为这个类创建一个实例,并在需要的地方注入。例如,当一个控制器依赖于一个服务时:

import { Controller, Get } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UsersController {
    constructor(private readonly userService: UserService) {}

    @Get()
    findAll() {
        return this.userService.findAll();
    }
}

这里 UsersController 的构造函数中注入了 UserService。NestJS 通过装饰器(@Injectable 装饰 UserService@Controller 装饰 UsersController)识别出这些组件,并利用依赖注入容器来管理它们的实例化和注入。当 UsersController 被创建时,NestJS 会先创建 UserService 的实例(如果还没有创建的话),然后将其注入到 UsersController 的构造函数中。

4.2 路由映射与装饰器

NestJS 利用装饰器来实现路由映射。@Controller 装饰器定义了控制器的基础路由路径,而 @Get@Post 等 HTTP 方法装饰器在控制器内部定义了具体的路由处理函数及其对应的 HTTP 方法和路径。当一个请求到达应用时,NestJS 的路由系统会根据请求的 URL 和 HTTP 方法,通过装饰器所定义的路由映射关系,找到对应的控制器和处理函数。例如,对于请求 GET /users,NestJS 会根据 @Controller('users')@Get() 装饰器找到 UsersController 中的 findAll 方法来处理请求。

4.3 元数据与装饰器

装饰器在 NestJS 中还用于存储和管理元数据。例如,@Module 装饰器的元数据对象包含了模块的配置信息,如控制器、提供者、导入的模块等。NestJS 在启动过程中会读取这些元数据,从而构建应用的模块结构、依赖关系等。同样,@Injectable 装饰器也可以看作是为类添加了可注入的元数据,使得依赖注入系统能够识别和处理该类。

5. 实际应用场景中的优化与最佳实践

5.1 装饰器的复用

在大型项目中,尽量复用装饰器以减少代码重复。例如,权限验证装饰器可能在多个控制器的不同方法中使用。通过将权限验证逻辑封装在一个装饰器中,可以提高代码的可维护性。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

export const Roles = (...roles: string[]) => {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        Reflect.defineMetadata('roles', roles, target, propertyKey);
    };
};

@Injectable()
export class RolesGuard implements CanActivate {
    constructor(private readonly reflector: Reflector) {}

    canActivate(context: ExecutionContext): boolean {
        const roles = this.reflector.get<string[]>('roles', context.getHandler());
        if (!roles) {
            return true;
        }
        const request = context.switchToHttp().getRequest();
        const user = request.user;
        return user.roles.some(role => roles.includes(role));
    }
}

在控制器中使用:

import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles, RolesGuard } from './roles.decorator';

@Controller('admin')
@UseGuards(RolesGuard)
export class AdminController {
    @Get()
    @Roles('admin')
    getAdminInfo() {
        return '管理员信息';
    }
}

这样,Roles 装饰器和 RolesGuard 可以在多个需要权限控制的地方复用。

5.2 装饰器与性能优化

虽然装饰器提供了强大的功能,但过度使用装饰器可能会影响性能。在编写装饰器时,尽量避免在装饰器函数中执行复杂的计算或 I/O 操作。例如,日志记录装饰器可以采用异步日志记录的方式,避免阻塞主线程。

import { promisify } from 'util';
import { appendFile } from 'fs';

function asyncLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (...args: any[]) {
        const result = await originalMethod.apply(this, args);
        const logMessage = `调用 ${propertyKey} 方法,参数:${JSON.stringify(args)},返回结果:${JSON.stringify(result)}`;
        await promisify(appendFile)('app.log', logMessage + '\n');
        return result;
    };
    return descriptor;
}

这样,日志记录操作在异步中进行,不会影响方法的正常执行。

5.3 装饰器的分层设计

在复杂项目中,对装饰器进行分层设计可以提高代码的可读性和可维护性。例如,可以将与业务逻辑相关的装饰器放在业务层,与基础架构相关的装饰器(如日志、性能监控)放在基础架构层。这样,不同层次的开发人员可以专注于自己关心的部分,同时也便于管理和维护整个项目的装饰器体系。

6. 常见问题与解决方法

6.1 装饰器参数传递问题

有时在使用装饰器时,可能会遇到参数传递不正确的情况。例如,自定义装饰器接收多个参数时,要确保参数的类型和顺序正确。

function customDecorator(arg1: string, arg2: number) {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
        // 处理参数
    };
}

在使用时:

class MyClass {
    @customDecorator('test', 10)
    myMethod() {}
}

确保 arg1 是字符串类型,arg2 是数字类型,按照定义的顺序传递。

6.2 装饰器冲突问题

当项目中使用多个装饰器时,可能会出现装饰器之间的冲突。例如,两个不同的装饰器都试图修改同一个属性描述符的 value 属性。解决这个问题的方法是仔细设计装饰器,确保它们不会相互干扰。可以在装饰器函数中添加一些检查逻辑,避免重复修改。

function decorator1(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    if (!descriptor.value.__decorator1__) {
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.log('decorator1 前置逻辑');
            const result = originalMethod.apply(this, args);
            console.log('decorator1 后置逻辑');
            return result;
        };
        descriptor.value.__decorator1__ = true;
    }
    return descriptor;
}

function decorator2(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    if (!descriptor.value.__decorator2__) {
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.log('decorator2 前置逻辑');
            const result = originalMethod.apply(this, args);
            console.log('decorator2 后置逻辑');
            return result;
        };
        descriptor.value.__decorator2__ = true;
    }
    return descriptor;
}

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

通过这种方式,可以避免两个装饰器对同一属性描述符的重复修改。

6.3 装饰器与 TypeScript 类型检查问题

在使用装饰器时,有时可能会遇到 TypeScript 类型检查方面的问题。例如,当装饰器修改了类的结构,但 TypeScript 类型定义没有及时更新。解决这个问题的方法是使用类型断言或正确定义类型。

function addPropertyDecorator() {
    return (target: any) => {
        target.prototype.newProperty = 'new value';
    };
}

@addPropertyDecorator()
class TypeCheckClass {}

const instance = new TypeCheckClass();
// 使用类型断言
console.log((instance as any).newProperty);

// 或者正确定义类型
interface TypeCheckClassWithNewProperty extends TypeCheckClass {
    newProperty: string;
}

const newInstance = new TypeCheckClass() as TypeCheckClassWithNewProperty;
console.log(newInstance.newProperty);

通过这些方法,可以确保在使用装饰器修改类结构时,TypeScript 类型检查能够正确工作。