阅读 825

使用IoC来管理你的Vue应用

作者:Ryanlau

伴随着现代应用功能越来越多,各个模块不可避免的相互依赖、引用,如果没有任何策略的堆代码,应用的维护会变成一种灾难。因此,有效的管理和解耦依赖变得很重要。本文从依赖注入的角度切入,尝试利用相关的理念来解决这个问题。

先看一个例子

假设我们有两个模块:一个实现http请求,另一个实现路由跳转。

// httpService.ts
export class HttpService {
    name = 'HttpService'
}

// routerService.ts
export class RouterService {
    name = 'RouterService'
}
复制代码

现在有一个登录功能使用了上述两个模块:

// login.ts
export class Login {
    constructor() {
        this.httpService = new HttpService()
        this.routerService = new RouterService()
    }
}
复制代码

在上面的代码中,为了实现登录功能,Login类内部分别实例化了HttpService和RouterService。虽然上述代码可以正常工作,但是不是很灵活。假如修改HttpService需要增加token信息:

// httpService.ts
export class HttpService {
    name = 'HttpService'
    constructor(token:string) {}
}
复制代码

此时我们就需要编辑Login类,在HttpService实例化时增加token参数。假如我们想要给RouterService增加操作或者再次更新HttpService,就不可避免每次都要重新编辑Login类。
为了解决现状,首先我们把依赖作为参数传递给模块:

// login.ts
export class Login {
    constructor(httpService: HttpService, routerService: RouterService) {
        this.httpService = httpService
        this.routerService = routerService
    }
}
复制代码

这样就完成了Login和HttpService、RouterService的解耦,Login不再亲自创建httpService和routerService,而是使用他们。
然而这样还有新的问题:想象一下假如HttpService和RouterService在很多地方被调用,如果增加他们的依赖条件,我们就不得不改变所有调用他们的地方

// routerService.ts
export class RouterService {
    name = 'RouterService'
    constructor(authService:AuthService) {}
}

// RouterService的依赖条件发生了改变,这时候就需要在下面的不同文件中修改,如果文件过多,这种方法就显得很不合适了。
// 并且我们发现出现了多个HttpService和RouterService实例。

// login.vue
const httpService = new HttpService(token)
const authService = new AuthService()
const routerService = new RouterService(authService) // 增加依赖条件
const login = new Login(httpService, routerService)

// list.vue
const httpService = new HttpService(token)
const authService = new AuthService()
const routerService = new RouterService(authService) // 增加依赖条件
const login = new Login(httpService, routerService)
复制代码

因此,我们需要一个来帮助我们管理依赖的工具。
这就是依赖注入要解决的问题,先列一下我们要实现的目标:

  • 依赖的创建和查找交给第三方
  • 依赖本身的依赖可以自动被创建
  • 可以手动注册依赖
  • 依赖的类型不仅限于类,也可以是值,或者函数
  • 为了共享数据,保持依赖单例
  • 语法简洁,不需要写太多的代码

什么是IoC、DI

在实现我们的目标之前,我们需要先弄明白依赖注入涉及的相关概念。

控制反转

控制反转(Inversion of Control,缩写IoC),是面向对象编程中的一种设计原则,可以用来降低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI),还要一种方式叫“依赖查找”(Dependency Lookup)。通过控制反转,对象在被创建的时候,由一个调控系统内所有对象的外界实体,将其所依赖的对象的引用传递(注入)给它。—— 维基百科

依赖注入

在软件工程中,依赖注入(Dependency Injection)的意思为,给予调用方它所需要的事物。“依赖”是指可被方法调用的事物。依赖注入形式下,调用方不再直接使用“依赖”,取而代之是“注入”。“注入”是指将“依赖”传递给调用方的过程。在“注入”之后,调用方才会调用该“依赖”。传递依赖给调用方,而不是让调用方直接获得依赖,这个是该设计的根本需求。 ——维基百科

控制反转概念中提到的调控系统就是我们要实现的目标中提到的第三方,即IoC容器。我们通过容器将A对象中用到的B对象在外部new出来并注入到A中,取代在A中显式的new一个B对象。从而达到设计的目的:解耦调用方和依赖,提高代码可读性以及代码重用性。

