依赖注入的TypeScript实现

️:轮子不是圆的吗?为什么是方的。

:因为圆能用。

要说依赖注入(DI,Dependency Injection),就不得不提控制反转(IoC,Inversion of Control),它们几乎已经是绑定概念了,那么两者有什么关系和区别呢。

很多文章会把两者混为一谈,但以我的理解,IoC是一种概念性的设计思想,除开Spring、Angular,就如React、Vue这样的框架,我们按照框架的规则写代码,框架拿到我们的代码进行一系列转换然后执行,这也算控制反转。相反的如jQuery、Lodash这样的工具库,我们调用库的方法实现目的,就不是控制反转。 回到DI,DI则是IoC思想的一种具体模式,主要用于降低Class之间的耦合。在Spring、Nest.js、Angular等框架中依赖注入是基本概念。

本文将基于TS实现一个基础的依赖注入过程,主要体现依赖注入的基本原理。

先看如下代码。

 
  

ts

复制代码

import { AuthService } from "./AuthService"; class AuthController { constructor() { this.authService = new AuthService(); } login() { this.authService.xxx() } }

我们在controller引入service类,并且直接实例化,类之间是强依赖的。

而在Java Spring、Nest.js类似框架里,将大概是下面这样子。

 
  

ts

复制代码

/** AuthService.ts **/ @Service() // 这个类要交给IoC容器 class AuthService { xxx() { // xxx } }

 
  

ts

复制代码

