TypeScript 中的依赖注入

2025-06-10

TypeScript 中的依赖注入

SOLID 中的字母 D 代表依赖倒置原则。它有助于将模块彼此解耦,以便您可以轻松地将代码的一部分替换为另一部分。

有助于遵循这一原则的技术之一是依赖注入。

这篇文章的灵感来自Sasha Bespoyasov 的文章,部分内容是其翻译。

什么是依赖关系?

为了便于参考,我们将依赖项定义为我们的模块使用的任何模块。

让我们看一个接受两个数字并返回一定范围内的随机数的函数:

const getRandomInRange = (min: number, max: number): number =>
  Math.random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

该函数取决于两个参数:minmax

但你可以看到,函数不仅依赖于参数,还依赖于Math.random函数本身。如果Math.random没有定义,我们的getRandomInRange函数也无法运行。也就是说,它getRandomInRange依赖于另一个模块的功能。因此,Math.random它也是一个依赖项。

让我们通过参数明确传递依赖关系:

const getRandomInRange = (
  min: number,
  max: number,
  random: () => number,
): number => random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

现在该函数不仅使用两个数字,而且还使用一个random返回数字的函数。我们将getRandomInRange像这样调用它:

const result = getRandomInRange(1, 10, Math.random);
Enter fullscreen mode Exit fullscreen mode

为了避免Math.random一直传递,我们可以将其作为最后一个参数的默认值。

const getRandomInRange = (
  min: number,
  max: number,
  random: () => number = Math.random
): number => random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

这是依赖倒置的原始实现。我们将模块运行所需的所有依赖项传递给它。

为什么需要它?

真的,为什么要把Math.random参数放进去然后直接用呢?直接在函数内部用有什么问题吗?原因有两个。

可测试性

当所有依赖项都明确声明后,模块测试起来会更容易。我们可以看到运行测试需要准备什么。我们知道哪些部分会影响该模块的运行,因此可以根据需要用一些简单的实现或模拟实现来替换它们。

Mock 实现让测试变得简单很多,有时候没有它们你根本测试不了任何东西。就像我们的函数一样getRandomInRange,我们无法测试它返回的最终结果,因为它是……随机的。

/*
 * We can create a mock function
 * that will always return 0.1 instead of a random number:
 */
const mockRandom = () => 0.1;

/* Next, we call our function by passing the mock object as its last argument: */
const result = getRandomInRange(1, 10, mockRandom);

/*
 * Now, since the algorithm within the function is known and deterministic,
 * the result will always be the same:
 */
console.log(result === 1); // -> true
Enter fullscreen mode Exit fullscreen mode

用另一个依赖项替换一个依赖项

在测试期间交换依赖项是一种特殊情况。通常,我们可能出于其他原因想要将一个模块交换为另一个模块。

如果新模块的行为与旧模块相同,我们可以用一个模块替换另一个模块:

const otherRandom = (): number => {
  /* Another implementation of getting a random number... */
};

const result = getRandomInRange(1, 10, otherRandom);
Enter fullscreen mode Exit fullscreen mode

但是我们能保证新模块的行为与旧模块相同吗?是的,我们可以,因为我们使用了() => number参数类型。这就是我们使用 TypeScript 而不是 JavaScript 的原因。类型和接口是模块之间的纽带。

对抽象的依赖

乍一看,这可能有点过于复杂。但事实上,采用这种方法:

  • 模块之间的相互依赖性降低;
  • 在开始编写代码之前,我们必须设计行为。

当我们提前设计行为时,我们会使用抽象约定。在这些约定下,我们设计自己的模块或第三方模块的适配器。这使我们能够替换系统的某些部分,而无需完全重写。

当模块比上面的示例更复杂时,这尤其有用。

依赖注入

我们再写一个计数器。我们的计数器可以增加或减少,并且还能记录……的状态。

class Counter {
  public state: number = 0;

  public increase(): void {
    this.state += 1;
    console.log(`State increased. Current state is ${this.state}.`);
  }

