TypeScript 装饰器与 IoC 机制

492 阅读14分钟

前言

在工作中用到了 vue-property-decorater 这个库,用到了很多 vue 相关的 TypeScript 修饰器,萌生了对 IoC 机制在这其中作用的兴趣。本文将从 TypeScript 装饰器的诞生背景开始,介绍不同种类装饰器的使用场景和功能,再到原数据反射与 IoC 机制。

TypeScript 装饰器简介

首先,装饰器是什么?简单地说,装饰器是一种应用在类及其内部成员的语法,它的本质其实就是函数。它能很好地隐藏掉许多功能的实现细节。

@Logger()
class Foo {}

加上 @Logger() 这个装饰器后,可以给 Foo 这个类的所有方法添加日志功能。我们甚至能够完全修改掉这个类的功能行为,而只需要这一行代码。这使得内部实现十分黑盒,但仔细想想,装饰器实际就相当于是 utils 方法,提供给外部使用,我们本就不希望使用者关心内部的逻辑,只需要知道它的作用,装饰器就是这样的一种函数存在。

为什么我们需要装饰器?基于装饰器我们能够快速优雅的复用逻辑,对业务代码进行能力增强。同时我们本文的重点:依赖注入也将使用装饰器的元数据反射能力实现。

注解与装饰器

注解,是一种向类声明添加元数据的方法,用于依赖注入或编译指令。但是不能实现任何操作。简单来说就是,它不能修改类或方法的行为,只是起到注解的作用。 装饰器,不能添加元数据,只能基于已经由注解注入的元数据来执行操作,来对类以及内部成员如方法、属性、方法参数进行某种特定的操作。

注解和装饰器几乎是相同的:

注解和装饰器几乎是相同的东西,从一个消费者观念,我们有完全相同的语法,唯一不同的是我们不能控制如何将注解作为元数据添加到我们的代码中。而装饰器更像是一个构建最终成为装饰器的东西的接口。

我们来看下 TypeScript 中的装饰器。

不同的装饰器及使用

类装饰器

    function addProp(constructor: Function) {
        constructor.prototype.job = 'fe';
    }
    @addProp
    class Person {
        job: string;
        constructor(public name: string) {}
    }
    let p = new Person('Joey')
    console.log(p.job); // fe

我们发现,使用装饰器@addProp 调用时,不管用它来装饰哪个类,起到作用都是相同的,即修改类上的属性。因为这里装饰器的逻辑是固定的。这样肯定不是我们想要的,至少要支持调用时传入不同的参数来将属性修改为不同的值。

    function addProp(param: string): ClassDecorator {
        return (constructor: Function) => {
            constructor.prototype.job = param;
        }
    }
    
    @addProp('fe')
    class Person {
        job: string;
        constructor(public name: string) {}
    }
    const p = new Person('Joey')
    console.log(p.job); // fe

需要明确的是,TS 中的装饰器实现本质就是一个语法糖,它的本质是一个函数,如果调用形式为 @deco() (即上面的例子),那么这个函数应该返回一个函数来实现调用,我们把这个函数叫做装饰器工厂,所以 addProp 方法再次返回了一个 ClassDecorator。应用在不同位置的装饰器讲接收的参数是不同的,如这里的类装饰器接收的参数就是类的构造函数。

方法装饰器

方法装饰器的入参为类的原型对象属性名以及属性描述符,属性描述符包含writable, enumerable, configurable,我们可以在这里去配置其相关信息,如禁止这个方法再次被修改。

注意,对于静态成员来说,首个参数会是类的构造函数。而对于实例成员(比如下面的例子),则是类的原型对象。

function addProps(): MethodDecorator {
    return (target, propertyKey, descriptor) => {
        descriptor.writable = false
    }
}
class A {
    @addProps()
    originMethod() {
        console.log("I'm Original!")
    }
}

const a = new A();
a.originMethod = () => {
    console.log("I'm Changed!")
}
a.originMethod(); // I'm Original!

看到这,发现很像我们用的 Object.defineProperty(),的确方法装饰器也是借助它来修改类和方法的属性的,你可以在 TypeScript Playground 中看看 TypeScript 对上面代码的编译结果。

