这又是一篇短命的(short-lived)描述当下技术框架的博客文章。 DI(依赖注入、Dependency injection)是一种设计模式,常用来提升代码的可复用性、健壮性和可测试性。 DI 在前端的流行很大程度上归功于它在 Angular1.x 中的应用。 Angular 2 以后借助 TypeScript 的类型分析,可以干脆省去了 Angular1.x 中冗余的依赖声明。 本文用来解释这个魔法是怎么工作的,以及相关的标准化 Proposal 和实现 Trick。

对于不熟悉依赖注入的同学可以参考我的另外两篇文章 什么时候应该使用依赖注入JavaScript 依赖注入实现, 或者 Wikipedia: DI。这里还有一篇 Angular 的教程:https://angular.io/guide/dependency-injection

一个例子

Angular1.x

为了方便说明,我们先给一个 Angular1 Dependency Injection 的例子:

angular.module('bar').controller('Bar', function (Foo) {})

在使用上述组件(Bar)时,Angular DI 会创建一个 Foo 并用来初始化 Bar 的实例。 Angular 使用 Function.prototype.toString 来获取控制器 Bar 的参数需要哪些类型。 因此如果上述代码经过 uglify 之后依赖注入会不起作用。因此有另一种写法:

// 因为字符串字面量不会被 uglify,相当于通过字符串字面量来声明依赖
angular.module('bar').controller('Bar', ['Foo', function (Foo) {}])

Angular with TypeScript

再看 TypeScript 下的 Angular2 代码,省去了这个字面量的声明:

import { Foo } from './foo'

@Component({...})
class Bar {
  constructor(private foo: Foo) {}
}

上述 TypeScript 代码中 Bar 只依赖 Foo 的类型, 这意味着 TypeScript 编译上述代码得到的文件中没有对 Foo 的引用。 这里的魔法在于:Angular DI 是怎么在运行时知道 Bar 的第一个参数类型是 Foo?

装饰器

装饰器是一种用来提供面向切面编程功能的一种语法构造。 目前是一个 Stage2 Proposal,还没有进入 ECMA 标准。

在 TypeScript 可以打开 experimentalDecorators 选项来使用。 它的原理就是调用自定义的一个高阶函数,来操作被装饰的函数。 可以参考 TypeScript 教程:https://www.typescriptlang.org/docs/handbook/decorators.html。 比如上面的 Component 装饰器,它会在运行时对 Bar 做一个转换。比如这样:

var __decorator = function() {/*...*/}
var Bar = function (foo) {
    this.foo = foo
}
Bar = __decorate([Component], Bar)

function Component(Bar) {
    // 对 Bar 进行一些操作,甚至返回一个新的类替代原来的 Bar
}

借助于装饰器的机制,我们就有机会在 Component 函数中标记 Bar 的依赖信息。 如果你是分模块编译的,可能需要避免重复输出 __decorator 函数的定义。 设置 importHelpers 编译器参数可以禁止它输出, 然后在运行时统一引入 tslib。 当然如果你在使用 Angular,则应该引入 Angular Polyfill。

获取依赖信息

恰好 TypeScript 提供来一个 emitDecoratorMetadata 开关来产出编译时的类型信息给装饰器。 目前只提供三种类型信息:

  • “design:type”
  • “design:paramtypes”
  • “design:returntype”。

参考这个 Issue https://github.com/Microsoft/TypeScript/issues/2577。 打开 emitDecoratorMetadata 开关后编译后代码会多这样一部分内容:

var Bar = __decorate([
    __metadata('design:paramtypes', [foo_1.Foo])
], Bar);

可以看到它把 Bar 的参数类型 Foo 传给来 __metadata 这个 Helper。 这里和 Angular1.x 的不同还在于这里存的类型就是对函数 Foo 的引用,不是某种序列化后的字符串。 这意味着 Angular DI 不依赖于序列化后的字符串和被注入函数之间的对应关系; 但是也意味着这个类型只能是 class、string、object 这样的既存在于编译期也存在于运行时的类型。 比如 Interface 和 Type 这样的类型信息就无法记录,参考: https://github.com/microsoft/TypeScript/issues/3015#issuecomment-98852421

接下来的问题就是,__metadata 怎么存储这个类型信息 Foo,最终怎么让 Angular DI 读取到。 由于 Angular DI 容器需要在运行时使用这个信息,这就需要 JavaScript 的反射机制。

Metadata Reflection API

反射 用来在运行时检查和操作对象,是 ES6 标准的一部分。 而 Reflect Metadata 提案 则是让 Reflect API 可以提供对类型的元数据进行操作的方法, 需要引入 Polyfill:reflect-metadata

元数据反射机制的灵感来源是 C#、Java 之类的语言。 它们可以在源代码中通过一些标注来给类型设置元数据,并用反射 API 在运行时读取。 总之 Reflect 就是用来存这些元数据(类型信息)的地方。 下面看一下 __metadata 是怎样使用 Metadata Reflection API 的:

var __metadata = this && this.__metadata || function (k, v) {
    if (typeof Reflect === 'object' && typeof Reflect.metadata === 'function')
        return Reflect.metadata(k, v);
};

可以看到 __metadata Helper 把类型信息存到来 Reflect.metadata API 中。

Angular DI 中获取类型信息

既然类型信息已经存入 Reflect API,Angular DI 就可以在创建 Bar 的时候拿到它的参数类型信息了。比如这样:

function create(Bar) {
    const types = Reflect.getMetadata('design:paramtypes', Bar)
    const args = types.map(Type => new Type())
    return new Bar(...args)
}

本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可,转载注明来源即可: https://harttle.land/2019/05/27/how-angular-di-works.html。学识粗浅写作仓促,如有错误辛苦评论或 邮件 指出。