jiayisheji / blog

没事写写文章,喜欢的话请点star,想订阅点watch,千万别fork!
https://jiayisheji.github.io/blog/
505 stars 49 forks source link

TypeScript 实现依赖注入 #45

Open jiayisheji opened 2 years ago

jiayisheji commented 2 years ago

SOLID 中的最后一个字母是 Dependency Inversion Principle。它可以帮助我们解耦软件模块,以便更容易地用另一个模块替换一个模块。依赖注入模式使我们能够遵循这个原理。

在这篇文章中,我们将了解什么是依赖注入,为什么它很有用,何时使用它,哪些工具可以帮助前端开发人员使用这种模式。

储备知识

我们假设你了解 JavaScript 的基本语法,并熟悉面向对象编程的基本概念,例如类和接口。不过,你不需要详细了解类和接口的TypeScript 语法,因为我们会在这篇文章中用到它。

什么是依赖

一般来说,依赖关系的概念依赖于上下文,但为了简单起见,我们将依赖关系称为模块所使用的任何模块。当我们开始在代码中使用一个模块时,这个模块就变成了一个依赖项。

我们使用函数参数来模拟依赖。这样,无需深入学术定义,我们可以将依赖关系与函数参数进行比较。两者都以某种方式使用,两者都会影响依赖于它们的软件的功能和可操作性。

// random 函数在没有 'min' 和 'max' 参数的情况下无法工作

function random(min, max) {
  if (typeof min === 'undefined' || typeof max === 'undefined') {
    throw new Error('All arguments are required');
  }

  return Math.random() * (max - min) + min;
}

在上面的例子中,random 函数有两个参数:minmax。如果我们不通过其中一个,函数将抛出一个错误。我们可以得出结论,这个函数取决于这些参数。

然而,这个函数不仅取决于这两个参数,而且依赖于 Math.Random 函数。这是因为如果 Math.Random 没有定义,random 函数也不能工作,所以 Math.Random 也是一种依赖。

如果我们将它作为参数传递给函数,可以使它更清楚:

function random(min, max, randomSource) {
  if (typeof min === 'undefined' || typeof max === 'undefined' || typeof randomSource === 'undefined') {
    throw new Error('All arguments are required');
  }

  return randomSource.random() * (max - min) + min;
}

现在很明显,random 函数不仅使用 minmax,还有随机数生成器。这类函数将被这样调用:

const randomBetweenTenAndTwenty = random(10, 20, Math);

或者如果我们不想每次都手动传递 Math 作为最后一个参数,我们可以在函数参数声明中使用它作为默认值:

function random(min, max, randomSource = Math) {
   // ...code
}
// 调用random函数
const randomBetweenTenAndTwenty = random(10, 20);

这就是基本的依赖注入。当然,它还没有得到”规范“,这是非常原始的,它必须用手完成,但关键的思想是一样的:我们将它工作所需要的一切传递给模块。

为什么需要依赖注入

random 函数示例中的代码更改似乎是不必要的。实际上,为什么我们要把 Math 提取到参数中,并像那样使用它?为什么我们不直接在函数体中使用它呢?有两个原因。

可测试性

当模块明确声明它需要的所有东西时,这个模块测试起来要简单得多。我们看到需要准备好的需要立即运行测试。我们知道是什么影响了这个模块的功能,如果需要,可以用另一个实现替换它,甚至是假实现来替换它。

看起来像依赖性的对象,但是做不同的东西被称为 Mock 对象。当运行测试时,它们可能会跟踪某个函数被调用了多少次,模块的状态是如何改变的,这样以后我们就可以检验预期的结果了。

一般来说,他们使测试模块更简单,有时它们是测试模块的唯一方法。random 函数是这种情况,我们不能检查这个函数应该返回的最终结果,因为每次调用这个函数都是不同的。然而,我们可以检查这个函数如何使用它的依赖项并从中得出结果。