实现一个IoC容器

在了解完依赖注入相关的概念之后,我们来手动实现一个简单的IoC容器

1.定义容器接口

// interface.ts
export interface ContainerInterface {
  addProvider<T>(token: Token<T>,provider: any): void;
  getProvider<T>(token: Token<T>): T;
}
复制代码

Container类至少需要实现addProvider()和getProvider()两个方法。接着我们定义参数类型

2.定义Token和Provider

// interface.ts
export interface Type<T> extends Function {
  [INJECTED]?: Type<any>[] // 在3.3实现@Injectable()用到
  new (...args: any[]): T;
}

export type Token<T> = string | Type<T>
复制代码

Token:DI令牌,它关联到一个依赖提供者,用来查找依赖。我们定义它的类型为联合类型,即可以是字符串也可以是函数类型。
Provider:一个提供者对象,定义了如何获取与 DI 令牌(token) 相关联的可注入依赖。这里我们不限制提供者的类型,可以是任意类型的值(any)

3.实现装饰器@Injectable()

3.1 装饰器

一个函数,用来修饰紧随其后的类或属性定义。装饰器(也叫注解)是JavaScript的一种语言特性,是一项位于stage 2的试验特性。
我们这里使用类装饰器@Injectable()来标记对象为可注入对象。

3.2 元数据反射

Reflect Metadata是ES7的一个提案,它主要用来在声明的时候添加和读取元数据。TypeScript在1.5+的版本结合refelct-metadata库已经支持,使用方法:

  • npm i refelct-metadata --save
  • tsconfig.json里配置emitDecoratorMetadata选项。

然后在项目中引入reflect-metadata后,就可以使用Reflect.getMetadata的API了。我们这里主要使用Reflect.getMetadata("design:paramtypes", target, key)方法来获取函数参数,记录依赖信息。

3.3 实现@Injectable()

了解了装饰器和元数据反射这两个前置条件的相关概念后,我们就可以来实现Injectable函数了

// injectable.ts
import 'reflect-metadata'
import { Type } from './interface'

export const INJECTED = '__INJECTED_TYPES'

export function Injectable() {
  return function(target: any) {
    // 记录前置依赖
    const outInjected = Reflect.getMetadata('design:paramtypes', target) as (Type<any> | undefined)[]
    const innerInjected = target[INJECTED]
    if(!innerInjected) {
      target[INJECTED] = outInjected
    } else {
      outInjected.forEach((argType, index) => {
        if(!innerInjected[index]) {
          target[INJECTED][index] = argType
        }
      })
    }
    return target
  }
}
复制代码

4.实现Container

我们在前面定义好了ContainerInterface和两个关键的方法addProvider()getProvider(),下面我们就来分别实现

4.1 addProvider()

// container.ts
import { ContainerInterface, Token } from "./interface";

export class Container implements ContainerInterface {
  private _providers = new Map();
  
  addProvider(token: Token<any>, provider: any) {
    this._providers.set(token, provider);
  }
  
}
复制代码

Container类具有一个私有变量_providers,类型为Map,保存所有的提供者。提供者类型为any,所以我们可以注册任意类型值的provider。addProvider()注册依赖。

4.2 getProvider()

  getProvider<T>(token: Token<T>): T {
    if (this._providers.has(token)) {
      return this._providers.get(token);
    } else {
      if (isClassProvider(token)) {
        const instance = this.getInstanceFromClass(token as Type<any>);
        this.addProvider(token, instance);
        return instance;
      } else {
        throw new Error(`${token} is a normal string that cannot be instantiated`);
      }
    }
  }
复制代码

通过getProvider()方法,传入token就可以拿到注册过的provider。这里我们增加了getInstanceFromClass方法,用来自动实例化class类型的依赖。

说明:在Angular DI的实现中,Provider为联合类型TypeProvider|ValueProvider|ClassProvider|ConstructorProvider| ExistingProvider|FactoryProvider|any[],考虑的场景比较全面,实现起来也很复杂。我们这里只是演示思路,所以只考虑class这一种类型,并且简化Provider的类型为any