  public decrease(): void {
    this.state -= 1;
    console.log(`State decreased. Current state is ${this.state}.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

这里我们遇到了和之前一样的问题——我们不仅使用了类实例的内部状态,还使用了另一个模块—— console。我们必须注入这种依赖关系。

如果在函数中我们通过参数传递依赖项,那么在类中我们将通过构造函数注入依赖项。

我们的Counter类使用了 console 对象的 log 方法。这意味着该类需要传递某个包含该log方法的对象作为依赖项。其实并非必须如此console——我们只需要考虑模块的可测试性和可替换性。

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

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

  public state: number = 0;

  public increase(): void {
    this.state += 1;
    this.logger.log(`State increased. Current state is ${this.state}.`);
  }

  public decrease(): void {
    this.state -= 1;
    this.logger.log(`State decreased. Current state is ${this.state}.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

然后我们将创建此类的实例,如下所示:

const counter = new Counter(console);
Enter fullscreen mode Exit fullscreen mode

如果我们想console用其他东西替换,我们只需要确保新的依赖项实现了Logger接口:

const alertLogger: Logger = {
  log: (message: string): void => {
    alert(message);
  },
};

const counter = new Counter(alertLogger);
Enter fullscreen mode Exit fullscreen mode

自动注射和 DI 容器

现在该类不再使用隐式依赖项了。这很好,但这种注入方式仍然很尴尬:每次你都必须手动添加对象的引用,如果有多个对象,还要尽量避免顺序混乱等等……

这应该由一个特殊的东西来处理——DI 容器。一般来说,DI 容器是一个只向其他模块提供依赖关系的模块。

容器知道哪个模块需要哪些依赖项,并在需要时创建并注入它们。我们将对象从跟踪其依赖项的义务中解放出来,控制权转移到其他地方,正如 SOLID 中的字母 S 和 D 所暗示的那样。

容器会知道每个模块需要哪些对象以及哪些接口。它还会知道哪些对象实现了这些接口。当创建依赖于这些接口的对象时,容器会自动创建并访问它们。

自动注射实践

我们将使用Brandi DI 容器,它的功能与我们上面描述的完全一致。

让我们首先声明Logger接口并创建其ConsoleLogger实现:

/* Logger.ts */

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

export class ConsoleLogger implements Logger {
  public log(message: string): void {
    console.log(message)
  }
}
Enter fullscreen mode Exit fullscreen mode

现在我们需要了解令牌。由于我们将 TypeScript 编译成 JavaScript,因此代码中不会包含接口和类型。Brandi 使用令牌将依赖项绑定到 JavaScript 运行时中的实现。

/* tokens.ts */

import { token } from 'brandi';

import { Logger } from './Logger';
import { Counter } from './Counter';

export const TOKENS = {
  logger: token<Logger>('logger'),
  counter: token<Counter>('counter'),
}; 
Enter fullscreen mode Exit fullscreen mode

上面的代码提到了Counter。我们看看依赖项是如何注入的:

/* Counter.ts */

import { injected } from 'brandi';

import { TOKENS } from './tokens';
import { Logger } from './Logger';

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

  /* Other code... */
}

injected(Counter, TOKENS.logger);
Enter fullscreen mode Exit fullscreen mode

我们使用该injected函数来指定应该通过哪个令牌注入依赖项。

由于令牌是有类型的,因此无法注入具有不同接口的依赖项,这将在编译时引发错误。

最后,我们来配置容器:

/* container.ts */

import { Container } from 'brandi';

import { TOKENS } from './tokens';
import { ConsoleLogger } from './logger';
import { Counter } from './counter';

export const container = new Container();

container
  .bind(TOKENS.logger)
  .toInstance(ConsoleLogger)
  .inTransientScope();

container
  .bind(TOKENS.counter)
  .toInstance(Counter)
  .inTransientScope();
Enter fullscreen mode Exit fullscreen mode

我们将令牌与它们的实现绑定在一起。

现在,当我们从容器中获取一个实例时,它的依赖项将被自动注入:

/* index.ts */

import { TOKENS } from './tokens';
import { container } from './container';

const counter = container.get(TOKENS.counter);

counter.increase() // -> State increased. Current state is 1.
Enter fullscreen mode Exit fullscreen mode

啥是inTransientScope()

瞬态是容器将创建的一种实例生命周期。

  • inTransientScope()— 每次从容器中获取实例时都会创建一个新实例;
  • inSingletonScope()— 每次获取都会返回相同的实例。

Brandi 还允许您在容器解析范围内创建实例

容器的好处

第一个好处是,我们可以用一行代码更改所有模块的实现。这样就实现了 SOLID 最后一个字母所说的控制反转。

例如,如果我们想Logger在所有依赖此接口的地方更改实现,我们只需要更改容器中的绑定:

/* The new implementation. */
class AlertLogger implements Logger {
  public log(message: string): void {
    alert(message);
  }
}

/*
 * With this implementation we replace the old `ConsoleLogger`.
 * This only needs to be done in one place, at the token binding:
 */
container
  .bind(TOKENS.logger)
  .toInstance(AlertLogger)
  .inTransientScope();
Enter fullscreen mode Exit fullscreen mode

另外,我们不需要手动传递依赖项,我们不需要遵循构造函数中的枚举顺序,模块之间的耦合度变得更低。

您应该使用 DI 吗?

花点时间了解一下使用 DI 的潜在利弊。虽然你需要编写一些基础代码,但你的代码耦合度会更低,更灵活,也更易于测试。

鏂囩珷鏉ユ簮锛�https://dev.to/vovaspace/dependency-injection-in-typescript-4mbf
PREV
🔥 VSCode 网格编辑器布局就在这里!
NEXT
如何使用 JavaScript 检测空闲的浏览器标签页