// 我们可以创建一个Mock对象,它将总是返回0.1而不是一个随机数:  
const fakeRandomSource = {
  random: () => 0.1,
}

// 然后,我们将调用函数,并将这个Mock对象作为依赖项而不是Math:  
const randomBetweenTenAndTwenty = random(10, 20, fakeRandomSource);

// 既然函数的算法是确定的并且不变, 我们可以预期结果总是一样的:  
randomBetweenTenAndTwenty === 11; // true

可替换性(改变依赖关系的能力)

在测试时替换依赖项只是一种特殊情况。通常,我们可能出于任何原因想要用另一个模块替换一个模块。如果一个新模块的行为与前一个模块相同,我们可以在没有任何问题的情况下做到这一点:

// 如果一个新对象包含 `random` 方法,我们可以把它当作一种依赖。
const otherRandomSource = {
  random() {
    // 自定义随机数生成的实现。
  }
}

const randomNumber = random(10, 20, otherRandomSource);

当我们想让我们的模块尽可能地彼此分开时,这是非常方便的。然而,是否有一种方法可以保证新模块包含 random 方法?(这是至关重要的,因为我们以后在函数随机中依赖这个方法)显然是有的,我们可以通过接口来实现。

接口

接口是一种功能契约。它限制了模块的行为,它必须做什么,以及它不应该做什么。在我们的案例中,为了保证随机方法的存在,我们可以使用接口。

定义行为

为了确定模块应该有一个返回数字的 random 方法,我们定义了一个接口:

interface RandomSource {
  random(): number;
}

为了确定一个具体的对象必须有这个方法,我们声明这个对象实现了这个接口:

// 使用冒号声明
// 这个对象实现了一个 “RandomSource” 接口
// 因此,必须以这种接口中描述的方式行事。
const otherRandomSource: RandomSource = {
  random = () => {
    // 它必须返回一个数字,否则 TypeScript 编译器会抛出一个错误。
    return 42;
  }
}

现在我们可以声明我们的 random 函数只接受一个实现 RandomSource 接口的对象作为最后一个参数:

function random(min: number, max: number, source: RandomSource = Math): number {
  if (typeof min === 'undefined' 
      || typeof max === 'undefined' 
      || typeof source === 'undefined') {
    throw new Error('All arguments are required');
  }
  return source.random() * (max - min) + min;
}

如果我们现在试图传递一个没有实现 RandomSource 接口的对象,TypeScript 编译器会抛出一个错误。

const randomNumber1 = random(1, 10, Math);
// `Math` 包含一个 `random` 方法,没有错误。  

const randomNumber2 = random(1, 10);
// `Math` 被用作默认参数值,没有错误。

const randomNumber3 = random(1, 10, otherRandomSource);
// 没有错误,因为`otherRandomSource`实现`RandomSource`接口。

const otherObject = {
  otherMethod() {};
};

const randomNumber4 = random(1, 10, otherObject);
// 错误,'otherObject' 没有实现所需的接口

依赖抽象

乍一看,这似乎有点过分。然而,这可以帮助我们获得很多好处。

当我们预先设计一个系统时,我们倾向于使用抽象的契约。使用这些契约,我们为第三方代码设计我们自己的模块和适配器。这解锁了与其他模块交换的能力,而不改变整个系统,而只是改变一部分。

特别是当模块比上面例子中的模块更复杂时,它就变得非常方便。例如,当一个模块具有内部状态时。

有状态模块

在 TypeScript 中,有很多方法可以创建有状态对象,例如使用闭包或类。在这篇文章中,我们将使用类。

作为一个例子,我们将使用一个计数器。作为一个类,它应该写成这样:

class Counter {
  private state: number = 0;

  public increase = (): void => {
    this.state++;
  }

  public decrease = (): void => {
    this.state--;
  }

  get stateOf(): number {
    return this.state;
  }
}

它的方法为我们提供了一种改变其内部状态的方法:

const counter = new Counter();
counter.stateOf; // 0