/** AuthController.ts **/ import { AuthService } from "./AuthService"; class AuthController { @AutoWired() // 容器帮忙创建和注入AuthService authService: AuthService; login() { this.authService.xxx() } }

这里没有再手动的new对象,而是给属性打上了一个注解,容器就会实例化对应的类再赋值给这个属性。这样做的好处有:

  1. 方便协同,如果两个类是两位同学一起写,只需要先定义好interface,引入interface作为类型。对方没有开发好这边也不会报错,Class的依赖也彻底没有了。(但是TS无法实现基于interface的自动注入,下面会实现基于abstract class的注入作为替代方案)
  2. 方便替换,如果我们有一个重构的V2版本的service,只需要更换一下实现类即可。
  3. 方便测试,只需要给到IoC容器一个简单的Mock实例,即可进行测试,不需要考虑内部new实例的副作用。

前置知识

装饰器

这里简单说一下,装饰器实质上就是高阶函数的语法糖,可以作用在类、方法、访问符(getter、setter)和方法参数上,可以获取并修改被作用元素的元信息。具体的用法大全与其我把文档搬过来,不如直接去看官方文档咯~

这里主要用到的是类装饰器和属性装饰器。

  • Component是类装饰器,打在要交给容器管理的类上。

  • AutoWired是属性装饰器,打在需要容器帮忙注入实例的属性上。(暂且只实现属性注入)

元信息

我们在装饰器中,可以拿到被作用元素的一些信息,比如类装饰器中可以拿到类的构造函数、构造函数名字(即类名),这些是默认就有的,也可以给其define一些新的信息,这些信息就称为元信息。我们可以通过定义和读取元信息,得到各个部件的依赖关系。

需求分解

  1. 需要一个IoC容器类来负责创建和注入实例。
  2. 需要@Component装饰器标准要被容器管理的类。
  3. 需要@AutoWired装饰器标注需要自动注入的属性。

假设我们要实现一个获取用户信息的功能,采用Web最常用的Controller -- Service -- Repository架构。

容器实现:

我们维护两个列表,一个注册方法,一个get方法。然后就可以先把容器new出来了。

 
  

ts

复制代码

class Container { /** * components维护组件列表 * instances维护实例列表 */ components = new Map(); // key -> Constructor instances = new Map(); // key -> Instance /** * 注册组件 * @param constructor 被装饰的类的构造函数 * @param alias 该组件的名字,默认取类名 */ regist(constructor: Function, alias?: string) { let name = alias; if (!name) { name = constructor.name; } if (this.components.has(name)) { console.warn("重复注册Component: " + name); } this.components.set(name, constructor); console.log(this); } /** * 获取实例,实例是懒加载的单例,第一次获取时创建 * @param alias 组件名字 */ get(alias: string) { if (this.instances.has(alias)) { return this.instances.get(alias); } const component = this.components.get(alias); if (!component) { throw "未注册: " + alias; } const ins = new component(); this.instances.set(alias, ins); console.log(this); return ins; } } const iocContainer = new Container();

@Component实现

这个非常简单,target就是UserRepo的构造函数,拿到构造函数target和别名alias,注册进容器然后将构造函数原样返回即可。

 
  

ts

复制代码

function Component(alias?: string) { return function (target: any) { iocContainer.regist(target, alias || target.name); return target; }; }

 
  

ts

复制代码

@Component() class UserRepo { getUserById(id: number) { return { user: "jj.zhang", id }; } }

然后容器的components会有UserRepo的构造函数。

依赖注入的TypeScript实现_第1张图片

@AutoWired实现

 
  

ts

复制代码

function AutoWired(alias?: string) { return function (target: any, propertyName: string) { let name = alias; if (!name) { const classConstructor = Reflect.getMetadata( "design:type", target, propertyName ); console.log(99, classConstructor, target, propertyName); name = classConstructor.name; if (name === "Object") { // 没有写类型,则尝试将属性名转大写查找实例 name = camelcase(propertyName, { pascalCase: true }); } } const instance = iocContainer.get(name || ""); target[propertyName] = instance; return instance; }; }

 
  

ts

复制代码

@Component() class UserService { @AutoWired() userRepo!: UserRepo; getUserById(id: number) { return this.userRepo.getUserById(id); } }

这里用到Reflect.getMetadata("design:type", target, propertyName)

  • target是UserService的原型对象

  • propertyName是属性的名字,即"userRepo"

  • design:type是内置的元数据key

整句表示获取target["userRepo"]的类型即被装饰属性的构造函数,构造函数的name属性就是我们要的属性类型字符串,也就是UserRepo,然后找容器要一个UserRepo实例,赋值给target["userRepo"]属性。

如果属性没有声明类型,比如这样:

 
  

ts

复制代码

@AutoWired() userRepo;

则name属性拿到的是"Object",此时我们就将属性名propertyName转换为pascalCase作为alias去容器取实例。

最后容器的instances就会多出一个UserRepo的实例了。

依赖注入的TypeScript实现_第2张图片

抽象类注入

Java Spring的接口注入是我觉得很精髓的地方,巧妙运用了多态的特性。

但是在TypeScript,装饰器中拿不到接口相关的信息,所以就无法实现接口注入了。

 
  

ts

复制代码

function Component(target: any) { // 这里拿不到任何关于TestInterface的信息 } interface TestInterface { test(): string; } @Component class A implements TestInterface { test() { return 'hello tuya'; } }

但是可以使用抽象类实现类似的效果:

  1. 容器新增一个abstracrs列表,维护每个抽象类的所有实现子类。

  2. 新增一个@Impl装饰器,收集每一对抽象类 & 子类,注册到容器,并写入元信息表明这是一个抽象类。

  3. 修改@AutoWired注入逻辑,根据第2步写入的元信息判断,如果属性类型是抽象类,就在该抽象类的所有实现类中,查找和属性名匹配的类实例,进行注入。

最后容器就是这个结构,顺藤摸瓜就可以找到实现类了。

但有意思的事,不管是Nest.js还是Midway的Injection库,都没有提供类似的方案,或许这是个伪需求?

毕竟即使在Java项目中,多数interface从生到死也只有一个Implements

依赖注入的TypeScript实现_第3张图片

  • 效果(精简版,完整代码比较长就不贴了,想看的点这里)
 
  

ts

复制代码

abstract class IUserService { } @Impl(IUserService) @Component() class UserService extends IUserService { } @Impl(IUserService) @Component() class UserServiceV2 extends IUserService { } @Component() class UserController { // // 注入UserService // @AutoWired() // userService!: IUserService; // // 注入UserServiceV2 // @AutoWired("UserServiceV2") // userService!: IUserService; // 注入UserService,因为名称转pascal后就是UserService @AutoWired() userService; }

总结

以上用最基础的手法,实现了依赖注入的基本原理。还有非常多的特性没有实现,比如构造函数注入、异步初始化等。

实际Component可能在出现在任意文件任意位置,而上面的例子中,如果将AutoWired移到Component前面,就找不到依赖了。

原因有二:

  1. 没有预先扫描所有Component。
  2. 不应该在装饰器中直接创建实例。

实际成熟框架的装饰器只负责写入元信息,不会实例化,由ApplicationContext启动时进行全局包扫描,获取所有的元信息,构建依赖图,确保所有Component都注册到容器中。时间所限这里不再继续展开,有兴趣的大佬可以移步源码Midway包扫描,injection源码。


来涂鸦工作: job.tuya.com/

作者:涂鸦大前端
链接:https://juejin.cn/post/6930898274639593480
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

你可能感兴趣的:(javascript,typescript,开发语言,注入)