属性装饰器

类似于方法装饰器,但它的入参少了属性描述符。原因则是目前没有方法在定义原型对象成员的同时,去描述一个实例的属性(创建描述符)。

function addProps(): PropertyDecorator {
    return (target, propertyKey) => {
        console.log(target, propertyKey)
    }
}
class A {
    @addProps()
    originProps: unknown;
}

属性与方法装饰器都有一个重要作用就是注入与提取元数据,这点后面会体现到。

参数装饰器

参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数重的索引。

function paramDeco(params?: any): ParameterDecorator {
    return (target, propertyKey, index) => {
        target.constructor.prototype.fromParamDeco = 'Foo';
    }
}
class B {
    someMethod(@paramDeco() param1: unknown, @paramDeco() param2: unknown) {
        console.log(`${param1} ${param2}`)
    }
}
new B().someMethod('A', 'B'); // A B
console.log(B.prototype.fromParamDeco); // Foo

参数装饰器与属性装饰器都有个特别之处,他们都不能获取到描述符的 descriptor,因此也就不能去修改其参数/属性的行为。但是我们可以这么做:给类原型添加某个属性,携带上与参数/属性/装饰器相关的元数据,并由下一个执行的装饰器读取。

当然像例子中这样直接在原型上添加属性的方式是十分不推荐的,后面会使用 ES7 的 Reflect Metadata 来进行元数据的读/写。

装饰器工厂

假设现在我们同时需要四种功能相近的装饰器,你会怎么做?定义四种装饰器然后分别使用吗?也行,但其实有更好的方法,工厂模式,使用一个装饰器工厂来为我们提供条件生成不同的装饰器。

首先,我们准备好各个装饰器函数:

function classDeco(): ClassDecorator {
    return (target: Object) => {
        console.log('Class Decorator Invoked', target);
    }
}
function propDeco(): PropertyDecorator {
    return (target: Object, propertyKey: string | symbol) => {
        console.log('Property Decorator Invoked', propertyKey)
    }
}
function methodDeco(): MethodDecorator {
    return (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
        console.log('Method Decorator Invoked', propertyKey)
    }
}
function paramDeco(): ParameterDecorator {
    return (target: Object, propertyKey: string | symbol, index: number) => {
        console.log('Param Decorator Invoked', propertyKey)
    }
}

接着,我们实现一个工厂函数来根据不同条件返回不同的装饰器:

enum DecoratorType {
    CLASS = 'CLASS',
    METHOD = 'METHOD',
    PROPERTY = 'PROPERTY',
    PARAM = 'PARAM',
}
type FactoryReturnType = 
    | ClassDecorator
    | MethodDecorator
    | PropertyDecorator
    | ParameterDecorator;
    
function decoFactory(this: any, type: DecoratorType.CLASS, ...args: any[]): ClassDecorator;
function decoFactory(this: any, type: DecoratorType.METHOD, ...args: any[]): MethodDecorator;
function decoFactory(this: any, type: DecoratorType.PROPERTY, ...args: any[]): PropertyDecorator;
function decoFactory(this: any, type: DecoratorType.PARAM, ...args: any[]): PropertyDecorator;

function decoFactory(  
  thisany,  
  type: DecoratorType,  
  ...args: any[]  
): FactoryReturnType {  
  switch (type) {  
    case DecoratorType.CLASS:  
      return classDeco.apply(this, args);  
  
    case DecoratorType.METHOD:  
      return methodDeco.apply(this, args);  
  
    case DecoratorType.PROPERTY:  
      return propDeco.apply(this, args);  
  
    case DecoratorType.PARAM:  
      return paramDeco.apply(this, args);  
  
    default:  
      throw new Error('Invalid DecoratorType');  
  }  
}

@decoFactory(DecoratorType.CLASS)
class C {
    @decoFactory(DecoratorType.PROPERTY)
    prop: unknown;
    
    @decoFactory(DecoratorType.METHOD)
    method(@decoFactory(DecoratorType.PARAM) param: string) {}
}
new C().method('foobar')