counter.increase();
counter.stateOf; // 1

counter.decrease();
counter.stateOf; // 0

当像这样的一些物体取决于其他物品时,它就会得到。让我们假设这个计数器不仅应该保持和更改它的内部状态,而且还应该在每次更改时将它记录到一个控制台中。

class Counter {
  private state: number = 0;

  // 添加日志记录方法。
  private log = (): void => {
    console.log(this.state);
  }

  public increase = (): void => {
    // 现在当状态发生变化时…
    this.state++;
    this.log();
  }

  public decrease = (): void => {
    // 现在当状态发生变化时…
    this.state--;
    this.log();
  }

  get stateOf(): number {
    return this.state;
  }
}

在这里,我们看到了与本文开头所看到的相同的问题。计数器不仅使用它的状态,而且还使用另一个模块 console。理想情况下,它还应该是明确的,或者换句话说,注入式的。

类中的依赖注入

可以使用 setterconstructor 在类中注入一个依赖项。我们使用 constructor

constructor (构造函数)是在创建对象时调用的一种特殊方法。通常在对象初始化时指定要执行的所有操作。

例如,如果我们想在创建对象时将问候信息打印到控制台,我们可以使用下面的代码:

class Counter {
  constructor() {
    console.log('Hello world!');
  }

  // ...code.
}

const counter = new Counter();
// "Hello world!"

使用构造函数,我们还可以注入所有需要的依赖项。

简单注入

我们想将类以与前面例子中的函数相同的方式处理依赖关系。

因此,我们的类 Counter 使用 Console 对象的 log 方法。这意味着该类期望依赖一个具有 log 方法的对象。它是 Console 对象还是其他对象并不重要,这里唯一的条件是对象有一个 log 方法。

当我们想要限制行为时,我们需要使用接口。因此,Counter 的构造函数应该接受一个对象作为参数,该对象实现了一个带有 log 方法的接口。

interface Logger {
  log(message: string): void;
}

class Counter {
  // 这个私有字段将保留一个引用到  logger  对象
  private logger: Logger;

  constructor(logger: Logger) {
    // 我们将在初始化时设置
    this.logger = logger;
  }

  // ...code.
}

// 或者使用字段自动分配
class Counter {
  // 在以这种方式写入时,构造函数中的参数将自动分配给`logger`私有字段。
  constructor(private logger: Logger) {}

  // ...code.
}

要初始化类实例,我们将使用以下代码:

const counter = new Counter(console);

如果我们想要,比方说,使用 alert 而不是 console,我们会这样改变依赖对象:

// 这就足够确保依赖对象 拥有所有必需的方法,或者实现所需的接口。
const customLogger: Logger = {
  log(message: string): void {
    alert(message);
  }
}

const counter = new Counter(customLogger);

自动注入和 DI 容器

现在,我们的 Counter 类没有使用任何隐式依赖关系。这很好,但是这种注入不方便。

实际上,我们想让它自动化。有一种方法可以做到这一点,它被称为 DI 容器

总的来说,DI 容器是只做一件事的模块-它为系统中的其他每个模块提供依赖关系。容器确切地知道模块需要哪些依赖项,并在需要时注入它们。这样我们就解放了其他模块来解决这个问题,然后控制到一个特殊的地方。这是 SOLID 在 SRPDIP 原则中描述的行为。

在实践中,为了使其工作,我们需要另一层抽象接口。(Typescript 有这个概念,Javascript 没有)这里的接口是不同模块之间的链接。

容器知道模块需要什么样的行为,知道哪些模块实现它,当创建一个对象时,它会自动提供对它们的访问。

在伪代码中,它看起来像这样:

// 嘿,容器!
// 当你被问到一个实现 `SomeInterface` 的对象时,你应该给访问 `SomeClass` 的一个实例。
container.register(SomeInterface, SomeClass);

尽管这段代码不是真实的,但它离现实并不遥远。

自动注入工具

