2024-07-06
TypeScript Decorators 原理解析

InversifyJS 的依赖定义和注入依赖 TypeScript 的装饰器实现。因为用到了,所以花了点时间学习了一下。有所收获,所以可以简单聊一聊。

装饰器模式是一种设计模式,指在不改变现有对象结构的情况下,动态的给该对象增加、移除一些功能。

TypeScript 的装饰器从提案到到如今的 stage3 阶段经历了整整九年。其中 stage1 在 TypeScript 1.5 就已经支持,通过 --experimentailDecorators 开启,也是用的最多的;2016 年的 stage2 并没有被广泛使用。

本文讨论的 TypeScript Decorators 是 TypeScript 5.0 及以上支持的 stage3 阶段。(stage3 与 stage1 已存在部分不兼容)。

什么是装饰器?

装饰器本质是一个函数,接收被装饰的对象(target)返回类型相同的对象(result);在被装饰的对象定义前使用 @ 符号表明装饰器,可以用空格或者换行分隔。最简单的实现可以如下:

/** 装饰器 */
function exampleDecorator<T>(target: T): T {
  return target;
}

/** 使用 */
@exampleDecorator
class ExampleClassA {}

/** 也可以同一行,用空格分隔 */
@exampleDecorator class ExampleClassA {}

多个装饰器

同一个被装饰对象可以被多个装饰器装饰,装饰器的执行逻辑为倒序执行。

/** 装饰器 */
function exampleDecoratorA<T>(target: T): T {
  console.log("A");
  return target;
}
function exampleDecoratorB<T>(target: T): T {
  console.log("B");
  return target;
}

/** 可以被多个装饰器装饰 */
@exampleDecoratorA
@exampleDecoratorB
class ExampleClassB {}

// log:B、A

装饰器工厂

装饰器被真正用于生产时,我们通常会习惯编写装饰器工厂返回一个装饰器来增强其代码重用性和可配置性。

function log(type: string) {
  console.log('factory: ', type);
  const decorator: any = function (target: any) {
    console.log('decorator: ', type);
    return target;
  };
  return decorator;
}

@log('class')
class ClassA {
  @log('field') fieldA = 1;
}

值得一题的是,装饰器工厂的运行时机要远早于装饰器函数的运行时机,并且不同被装饰对象的运行时机各不相同,其中类对象的装饰器工厂是最早被执行的,同时它的装饰器函数是最晚被执行的。

即上述示例代码的日志输出为:factory: class => factory: field => decorator: field => decorator: class

具体的原因在后文还会提到。

被装饰对象

上文频繁提到的「被装饰的对象」并非特指「Object」。装饰器一共有 6 种类型,对应 6 种可被装饰的对象,其中 accessor 是 stage3 新增的一种用法。

type Kind = "class" | "method" | "getter" | "setter" | "field" | "accessor"

不同的被装饰对象所需要实现的装饰器也存在细微差别。我们把接口都列出来

Class