以上是一种方式,也可以通过判断传入的参数,来判断当前的装饰器被应用在哪个位置。

元数据反射

这里我们分成四点来学习:

  1. 为什么我们需要反射?
  2. 元数据反射 API

为什么我们需要反射?

反射用于描述能够检查同一系统中的其他代码的代码。反射有很多用例场景,例如 组合,依赖注入,运行时类型断言等。

我们的 JavaScript 应用程序越来越大,所以我们开始需要一些工具比如控制反转容器,以及一些功能例如运行时类型断言之类的特性来管理这种日益增长的复杂性。

问题是,由于 JavaScript 中没有反射,所以一些功能和工具无法实现,或者说它们不能像 C# 和 Java 中的功能那么强大。

一个强大的反射 API 应该允许我们在运行时检查一个未知的对象,并找出它的一切信息。我们应该能够知道:

  • 实体的名称;
  • 实体的类型;
  • 实体实现了哪些接口
  • 实体属性的名称和类型
  • 实体构造函数参数的名称和类型

在 JavaScript 中可以用诸如 Object.getOwnPropertyDescriptor() 或者 Object.keys() 来找到一些关于实体的信息,但是我们需要反射来实现更强大的开发工具。

TypeScript 的出现支持了一些反射的功能。

元数据反射 API

我们可以通过使用 reflect-metadata 包来使用元数据反射 API 的Polyfill。

npm install reflect-metadata

我们必须将它与 Typescript 1.5 一起使用,并且将编辑器中的 emitDecoratorMetadata 标识为 true 才能使用。同时,我们还需要引入 reflect-metadata.d.ts,以及加载 Reflect.js 文件。

接下来我们就可以用任意一种 reflect 元数据设计的键。目前只有三种选择:

  • 类型元数据使用的键design:type
  • 参数类型元数据使用的键design:paramtypes
  • 返回类型元数据使用的键design:returntype

让我们来看一组例子。

A) 用 reflect 元数据 API 获取类型元数据

首先,声明一个属性装饰器:

function logType(target: any, key: string) {
    const t = Reflect.getMetadata("design:type", target, key)
    console.log(`${key} type: ${t.name}`)
}

我们把它用在一个类属性上获取它的类型:

class Demo {
    @logType
    public attr1: string
}

以上的例子会在控制台打印出以下内容

attr1 type: string

B) 获取参数类型元数据

首先声明一个参数修饰器:

function logParamTypes(target: any, key: string) {
    const type = Reflect.getMetaData("design:paramtypes", target, key);
    const s = types.map(t => t.name).join();
    console.log(`${key} param types: ${s}`)
}

把它用在类方法中获取它所有参数的类型信息:

class Foo{}
interface IFoo {}

class Demo {
    @logParamTypes
    doSomething(
        param1: string,
        param2: number,
        param3: Foo,
        param4: { test: string },
        param5: IFoo,
        param6: Function,
        param7: { a: number } => void
    ): number {
        return 1
    }
}

上面的例子会在控制台打印出以下内容:

doSomething param types: String, Number, Foo, Object, Object, Function, Function

C) 获取返回值类型元数据

同样我们可以用元数据设计的键获取返回类型信息

Reflect.getMetadata("design:returntype", target, key);

IoC

概念介绍:IoC、容器、依赖注入

IoC 的全称为 Inversion of Control,意思是控制反转,它是 OOP 中的一种设计原则,常用于代码解藕。

再举个🌰,当我们想要查攻略时,我们会上小红书、微博等一个个去找,找哪种跟怎么找都是由我自己决定的,这叫控制正转。现在我觉得有点麻烦,直接把自己想查的信息发布到平台上,如果有人愿意分享,就会主动向我发起聊天或者留言,这就叫控制反转

想象这样一个场景:

有这么一个类 A,它的代码内部使用到了另一个类 B,需要去实例化它。在不使用 IoC 的情况下,我们很容易写出这样的代码:

import { B } from './B';

class A {
    constructor(p: number) {
        this.b = new B(p)
    }
}