TypeScript 有很棒的工具,它可以做我们上面描述的事情。它们都是使用泛型函数来绑定接口和实现。

当然,在前端有强大框架 Angular,它有核心特性就是依赖注入。在后端也有强大框架 Nest,它有核心特性也是依赖注入。Nest 依赖注入也是参考 Angular 实现。

Angular 爱好者把依赖注入特性从 Angular 的 ReflectiveInjector 中提取出来的,创建一个独立库 injection-js。这意味着它设计得很好,功能齐全,快速、可靠,而且经过了很好的测试。有很多库内部使用 injection-js,最有名当属将库编译为 Angular 包格式 ng-packagr(官方 Angular CLI 的一部分)。

在这里使用一个简单的 DI 库,使用此工具的代码如下所示:

import {DIContainer} from '@wessberg/di';

// 创建 DI 容器
const container = new DIContainer();

// 创建注入接口
interface Logger {
  log(message: string): void;
}

// 实现注入接口
export class ConsoleLogger implements Logger {
  public log = (message: LogEntry): void => console.log(message);
}

// 声明当有模块访问一个实现 `Logger` 接口的对象容器时,它应该返回 `ConsoleLogger` 类的一个实例。
container.registerSingleton<Logger, ConsoleLogger>();

// `<Logger, ConsoleLogger>` 语法是一个泛型函数。它使用类型参数将 `Logger` 类型与 `ConsoleLogger` 类型绑定。

// `Logger` 是一个抽象接口,`ConsoleLogger` 是一个更具体的类。
// 由于 TypeScript 将它们都视为类型,所以我们可以在泛型函数中将它们用作类型参数。

现在,如果我们想访问 Counter 类中的依赖项,我们可以通过编写下面的代码来实现:

class Counter {
  constructor(private logger: Logger) {}

  private log = (): void => {
    this.logger.log(this.state);
  }

  // ... code.
}

container.registerSingleton<Counter>();

最后一行在容器本身中注册 Counter 类。这样容器就知道 Counter 可以从中寻求依赖关系。

使用容器的目的

首先,我们现在只需改变一行就可以改变整个项目的实现。

例如,如果我们想在每个使用它的地方更改 Logger 实现,只需更改模块注册就足够了:

// 自定义日志实现
class CustomLogger implements Logger {
  public log = (message: LogEntry): void => alert(message);
}

// 替换旧的 `ConsoleLogger` 我们只更改下面一行的注册:
container.registerSingleton<Logger, CustomLogger>();

此外,我们不必手动传递依赖项,我们不必再保持依赖项的顺序,因此模块之间的耦合会变得更少。

这个容器的杀手锏是它不使用装饰器(如果喜欢装饰器,可以使用 inverse.js)。类型参数注册使得区分基础结构代码和生产代码更加容易。

什么是 registerSingleton

单例和临时是对象的生命状态类型。

registerSingleton 只创建一个对象,之后它会传递到每个需要它的地方。registerTransient 每次都会创建一个新对象。

临时对象用于处理一些独特的场景,比如每次都应该从头创建的网络请求对象。当我们可以使用相同的实例(例如,用于记录日志)时,就使用单例对象。

最后的示例

我写了一个小应用程序,点击时候 alert 提示唯一ID,此外,它每 5 秒在控制台显示一条 Hello world 日志。

入口点

export class Application {
  constructor(
    private dateTimeSource: DateTimeSource,
    private idGenerator: UuidGenerator,
    private clickHandler: EventHandler<MouseEvent>,
    private logger: Logger,
    private timer: Timer,
    private env: Window
  ) {}

  private greet = (): void => this.logger.log('Hello world!');
  private setupTimer = (): void => this.timer.invokeEvery(this.greet, 5000);
  private registerClicks = (): void => this.clickHandler.on('click', this.handleClick);

  private handleClick = (e: MouseEvent): void => {
    const position = [e.pageX, e.pageY];
    const datetime = this.dateTimeSource.toString();
    const eventId = this.idGenerator.generate();
    this.env.alert(`${eventId}, ${datetime}: Mouse was clicked at ${position} `);
  };

