进阶教程

kaokei About 13 min

# 简介

本教程继续介绍下面 2 个 api,可以用来支持更加复杂场景的需求。

  • declareProviders
  • Inject

# 解答问题

上篇文章中,即基础教程中提到的三个问题,如下:

  • 为什么我们不自己 new CountService(),而要通过 useService 获取实例对象?
  • useService(CountService) 这里并没有传递 LoggerService,那么这里是如何初始化 LoggerService 的?
  • 在不同的组件中调用 useService(CountService),获取到的实例是不是同一个对象?

其实这三个问题可以用一个名词来解答,就是依赖注入

第一个问题的答案就是,因为我们要使用依赖注入,我们要通过依赖注入来获取实例对象,而不是通过手动 new 来获取实例对象。至于为什么需要依赖注入,这是因为依赖注入相对于手动 new 更具有优势。

第二个问题的答案就是,useService 会自动帮我们实例化一个 LoggerService 的实例,并且传递给 CountService 构造函数。这也是依赖注入其中一个最重要的优势的体现。依赖注入框架会自动帮助我们实现依赖的注入,包括依赖的依赖的注入,这是一个递归的注入过程。

第三个问题的答案就是,useService 有一个非常重要的特性,在同一个Injector同一个服务一定是单例的。 所以默认情况下,在不同的组件中调用 useService(CountService),获取到的实例是同一个对象,这种默认的特性正好完美的解决了跨组件通信的问题。具体体现在当任意一个组件内修改了 countService.count 属性,两个组件都会重新渲染。

当然,针对特定的复杂场景我们可能需要实现一个服务可以有多个实例,我们可以有两种做法。第一种是使用不同的 Injector,不同 Injector 中的服务都是互相独立的。第二种就是在同一个 Injector 中,可以针对同一个服务取不同的名字,这样让 Injector 以为是不同的服务,从而得到多个实例。

# 给组件绑定 Injector

默认情况下,本库提供了一个全局的根 Injector,它的生命周期是和应用一致的。所以如果是用户信息这类全局性质的数据,那么默认的 Injector 就能满足需求了。

但是如果是某个页面的业务数据,我们期望进入页面的时候初始化数据,离开页面的时候应该销毁数据。那么就应该把这个服务关联到这个页面对应的组件上。这样服务的生命周期就和页面的生命周期一致了。

我们可以借助 declareProviders 来关联组件和 Injector。

import { declareProviders } from "@kaokei/use-vue-service";
1

在介绍如何使用 declareProviders 函数之前,我们必须弄清楚 useService 是如何工作的。前面已经介绍过 useService 是一个 hooks 函数,是只能在 setup 函数中使用的。

我们还知道一个 vue 项目,最终的产出物就是一棵 vue 组件树,然后 vue 框架会通过 vue 组件树渲染成 dom 树。这个渲染细节我们暂时不用去关心,这里只需要关注 vue 组件树,在这棵树中,任意一个组件节点都有它的父节点,直到根结点。

当我们在某个组件中调用 useService(CountService) 函数时,它会首先从当前组件关联的 Injector 中寻找是否存在 CountService 服务的 provider。如果没有找到,则进入到父组件关联的 Injector 中寻找 CountService 的 provider。如果还没有找到,则继续到更上一层父组件中寻找,直到找到相应服务的 provider,那么就通过这个 provider 获取一个对象出来。这个对象就是 useService(CountService)的返回值。当然还有一种情况就是直到根组件都没有找到 provider,针对这种情况,useService 做了一层 fallbak 机制,就是把 CountService 类当作默认 provider,然后用这个默认的 provider 来获取实例对象。

通过上面简单的介绍,我们应该对 useService 解析机制有一个大概的认识,它和 js 中的原型链的解析机制以及 nodejs 中的 node_modules 解析机制都是非常相似的,应该不是很难理解。

通过上面的介绍,我们知道默认情况下,在不同的组件中调用 useService(CountService)时,肯定是都找不到对应的 provider 的,最终都会冒泡到根组件上,在根组件对应的 Injector 中使用 CountService 类作为默认的 provider 获取实例对象,又因为同一个 Injector 中,同一个服务只有一个实例,所以不同的组件中获取到的是同一个 countService 实例对象。

接下来介绍如何通过 declareProviders 函数来关联组件和 Injector。

注意:有关根组件和根 Injector 的关系上面的介绍在某些细节上存在一些瑕疵,但是不妨碍理解整体的工作机制。具体细节差别可以参考这里

# 定义 A 组件-没有 declareProviders

<template>
  <div>
    <span>{{ countService.count }}</span>
    <button type="button" @click="countService.addOne()">+1</button>
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useService } from "@kaokei/use-vue-service";
import { CountService } from "../services/count.service";