乍一看没什么问题,但实际上类 C 会强依赖于 A、B,造成模块之间的耦合。如果后续A、B的实例化参数变化,或者是A、B内部又依赖了别的类,那么维护起来非常困难。

那我们要如何实现解藕呢?事实上我们发现在以上的例子中,只有 B 需要参数 p,而 C 接受 p 只是为了实例化依赖的对象,它本身并不关心这个参数。

因此我们可以考虑将依赖对象的实例化移到构造函数外面,例如:

import { B } from './B';

class A {
    constructor(b: B) {
        this.b = b
    }
}
// main.ts
const b = new B(10)
const a = new A(b)

在这个例子中,A 接受的参数为 B 的实例对象,也就是它的依赖对象,这样它就不需要去关心这个对象是如何被初始化的了。这种方法解决了我们之前的耦合问题,我们可以直接改变参数 p 而不需要去改动 A。

容器

即使我们实现了解藕,但是我们仍然需要手动实例化所有的类,并且传给它们对应的参数。是否存在一种全局对象可以预先注册所有的类定义以及我们需要的初始化参数,并且赋予每个对象一个 unique_key,然后我们只需要告诉容器对应的 unique_key 就能直接拿到实例化后的对象呢?如果有的话我们就不需要关心这些对象是怎么被实例化,也不需要在依赖链中将它们作为构造函数的参数传递了。

换句话说,我们的容器必须有两个方法,绑定实例和获取实例。我们可以用 Map 简单实现一个容器:

export class Container {
    bindMap = new Map()
    
    bind(identify: string, clazz: any, constructorArgs: Array<any>) {
        this.bindMap.set(identify, { clazz, constructorArgs });
    }
    get<T>(identifier: string): T {
        const target = this.bindMap.get(identifier);
        const { clazz, constructorArgs }  = targer;
        const inst = Reflect.constructor(clazz, constructorArgs);
    }
}

这里我们使用了 Reflect.construct,它的行为跟 new 操作符是一样的,都是实例化一个对象。有了这个容器,我们可以解放双手,不再需要传不关心的参数,实现解藕:

import { B } from './B';

class A {
    constructor() {
        this.b = container.get('b')
    }
}
// main.ts
const container = new Container()
container.bind('a', A)
container.bind('b', B, [10])

// get from container
const a = container.get('a');
console.log(a); // A => { b: B { p: 10 }}

到现在其实已经基本实现了 IoC,基于容器进行了解藕。但是从代码上看,代码看起来并不如之前那么“干净”,相反,容器初始化和类的注册的工作令人乏味。如果这部分代码能够封装成一个框架,所有的类注册都可以自动“连线”,同时所有的类都可以在构造函数执行期间获取到依赖对象实例,而不需要在构造函数手动获取,那我们就可以完全解放双手了!我们就可以只关注类中的逻辑,避免花很多时间写重复性的代码。而这就是依赖注入在做的事情。

依赖注入

DI 的全称为 Dependency Injection,即依赖注入。依赖注入是控制反转最常见的一种实现,就如它的名字一样,它的思路就是在对象创建时自动注入依赖对象。这就是通过依赖倒置编写的最终代码。

// b.ts
@Provide('b', [10])
class B {
    constructor(p: number) {
        this.p = p
    }
}
// a.ts
@Provide('a')
export class A {
    @inject()
    private b: B;
    
    async getUser(id) {
        return await this.userModel.get(id)
    }
}

由于篇幅过长,这里就不展开介绍依赖注入的实现了。

基于 IoC 机制的路由简易实现

如果你用 Nestjs 写过应用,那么肯定熟悉下面这样的代码:

@provide()
@controller('/user')
export class UserController {
    @get('/all')
    async getUser(): Promise<IUser[]> {/* ... */}
    
    @get('/uid/:uid')
    async findUserByUid(): Promise<IUser> { /* ... */ }
    
    @post('/uid/:uid')
    async updateUser(): Promise<IUser> { /* ... */ }
}

这种基于装饰器声明路由的方式十分优雅,你可以通过装饰器非常容易的定义路由层面的拦截器与中间件等操作。