  public init = (): void => {
    this.setupTimer();
    this.registerClicks();
  };
}

container.registerSingleton<Application>();

所有有趣的东西都在类构造函数中。在那里,我们向一个容器请求所有依赖项。

一级依赖项

这些是主要模块取决于的依赖关系:

DateTimeSource

为了访问日期和时间,我们使用 BrowserDateTimeSource,它被注册为 DateTimeSource 的实现。请注意,当我们要求这种依赖时,我们使用了接口,因为接口是所有东西都应该依赖于抽象的关键点。

export interface DateTimeSource {
   source: Date;
   toString: () => string;
   valueOf: () => string; 
}

export class BrowserDateTimeSource implements DateTimeSource {
  get source() {
    return new Date();
  }

  public toString = (): UtcDateTimeString => this.source.toUTCString();
  public valueOf = (): TimeStamp => this.source.getTime();
}

container.registerSingleton<DateTimeSource, BrowserDateTimeSource>();

UuidGenerator

唯一的 ID 生成器是第三方的适配器。注意,我们只在注册适配器时引用这个第三方模块一次。如果我们决定用另一个 UUID 生成器可以随时替换,这是很方便的。

export interface UuidGenerator {
   generate:() => string;
}
export class IdGenerator implements UuidGenerator {
  constructor(private adaptee: ThirdPartyGenerator) {}
  generate = () => this.adaptee();
}

container.registerSingleton<ThirdPartyGenerator>(() => uuid);
container.registerSingleton<UuidGenerator, IdGenerator>();

EventHandler

事件处理程序使用通用接口 EventHandler<MouseEvent>。稍后从容器中请求这种依赖关系是很重要的。如果在这个接口中传递另一个类型参数,容器将搜索使用该参数注册的模块。当我们处理类似的对象类型时,这是很方便的。

export class ClickHandler implements EventHandler<MouseEvent> {
  constructor(private env: Window) {}

  public on = (event: EventKind, callback: EventCallback<MouseEvent>): () => void {
         this.env.addEventListener(event, callback);
         return () => {
               this.env.removeEventListener(event, callback);
         }
  }

  public off = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
    this.env.removeEventListener(event, callback);
}

container.registerSingleton<EventHandler<MouseEvent>, ClickHandler>();

Logger

这个我们已经实现过了:

export class ConsoleLogger implements Logger {
  public log = (message: LogEntry): void => console.log(message);
}

container.registerSingleton<Logger, ConsoleLogger>();

Timer

模拟一个定时器,在间隔时间内执行回调函数。

export interface Timer {
   invokeEvery:(fn: (...args: any[]) => void, delay: number) => () => void;
}
export class BrowserTimer implements Timer {
  constructor() {}
  invokeEvery = (fn: (...args: any[]) => void, delay: number) => () => void{
       let timer = setInterval(fn, delay);
       () => {
            clearInterval(timer);
            timer = null;
       }
  }
}

container.registerSingleton<Timer, BrowserTimer>();

二级依赖项

它们是依赖项的依赖项,例如,ClickHandler 类中的 envIdGenerator 中的 adaptee

对于容器来说,依赖于什么级别并不重要。容器可以毫无问题地提供所有依赖项。(除非有循环依赖,那是另外一个值得深入探讨的话题)

// 对于 `idgenerator`,我们注册了依赖项,如:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);

// 对于“ClickHandler”(需要 `Window`)
container.registerSingleton<Window>(() => window);

缺陷

DI 容器的主要问题是,当使用它时,必须注册那里的所有依赖项。它有时并不像我们想要的那样灵活。

另一个缺点是只能从容器访问入口点,这可能看起来有点脏代码。(不过,对于入口点来说,这是可以接受的)

const app= container.get<Application>();
app.init();

今天就到这里吧,伙计们,玩得开心,祝你好运。