TypeScript 属性与参数装饰器

到目前为止,我们已经掌握了类装饰器(全局改造)和方法装饰器(动作拦截)。但在真实的企业级业务中,我们经常会面临以下两个需求:

  • 针对状态(属性):希望给类的某个字段加上自动格式化功能(比如不管传什么英文,存进去自动变成大写)。
  • 针对入参(参数):希望在复杂的函数中,专门针对某一个参数进行特殊标记(比如标记它来源于 HTTP 请求的 Body)。

为了实现这种极致的 “微操”,我们就需要使用装饰器家族的最后两位成员:属性装饰器与参数装饰器。

TypeScript 属性装饰器

在 TypeScript 中,属性装饰器是贴在类的具体属性(字段)上的。当类被加载时,属性装饰器会被自动调用,并接收以下 2 个参数:

  • target:对于静态属性来说,是类的构造函数。对于实例属性来说,是类的原型对象。
  • propertyKey:被装饰的属性的名称(字符串)。

需要注意的是,与方法装饰器不同,属性装饰器没有第 3 个参数 descriptor(属性描述符)。这是因为目前 TypeScript 的实验性装饰器在初始化属性时,无法直接获取到描述符。

不过别担心,没有条件我们就自己创造条件。我们可以借用 JavaScript 底层的 Object.defineProperty() 来强行劫持属性的 getter 和 setter。

示例 1:实现属性自动大写的拦截器

// 定义属性装饰器
function Uppercase(target: any, propertyKey: string) {
    // 定义一个隐藏的私有键名,用来存放真实的数据
    const hiddenKey = `_${propertyKey}`;

    // 强行拦截该属性的存取过程
    Object.defineProperty(target, propertyKey, {
        // 修正:严格模式下,必须显式声明 this: any,防止隐式报错
        get: function (this: any) {
            // 获取值时,返回隐藏键里的数据
            return this[hiddenKey];
        },
        // 修正:严格模式下,同样需要显式声明 this: any
        set: function (this: any, newValue: string) {
            // 设置值时,强行将其转换为大写,再存入隐藏键中
            console.log(`[系统]: 正在将 ${newValue} 转换为大写`);
            this[hiddenKey] = newValue.toUpperCase();
        },
        enumerable: true,
        configurable: true
    });
}

class User {
    @Uppercase
    public username: string;

    constructor(name: string) {
        // 这里的赋值,会自动触发装饰器里的 setter
        this.username = name;
    }
}

const u1 = new User("Jack");
console.log("最终存入的值:", u1.username);

const u2 = new User("lucy");
console.log("最终存入的值:", u2.username);

运行结果如下。

[系统]: 正在将 Jack 转换为大写
最终存入的值:JACK
[系统]: 正在将 lucy 转换为大写
最终存入的值:LUCY

分析:

这是一个非常经典的避坑案例。很多初学者在写属性装饰器时,会在外面定义一个局部变量 let value = "" 来充当缓存。但由于装饰器是挂载在 target(原型)上的,如果用局部变量,会导致所有实例对象 “共享” 同一个值!

为了保证每个实例化对象的数据互相独立,我们这里使用 this[hiddenKey] 的方式,将数据动态绑定到了每个具体的实例自身(this)上。

TypeScript 参数装饰器

在 TypeScript 中,参数装饰器是贴在函数参数前方的。它接收以下 3 个参数:

  • target:类的构造函数或原型对象。
  • propertyKey:参数所在的方法的名称。
  • parameterIndex:参数在函数参数列表中的索引号(从 0 开始)。

参数装饰器的能力非常有限:它不能修改参数的值,也不能拦截方法的执行。它的唯一作用,就是 “做记录”。

示例 2:参数装饰器用于记录

// 定义参数装饰器
function Inject(target: any, propertyKey: string, parameterIndex: number) {
    console.log(`[路由解析]: 发现方法 ${propertyKey} 的第 ${parameterIndex} 个参数需要注入数据`);
}

class UserController {
    // 将装饰器贴在 userId 这个参数前面
    public getUserInfo(role: string, @Inject userId: number) {
        console.log(`查询用户信息,角色:${role},ID:${userId}`);
    }
}

运行结果如下。

[路由解析]: 发现方法 getUserInfo 的第 1 个参数需要注入数据

分析:

当小伙伴们看到这段代码时,如果你接触过 NestJS 等后端框架,一定会觉得无比亲切!在 NestJS 中,我们经常写出这样的代码:

getUserInfo(@Query("id") id: string)

参数装饰器在底层干的事情非常纯粹:它只是悄悄地在原型上做了一个标记(比如记录下:“getUserInfo 方法的第 1 个参数,需要从 URL 的 Query 里面取值”)。等到真正有网络请求打过来的时候,框架底层的引擎就会读取这个标记,自动把 URL 里的参数剥离出来,完美地 “注入” 到这个位置上。

这就是大名鼎鼎的 “依赖注入(DI)” 机制的基础架构!

框架底层是如何真正 “做记录” 的?

在上面的例子中,我们只是用 console.log() 打印了一下标记。但在真实的框架(比如 NestJS)中,标记是不能随便乱丢的,它必须被严谨地存放到一个专属的 “元数据仓库” 中。

在本章的第一节中,我们曾在 tsconfig.json 里开启了 emitDecoratorMetadata。这就允许我们使用 JavaScript 的高级反射 API(Reflect)来隐蔽地存储数据。

示例 3:使用 Reflect 存储参数元数据

// 注意:真实环境需引入 "reflect-metadata" 库
function Inject(target: any, propertyKey: string, parameterIndex: number) {
    // 定义一个专属的黑话标识
    const META_KEY = "custom:inject_params";

    // 获取以前存过的参数索引数组,如果没有就给个空数组
    const existingParams: number[] = Reflect.getOwnMetadata(META_KEY, target, propertyKey) || [];
    
    // 把当前被装饰的参数索引(比如 1)存进去
    existingParams.push(parameterIndex);

    // 重新写回类的原型中隐蔽保存起来
    Reflect.defineMetadata(META_KEY, existingParams, target, propertyKey);
    
    console.log(`[系统]: 成功将参数索引 ${parameterIndex} 写入元数据仓库`);
}

分析:

通过 Reflect.defineMetadata,参数装饰器就像一个特工,把 “第几个参数需要注入” 这个绝密情报,以 META_KEY 为暗号,死死地刻在了类原型的 DNA 里。

等到网络请求到达时,框架引擎只需要调用 Reflect.getOwnMetadata,就能立刻知道:“哦!原来 getUserInfo 方法的第 1 个参数需要提取出来传进去!” 这就是现代前端框架能够实现全自动 “依赖注入(DI)” 的秘密。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号