type ClassDecorator = (
  value: Function,
  context: {
    kind: 'class';
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;

Method

type ClassMethodDecorator = (
  value: Function,
  context: {
    kind: 'method';
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;

Getter/Setter

type ClassGetterDecorator = (
  value: Function,
  context: {
    kind: 'getter';
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;

type ClassSetterDecorator = (
  value: Function,
  context: {
    kind: 'setter';
    name: string | symbol;
    access: { set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => Function | void;

Field

type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: 'field';
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => (initialValue: unknown) => unknown | void;

Accessor

type ClassAutoAccessorDecorator = (
  value: {
    get: () => unknown;
    set: (value: unknown) => void;
  },
  context: {
    kind: 'accessor';
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  },
) => {
  get?: () => unknown;
  set?: (value: unknown) => void;
  init?: (initialValue: unknown) => unknown;
} | void;

ES Decorators Helpers

由于装饰器目前还仅属于 TypeScript 标准,是无法直接在浏览器或者 Node 环境跑起来的。所以 TSC 在把 TypeScript 编译到 JavaScript 的过程中遇到了装饰器语法需要把它翻译成 JavaScript。

从 TypeScript 源码中我们可以找到翻译装饰器的逻辑集中在 esDecorators.ts 文件中。

找到入口函数 transformSourceFile,观察发现翻译前 TSC 会先插入一段帮助代码:

这段帮助代码叫做 ES Decorators Helper,是一个名叫 __esDecorate 的函数。这个函数的作用就是把装饰器的语法糖翻译成符合 ECMAScript 标准的 JS 代码。

接下来我们就来仔细研究一下这段垫片函数:

由于这段代码会在 TSC 编译的过程中直接插入到源码中,所以可读性是比较差的。我们重点盘一下逻辑:

数据预处理

在真正开始循环运行装饰器函数前,会针对不同类型的属性做一些数据预处理:

属性的描述其实就是 Object.defineProperty()函数返回的数据结构,数据属性通常会把关联的值放在 value 字段上,而访问器就是我们熟悉的 get 或者 set 字段。

什么是属性描述符?看这里:Object.defineProperty() - JavaScript | MDN
const { kind } = contextIn;
const key = kind === 'getter' ? 'get' : kind === 'setter' ? 'set' : 'value';

但是和其他属性不同的是 accessor 同时具有 get 和 set 字段,所以后面有一段硬编码

const value = kind === 'accessor' ? { get: decriptor.get, set: decriptor.set } : descriptor[key]

「属性的宿主」指代该属性真正被挂载的地方。

到这里我们需要回忆一下原型知识:

所有未被显式定义在实例上的属性都被挂载在原型上
静态的属性挂载在构造函数上

我们以一个 Person 对象为例:

class Person {
  age = 10;
  static getAge(persion: Person) {
    return persion.age;
  }
  getAge() {
    return this.age;
  }
}
const person = new Person();
console.log(person.getAge() === Person.getAge(person)); // true

整个 Class 的语法糖翻译和原型链构建流程图如下:

装饰器的预处理时机是远早于对象实例化的时机的,普通的 Field 的宿主为实例,在此时是拿不到的;另外 Class 本身也没有宿主一说。所以 Field 和 Class 的宿主都为 null

其他的属性的宿主是原型还是构造函数取决于属性是否是静态的。

有了这个前置知识点,我们再来看源码就一目了然了。

/**
 * 1. kind === class || kind === field   => target = null
 * 2. static                             => target = constructor
 * 3. others                             => target = prototype
 */
const target = !descriptorIn && ctor ? (contextIn.static ? ctor : ctor.prototype) : null;

有了 target,我们可以借助 Object.getOwnPropertyDescriptor 静态方法拿到属性的描述

Class 没有描述的说法,为了让它能够像普通属性一样被装饰器栈迭代处理,会从外部传入一个参数 descriptorIn来模拟属性描述。

/**
 * 1. kind === field && !static  => descriptor = {}
 * 2. kind === class             => descriptor = { value: constructor }
 * 3. others                     => descriptor = Object.getOwnPropertyDescriptor(target, contextIn.name)
 */
const descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});

前面提到过装饰器函数的运行时机早于实例化时机,所以普通的 Field 没有描述且在此处不用做任何处理。

装载装饰器

有了 targetdecriptor 就可以真正的开始装载装饰器了。

装饰器本质是一个函数,并且我们在使用的时候允许对一个属性添加多个装饰器。

所以装载的核心代码是遍历运行一次装饰器函数队列,上文提到过多个装饰器是倒序运行的,所以遍历也是反向的:

for (const i = decorators.length - 1; i >=0; i--) {
  const result = decorators[i]();
}

是不是瞬间觉得很简单。

还有一个需要明确的点是:装饰器的装载过程是一个 reduce 的过程,下个装饰器消费上个装饰器的加工结果产生新的结果,以此遍历得到的最终结果会覆盖原有属性。

在循环中需要处理两个东西:上下文(context)和结果(result)。

构造上下文

上下文是装饰器 stage3 提案新增的入参。

不同类型属性的上下文会有些不同,具体的不同点在上文的「被装饰对象」接口都已经列举出来了,这里不多赘述。

这里要关注的是两点:

上下文变量 context 来自语法糖翻译时直接传入的参数 contextIn 的浅拷贝,且浅拷贝在每个装饰器装载时都会进行。这意味 context 对于装饰器是只读的,且不参与 「reduce」的。
所有类型的属性公共函数 context.addInitializer 会尝试往 extraInitializers 队列末尾添加额外的装载器。该行为仅能在装载过程中进行
/** 浅拷贝,但 access 字段需要下钻一层浅拷贝 */
const context = {
  ...contextIn,
  access: contextIn.access ? { ...contextIn.access } : undefined,
  addInitializer: function(f) {
    /**
     * 如果已经装载完成就不允许入队
     * 滞后的调用、异步的调用都是非法的
     */
    if (done) throw new TypeError('Cannot add initializers after decoration has completed');
    extraInitializers.push(f);
  }
};

extraInitializers 队列的执行时机视属性类型而定,此处暂时按下不表。

迭代结果

把装饰器函数运行的结果作为描述符的值直到运行完成之后回写到宿主对应的属性上。

类型为 field 的属性比较特别,此时它的值还不存在。所以 field 类型的装饰器的返回值只能是空值或者函数,若是函数则会被添加到 initializers 队列的开头

除此之外 stage3 新增的语法糖 accessor 也很特别,它是 field 和访问器的结合。因此它不但要覆写描述符还同样需要往initializers 队列开头添加 init 函数。

/** 循环内 */
const value = kind === 'accessor' ? { get: decriptor.get, set: decriptor.set } : descriptor[key];
const result = decorators[i](value, context);

if (kind === 'accessor') {
  if (result.get) descriptor.get = result.get;
  if (result.set) descriptor.set = result.set;
  if (result.init) initializers.unshift(result.init);
} else if (kind === 'field') {
  initializers.unshift(result);
} else {
  descriptor[key] = result;
}

/** 循环外 */
if (target) {
  Object.defineProperty(target, contextIn.name, descriptor);
}

语法糖翻译

上述的 __esDecorate 函数帮我们完成了 90% 的翻译工作。剩下的工作在对象内完成。

仍然以上述的 Person 对象为例,我们为它增加几个属性补全装饰器的所有场景:

@d("class") class Person {
  @d("static field") static isAnimal = true;
  @d("field") age = 10;
  @d("accessor") accessor sex = "man";
  constructor(age: number, sex: string) {
    this.age = age;
    this.sex = sex;
  }
  @d("static function") static getAge(person: Person) {
    return person.age;
  }
  @d("function") getAge() {
    return this.age;
  }
}

Accessor 基本涵盖了 Getter/Setter 的能力,在示例中 Getter/Setter 就精简掉了,下文涉及到的地方会提到。

调用帮助函数

每一个被装饰器装饰的属性都调用一次帮助函数,调用的格式和顺序如下:

__esDecorate(Person, null, [d("static method")], { kind: "method", name: "getAge", static: true }, null, staticMethodExtraInitializers);
__esDecorate(Person, null, [d("accessor")], { kind: "accessor", name: "sex" }, accessorInitializers, accessorExtraInitializers);
__esDecorate(Person, null, [d("method")], { kind: "method", name: "getAge" }, null, methodExtraInitializers);
__esDecorate(null, null, [d('static field')], { kind: "field", name: "isAnimal", static: true }, staticMethodInitializers, staticMethodExtraInitializers);
__esDecorate(null, null, [d('field')], { kind: "field", name: "age" }, fieldInitializers, fieldExtraInitializers);
__esDecorate(null, descriptor = { value: Person }, [d('class')], { kind: "class" }, null, classExtraInitializers);

翻译顺序没有太多意义,只需要注意一下 Class 本身是最后被调用的即可,不过还是列一下: static method => accessor => method => getter => setter => static field => field => class

好了,接下来我们要逐渐回收上文留下的一些伏笔!

Class 覆盖

上文提到普通的属性在最终会调用 Object.defineProperty 函数,把装饰器迭代的最终结果覆盖到宿主的同名属性上。而 Class 是覆盖它本身,这也是为什么它是放在最后的。

Person = descriptor.value;

Static ExtraInitializers & Static Field

上文提到在装饰器中调用 context.addInitializer 函数可以往 extraInitializers 队尾添加一个初始化函数,静态属性的 extraInitializers 就是在此处被运行,Class 的 extraInitializers 队列在最后运行。

Static Field 和普通的 Field 一样会在此处初始化值。

__runInitializers(Person, staticMethodExtraInitializers);
Person.isAnimal = __runInitializers(Person, staticMethodInitializers, true);
__runInitializers(Person, staticMethodExtraInitializers);
__runInitializers(Person, classExtraInitializers);

这里的 __runInitializers 也是一个帮助函数,它的功能基本等效于 Array.reduce。由于它非常简单这里不做过多介绍,在后面我们还会多次用到它:

至此,所有在对象声明时要做的事情就已经做完了。剩下几个属性的逻辑在构造函数中处理,也就是说接下来的逻辑仅会在对象实例化时被执行。

构造函数语法糖

Accessor 和普通 Field 的值在构造函数中迭代赋值。

其他类型的 extraInitializers也是在此时被执行的,且 initializers 会先于 extraInitializers。这是需要特别注意的,很容易陷入在声明时执行的误区。

constructor(age, sex) {
  __runInitializers(this, methodExtraInitializers);

  this.age = __runInitializers(this, fieldInitializers, 10);
  __runInitializers(this, fieldExtraInitializers);

  sexAccessorStorage.set(this, __runInitializers(this, accessorInitializers), 'man');
  __runInitializers(this, accessorExtraInitializers);

  this.age = age;
  this.sex = sex;
}

另外,原本构造函数中的逻辑会在随后执行,如果有赋值操作也会直接覆盖装饰器的结果。

Accessor

相信大家会对上文代码块中的 sexAccessorStorage感到疑惑。它实际也是对 Accessor 语法糖的翻译,考虑到 Accessor 本身是为装饰器服务的关键字,所以也算是本文的范畴,这里做额外的解释。

Accessor 相当于 Field + Getter + Setter 的缩略写法。便于理解的翻译如下:

class Person {
  accessor sex = 'man';
}

/** 不完全等效于 */
class Person {
  private _sex = 'man'
  get sex() {
    return this._sex;
  }
  set sex(v: string) {
    this._sex = v;
  }
}

不完全等效的原因是 _sex并不是 Person 的属性,它只是一个在对象声明范围内的局部变量。

TSC 使用 WeakMap 来存储这一变量值

/** 这里做了简化,实际上这个变量是被包裹在对象声明范围内的闭包里 */
const sexAccessorStorage = new WeakMap();

class Person {
  constructor() {
    sexAccessorStorage.set(this, 'man');
  }
  get sex() {
    return sexAccessorStorage.get(this);
  }
  set sex(v: string) {
    sexAccessorStorage.set(this, v);
  }
}

使用 WeakMap 的意义很明确,Accessor 的值和对象实例应当有相同的生命周期,当实例被销毁时,Accessor 的值也可以被 GC。

Q: 试想一下,如果使用 Map 来维护会有什么问题? A: 每次 new Person()都会触发 Map.set(),但又缺乏一个合适的时机 Map.delete(),随着实例数量不断增多很容易导致内存泄露。

总结

小小的装饰器有巨大的学问。

装饰器在复杂的业务场景中有巨大的作用,本文算是一个抛砖引玉,只讲了原理没有讲用法。

不过吃透原理尤其是内部实现是非常重要的,应用的场景万变不离其宗,后面我会结合 Reflect metadata 讲讲 InversifyJS 的依赖注入是怎么实现的。