那么它们又是如何实现的呢?假设我们要解析以上路由,首先思考 controllerget/post装饰器,我们需要使用这几个装饰器注入哪些信息:

  • 路径
  • 方法(方法装饰器) 首先是对于整个类,我们需要将 path: "/user" 这个数据注入:
export enum METADATA_MAP {
    METHOD = 'method',
    PATH = 'path',
    GET = 'get',
    POST = 'post',
    MIDDLEWARE = 'middleware',
}

const { METHOD, PATH, GET, POST } = METADATA_MAP;

export const controller = (path: string): ClassDecorator => {
    return (target) => {
        Reflect.defineMetadata(PATH, path, target);
    }
}

而后是方法装饰器,选择一个高阶函数去输出各个方法的装饰器,而不是为每个方法定义一个。

export const methodDecorator = (method: string) => {
    return (path: string): MethodDecorator => {
        return (_target, _key, descriptor) => {
            Reflect.defineMetadata(METHOD, method, descriptor.value!)
            Reflect.defineMetadata(PATH, path, descriptor.value!)
        }
    }
}

const get = methodDecorator(GET)
const post = methodDecorator(POST)

接下来我们要做的事情就很简单了:

  1. 拿到注入在类上元数据的根路径
  2. 拿到每个方法上元数据的方法、路径
  3. 拼接,生成路由表
const routeGenerator = (ins: Record<string, unknown>) => {
    const prototype = Object.getPrototypeOf(ins)
    const rootPath = Reflect.getMetadata(PATH, prototype['constructor'])
    const methods = Object.getOwnPropertyNames(prototype).filter(item => item !== 'constructor');
    const routeGroup = methods.map(methodName => {
        const methodBody = prototype[methodName];
        const path = Reflect.getMetadata(PATH, methodBody)
        const method = Reflect.getMetaData(METHOD, methodBody)
        
        return {
            path: `${rootPath}${path}`,
            method,
            methodName,
            methodBody
        }
    })
    console.log(routeGroup);
    return routeGroup;
}

生成的结果大概就是这样:

[  
  {  
    path'/user/all',  
    method'post',  
    methodName'getAllUser',  
    methodBody: [Function (anonymous)]  
  },  
  {  
    path'/user/update',  
    method'get',  
    methodName'updateUser',  
    methodBody: [Function (anonymous)]  
  }  
]

在 Vue 中使用装饰器

如果项目中没有使用 ts,也可以使用装饰器。如果项目是 vue-cli 搭建的,并且版本 >2.5, 那么不需要进行任何配置旧可以使用。如果项目还包含 eslint,那么需要在 eslint 中开启装饰器相关的语法检测:

parserOptions: {
    ecmaFeatures: {
        legacyDecorators: true, // 支持装饰器
    }
}

在项目中我们经常会使用二次弹窗进行删除操作,这时候就可以封装成一个装饰器:

export function confirm(header, body, confirmBtn = '确定') {
    return function(target, name, descriptor) {
        const originMethod = descriptor.value
        descriptor.value = function(...args) {
            const confirmDia = DialogPlugin.confirm({
                header,
                body,
                confirmBtn,
                cancelBtn: '取消',
                onConfirm: ({ e }) => {
                  Promise.resolve(originMethod.apply(this, ...args))
                      .finally(() => {
                          confirmDia.hide();
                      })

                },
                onClose: () => {
                  confirmDia.hide();
                },
            });
        }
        return descriptor
    }
}

我们在 del() 方法上用装饰器

export default {
    methods: {
        @comfirm('操作提示', '请确认是否删除!')
        del(id) {
            const res = await api.delPost(id)
            if (res.data.code === 200) {/* 成功删除后的逻辑 */}
        }
    }
}

除此之外,还可以封装诸如防抖截留等装饰器,可以手动尝试实现一下,加深对装饰器用法的理解。

总结

这篇文章是对 TypeScript 中装饰器与 IoC 机制的一些个人简介,如果感兴趣,不妨去看一下 TypeScript 对装饰器、反射器的编译结果,可以直接使用 TypeScript Playground