export default defineComponent({
  setup() {
    // 返回的就是CountService类的实例
    // 并且是reactive的
    const countService = useService(CountService);
    return {
      // 可以在模板中直接绑定数据和事件
      countService
    };
  }
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 定义 B 组件-使用 declareProviders

<template>
  <div>
    <span>{{ countService.count }}</span>
    <button type="button" @click="countService.addOne()">+1</button>
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useService } from "@kaokei/use-vue-service";
import { CountService } from "../services/count.service";

export default defineComponent({
  setup() {
    // 手动定义CountService服务的provider
    // 这里就是在当前组件上关联了一个新的Injector
    declareProviders([CountService]);
    // 返回的就是CountService类的实例
    // 并且是reactive的
    const countService = useService(CountService);
    return {
      // 可以在模板中直接绑定数据和事件
      countService
    };
  }
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

对比上面的 A 组件和 B 组件,我们发现唯一的差别就是 B 组件中调用了declareProviders([CountService]);,这行代码的功能就是在 B 组件关联了一个新的 Injector,并且配置了 CountService 这个服务的 provider。

这行代码其实是一种简写,完整的代码应该是这样的:

declareProviders([
  {
    provide: CountService,
    useClass: CountService
  }
]);
1
2
3
4
5
6

从完整的代码里,我们应该可以明确的看出来,其中provide属性定义了服务的名字,也就是指定了是哪个服务,一般称之为服务标识符。useClass属性则是指定了如何生成服务的实例。provide 和 useClass 所在的对象被称为 provider。关于具体的 provider 解释,具体可以参考这里

我们还能发现 declareProviders 函数的参数是一个数组,其实是一个 provider 数组,这是很好理解的,因为一个组件是可以依赖多个不同的服务的,所以可以通过 declareProviders 函数一次性注册多个服务的 provider 的。

接下来开始分析 A 组件和 B 组件中 useService 的差异了。

在 A 组件中,因为没有使用 declareProviders,所以在当前组件以及父组件中都没有找到 CountService 服务的 provider,一直到根组件中都没有找到 provider,所以只能使用默认的 provider 获取实例对象。然后作为 useService 函数的返回值。

在 B 组件中,因为 B 组件本身就已经定义了 CountService 服务的 provider,所以不用到父组件中去寻找了,更不需要到根组件中寻找了。直接在当前组件关联的 Injector 中获取服务的实例对象,然后作为 useService 函数的返回值。

最后的总结就是 A 组件中的 countService 对象是在根组件关联的 Injector 中,B 组件中的 countService 对象是在 B 组件关联的 Injector 中。

这样我们就达成了我扪想要的效果了,即通过 declareProviders 手动管理服务的位置,从而管理服务的生命周期,其实也是管理了该服务对哪个组件(及其子孙组件)可见,达到类似作用域的功能。同时我们也客观上实现了同一个服务可以具有多个实例对象,这里的意思是指根 Injector 和 B 组件关联的 Injector 中都有 CountService 实例对象。

这里还是要多说一句,一开始我们一直强调同一个服务在同一个 Injector 下只有一个实例,这是默认行为。然后这里我们又花费了大篇文章介绍怎么实现同一个服务获取多个实例对象。看起来有些冲突,或者说是多此一举。实际上并不是这样的,这是因为业务的复杂性决定的,大多数简单的场景下我们是不需要 declareProviders 的,但是当业务场景足够复杂的时候,我们还是需要一种机制去实现多例的功能。为了满足不同的业务场景,我们肯定是需要提供这种基础能力的。

# 在服务中注入其他服务

在之前的介绍中我们一直用类来描述服务,但是实际上服务不仅仅只能是类来描述。

本质上服务应该是数据和方法的集合体,极端场景也可以没有数据或方法,那么服务就退化为数据和方法了。所以有时候文档中会使用数据来代指服务。

现在再反过来思考类和服务之间的关系,显然类本身是“不太能”直接作为服务来使用的,而应该是作为服务的工厂,通过类的实例化来生成服务对象。

所以类只不过是生成服务对象的一种方式而已,我们还可以直接配置一个函数,然后把这个函数的返回值作为服务。

最直接的情况就是我们不再需要一个生产服务的过程,而是直接配置服务本身。这个服务本身可以是对象、字符串、数字、布尔值、函数。甚至如果你愿意的话可以直接配置一个类作为服务本身(虽然我暂时还没有遇到这个场景)。

还有另外一个问题也需要考虑,现在我们回归到使用类来描述服务,如果这个服务并不是象我们之前所举的例子 LoggerService 那么简单,没有任何依赖,而是具有非常复杂的依赖结构。比如这样的:

A --> B、C、D
B --> C、D
C --> D、E
D --> E、F
E --> F
F 没有依赖
1
2
3
4
5
6

考虑到这样复杂的场景,当我们使用useService(A)的时候,显然依赖注入框架本身是需要分析这种依赖关系的,然后依次实例化 F、E、D、C、B,最后才是实例化 A。

而且这个例子中也特意规避了循环依赖的场景,本库已经支持了部分循环依赖,对于不支持的循环依赖也会有准确的异常报错。

让我们再回归一下话题,不考虑依赖入住背后复杂的实现,我们现在需要做的应该是如何在代码成面来描述这种依赖关系。这里就需要借助@Inject这个装饰器了。

# 没有依赖的服务

import { Injectable } from "@kaokei/use-vue-service";

@Injectable()
export class LoggerService {
  public log(...msg: any[]) {
    console.log("from logger service ==>", ...msg);
  }
}
1
2
3
4
5
6
7
8

很显然这是最简单的场景,这个服务没有任何的依赖,只需要使用@Injectable()这个装饰器声明一下这个服务可注入即可。

实际上这里可以不用@Injectable()也是没问题的,只不过为了一致性,所有的服务都使用@Injectable()声明一下是一个良好的习惯。

# 通过构造函数注入依赖

import { Injectable, Inject } from "@kaokei/use-vue-service";
import { LoggerService } from "./logger.service.ts";

const someStringServiceToken = "stringServiceToken";
const someNumberServiceToken = "numberServiceToken";

@Injectable()
export class CountService {
  public count = 0;
  constructor(
    private logger: LoggerService,
    @Inject(LoggerService) private logger2: LoggerService,
    @Inject(someStringServiceToken) private someStringService: string,
    @Inject(someNumberServiceToken) private someNumberService: number
  ) {}

  public addOne() {
    this.count++;
    this.logger.log("addOne ==> ", this.count);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

这里展示了常见的几种注入方式。

第一种方式:不使用@Inject,直接使用private logger: LoggerService。我们会得到一个 logger 实例属性,logger 的类型是 LoggerService。

第二种方式:使用了@Inject,其效果是和第一种完全一致的。第一种方式可以看作第二种方式的简写。 第二种方式中有两个 LoggerService,第一个出现在@Inject(LoggerService),功能是指定了具体是哪个服务,起到服务标识符的作用。 第二个出现在private logger2: LoggerService,功能是指定了 logger2 的类型是 LoggerService。

第三种和第四种方式是一致的,只不过分别是 string 类型和 number 类型而已。但是这里是一定需要使用@Inject 了。因为如果不通过@Inject 来指定是哪个服务,就会导致使用string来作为服务标识符,最终得到 string 的实例,即new String()得到一个空字符串。这显然不是我们想要的。对于 number 类型也是同理,我们会得到数字 0。但是我们使用了@Inject 就不一样了。我们已经指定了这里的服务就是someStringServiceToken,然后就会通过这个 token 找到对应的服务,并且我们也同时指定了 someStringService 的类型是 string。唯一需要注意的是因为我们注入的不是类服务,而是字符串/数字类型的服务,是没有办法提供默认的 provider 的,也就是说我们必须要手动调用 declareProviders 来指定 provider。

declareProviders([
  {
    provide: someStringServiceToken,
    useValue: "hello world"
  },
  {
    provide: someNumberServiceToken,
    useValue: 123456
  }
]);
1
2
3
4
5
6
7
8
9
10

如果没有明确指定服务,则会直接导致注入服务的过程找不到服务而抛出异常。

# 通过实例属性注入依赖

import { Injectable, Inject } from "@kaokei/use-vue-service";
import { LoggerService } from "./logger.service.ts";

const someStringServiceToken = "stringServiceToken";
const someNumberServiceToken = "numberServiceToken";

@Injectable()
export class CountService {
  public count = 0;

  @Inject(LoggerService)
  public logger!: LoggerService;

  @Inject(someStringServiceToken)
  public someStringService!: string;

  @Inject(someNumberServiceToken)
  public someNumberService!: number;

  public addOne() {
    this.count++;
    this.logger.log("addOne ==> ", this.count);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

这个例子的效果是和上面通过构造函数注入依赖是一致的。有几点需要注意一下。

第一点:所有待注入的属性都必须明确使用@Inject,比如 count 属性就不是注入属性,那么就不需要使用@Inject 了。

第二点:@Inject 的参数是必填项,必须手动指定服务标识符。

第三点:可以给实例属性赋值默认值,但是正常情况下是没有意义的,因为一定会被注入的数据覆盖掉,除非指定了服务是@Optional 的,而且确实没有找到该服务才会使用这个默认值。如果没有指定默认值,那么我们应该使用!:来强制指定该属性是非空的。

第四点:和上方的构造函数的注入依赖是同样的道理,我们也必须给字符串/数字类型的服务手动声明 provider。

# 参考文章