4.3 getInstanceFromClass()

  private getInstanceFromClass<T>(provider: Type<T>): T {
    const target = provider;
    if (target[INJECTED]) {
      const injects = target[INJECTED]!.map(childToken => this.getProvider(childToken));
      return new target(...injects);
    } else {
      if (target.length) {
        throw new Error(
          `Injection error.${target.name} has dependancy injection but,but no @Injectable() decorate it`
        );
      }
      return new target();
    }
  }
复制代码

还记得我们在实现@Injectable()时通过元数据反射拿到的参数信息吗,当时被我们记录在了对象的[INJECTED]属性上面。target[INJECTED]类型为Type<any>[],使用map()方法让数组中的每一个元素都调用getProvider()方法,递归获取所有的依赖,然后返回目标类的实例。

至此,一个简易版的IoC容器就制作完成了,让我们来测试一下是否可行吧。

// test.ts
import { Container } from './container'
import { Injectable } from "./injectable";

@Injectable()
class AuthService {
  name = 'authService'
}
@Injectable()
class RouterService {
  name = 'routerService'
  constructor(private authService: AuthService){}
}

const container = new Container()
const routerService = container.getProvider(RouterService)

console.log(routerService)
复制代码

在浏览器中运行测试代码,可以在控制台中看到成功后的打印信息

Chrome Console

在Vue中使用

在前文中我们实现了一个简易版的IoC容器,现在回到我们的主题“使用IoC来管理你的Vue应用”。众所周知,.vue文件使用三段式代码分别实现templatescriptstyle,并基于component来拆分组合代码。但是当组件中js逻辑过多时,.vue文件就会变得比较臃肿,不利于维护。我相信很多人都碰到过这种情况,也都有各种的解决方案。

本文不讨论哪种方案更好,旨在通过依赖注入这个点来切入Vue项目,提供一种维护项目的思路。我们先来看一张图片

图片摘自:IoC 在前端模块化中的实践应用,原文链接https://efe.baidu.com/blog/ioc-in-modulization/

这张图片形象的说明了IoC容器扮演的角色,通过控制反转将业务模块中各个容易变化的部件抽象解耦,不同的模块去实现自己的定制需求,而通用代码不要重复开发。

这里我们使用插件来为Vue提供IoC容器的功能。

// plugin.ts
import { VueConstructor } from "vue";
import { Container } from "./index";
import { Type } from "./interface";

export default {
  install(Vue: VueConstructor, rootContainer: Container) {
    Vue.mixin({
      beforeCreate() {
        const { viewInject } = this.$options;
        if (viewInject) {
          const injects = viewInject;
          for (const name in injects) {
            this[name] = rootContainer.getProvider(injects[name] as Type<any>);
          }
        }
      }
    });
  }
};
复制代码

我们将依赖的注入位置放在Vue实例的初始化选项里,类型定义为对象viewInject?: Object。之后在rootContainer里查找依赖,如果存在就返回,没有就自动实例化。

插件完成之后,我们就可以在Vue项目中使用IoC了。

首先在入口文件main.ts中安装插件

// main.ts
import Vue from 'vue'
import IocPlugin from './plugin'
import { Container  } from "./container"

Vue.use(IocPlugin, new Container())
复制代码

然后我们创建两个文件list.vuelistService.ts

// list.vue
import ListService from './listService'
export default {
  name: 'list',
  viewInject: {
    listService: ListService
  },
  mounted() {
    console.log(this.listService.getList())
  }
}

// listService.ts
import { Injectable } from "./injectable";

@Injectable()
export default class ListService {
  getList(): number[] {
    const data = [1,2,3,4,5,6]
    return data;
  }
}
复制代码

在浏览器中运行上述代码,控制台会打印出来getList()的返回值

Chrome Console

不局限于Vue

至此,我们已经实现了一个在Vue应用中使用IoC的MVP了。虽然还有很多功能没有实现,比如provider scope,@Inject(),依赖的生命周期等,但这不妨碍我们理解IoC和DI的理念,解耦我们的项目。不局限于Vue,你也可以在其他框架中使用DI。

参考资料: