控制反转(IoC)技术的出现打破了开发者的传统编程模式,她使得开发者更灵活更容易地控制代码。依赖注入则是控制反转的一种形态,旨在分离构造对象和使用对象的关注点。本文中,你将会了解到什么是依赖注入,什么时候你会用到她,哪些流行的JS框架已经实现了她。
什么是依赖注入?
依赖注入是一种软件设计模式,一个对象或者函数需要使用其他的对象或者函数(依赖)的时候并不需要关心其内部实现细节,向需要的对象提供依赖关系的任务留给了注入者,有时候我们也称他们为汇编器、供应者或者容器。
例如,考虑视频游戏机如何只需要兼容的光盘或盒式磁带即可运行。不同的光盘或盒式磁带通常包含有关不同游戏的信息。玩家不需要了解游戏机的内部结构,通常只需要插入或更换游戏光盘即可玩游戏。
通过这种方式设计软件或编写代码,可以很容易地删除或替换组件(如上面的示例),编写单元测试,并减少代码库中的样板代码。
虽然依赖注入是其他语言(特别是Java、C#和Python)中常用的一种模式,但在JavaScript中却不太常见。在下面的部分中,您将考虑这种模式的利弊、它在流行的JavaScript框架中的实现,以及如何在考虑依赖注入的情况下设置JavaScript项目。
你为什么需要依赖注入?
为了充分理解依赖注入为什么有用,了解IoC技术解决的问题很重要。例如,考虑前面的视频游戏机类比;尝试用代码表示控制台的功能。在本例中,您将把控制台称为NoSleepStation,并使用以下假设:
- NoSleepStation控制台只能玩为NoSleepStation设计的游戏
- 控制台的唯一有效输入源是光盘
有了这些信息,可以通过以下方式实现NoSleepStation控制台:
// The GameReader class
class GameReader {
constructor(input) {
this.input = input;
}
readDisc() {
console.log("Now playing: ", this.input);
}
}
// The NoSleepStation Console
class class NSSConsole {
gameReader = new GameReader("TurboCars Racer");
play() {
this.gameReader.readDisc();
}
}
// use the classes above to play
const nssConsole = new NSSConsole();
nssConsole.play();
核心控制台逻辑位于“GameReader”类中,它有一个从属的“NSSConsole”。控制台的“play”方法使用“GameReader”实例启动游戏。然而,这里有一些很明显的问题,包括灵活性和测试。
灵活性
前面提到的代码是不灵活的,如果用户想玩不同的游戏,他们必须修改“NSSConsole”类,这类似于在现实生活中拆开控制台。这是因为核心依赖项“GameReader”类被硬编码到“NSSConsole”实现中。
依赖注入通过将类与其依赖项分离来解决这个问题,只在需要时提供这些依赖项。在前面的代码示例中,“NSSConsole”类真正需要的只是来自“GameReader”实例的“readDisc()”方法。
通过依赖注入,可以像这样重写前面的代码:
class GameReader {
constructor(input) {
this.input = input;
}
readDisc() {
console.log("Now playing: ", this.input);
}
changeDisc(input) {
this.input = input;
this.readDisc();
}
}
class NSSConsole {
constructor(gameReader) {
this.gameReader = gameReader;
}
play() {
this.gameReader.readDisc();
}
playAnotherTitle(input) {
this.gameReader.changeDisc(input);
}
}
const gameReader = new GameReader("TurboCars Racer");
const nssConsole = new NSSConsole(gameReader);
nssConsole.play();
nssConsole.playAnotherTitle("TurboCars Racer 2");
此代码中最重要的更改是“NSSConsole”和“GameReader”类已分离。虽然“NSSConsole”仍然需要“GameReader”实例才能运行,但它不必显式创建一个。创建“GameReader”实例并将其传递给“NSSConsole”的任务留给依赖注入提供程序。
在大型代码库中,这可以显著帮助减少代码样板,因为创建和连接依赖项的工作由依赖项注入提供程序处理。这意味着您不必担心创建某个类所需的类的实例,尤其是当这些依赖项有自己的依赖项时。
测试
依赖注入最重要的优点之一是在单元测试中。通过将提供类依赖项的工作委托给外部提供程序,可以使用模拟或存根来代替未测试的对象:
// nssconsole.test.js
const gameReaderStub = {
readDisc: () => {
console.log("Read disc");
},
changeDisc: (input) => {
console.log("Changed disc to: " + input);
},
};
const nssConsole = new NSSConsole(gameReaderStub);
只要依赖项的接口(公开的方法和属性)不变,就可以使用具有相同接口的简单对象单元测试来代替实际实例。
依赖注入的限制
需要注意的是,使用依赖注入并非没有缺点,这通常与依赖注入如何解决减少代码样板的问题有关。
为了充分利用依赖注入,特别是在大型代码库中,必须设置依赖注入提供程序。设置这个提供程序可能需要很多工作,在一个简单的项目中有时是不必要的开销。
这个问题可以通过使用依赖注入库或框架来解决。然而,在大多数情况下,使用依赖注入框架需要完全购买,因为在配置过程中,不同的依赖注入框架之间没有太多API相似之处。
流行js框架中的依赖注入
在很多流行的js框架中依赖注入是个很重要的特性,比较有名的包括Angular,NestJS和Vue。
Angular
Angular是一个客户端web开发框架,包含构建、测试和维护web应用程序所需的工具。依赖注入内置于框架中,这意味着不需要配置,这是使用Angular开发的推荐方法。
以下是依赖注入如何在Angular中工作的代码示例(摘自Angular文档):
// logger.service.ts
import { Injectable } from '@angular/core';
@Injectable({providedIn: 'root'})
export class Logger {
writeCount(count: number) {
console.warn(count);
}
}
// hello-world-di.component.ts
import { Component } from '@angular/core';
import { Logger } from '../logger.service';
@Component({
selector: 'hello-world-di',
templateUrl: './hello-world-di.component.html'
})
export class HelloWorldDependencyInjectionComponent {
count = 0;
constructor(private logger: Logger) { }
onLogMe() {
this.logger.writeCount(this.count);
this.count++;
}
}
在Angular中,对类使用“@Injectable”修饰符表示可以注入该类。可注射类可通过三种方式提供给家属:
- 在组件级别,使用“@component”修饰符的“providers”字段
- 在NgModule级别,使用“@NgModule”修饰符的“providers”字段
- 在应用程序根级别,通过将“providedIn:root”添加到“@Injectable”修饰符(如前所述)
在Angular中注入依赖项就像在类构造函数中声明依赖项一样简单。参考前面的代码示例,下面一行显示了如何做到这一点:
constructor(private logger: Logger) // Angular injects an instance of the LoggerService class
NestJS
NestJS是一个JavaScript框架,其架构可有效构建高效、可靠和可扩展的后端应用程序。由于NestJS在设计上从Angular中汲取了大量灵感,因此NestJS中的依赖注入工作原理非常相似:
// logger.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class Logger {
writeCount(count: number) {
console.warn(count);
}
}
// hello-world-di.component.ts
import { Controller } from '@nestjs/common';
import { Logger } from '../logger.service';
@Controller('')
export class HelloWorldDependencyInjectionController {
count = 0;
constructor(private logger: Logger) { }
onLogMe() {
this.logger.writeCount(this.count);
this.count++;
}
}
注意,在NestJS和Angular中,依赖关系通常被视为单体。这意味着一旦找到依赖项,它的值就会被缓存并在整个应用程序生命周期中重用。要在NestJS中更改此行为,需要配置“@Injectable”装饰器选项的“scope”属性。在Angular中,您将配置“@Injectable”装饰器选项的“providedIn”属性。
Vue
Vue.js是一个用于构建用户界面的声明性和基于组件的JavaScript框架。在创建组件时,可以使用“provide”和“inject”选项配置Vue.js中的依赖注入。
要指定哪些数据应提供给组件的后代,请使用“provide”选项:
export default {
provide: {
message: 'hello!',
}
}
然后可以使用“inject”选项将这些依赖项注入到需要它们的组件中:
export default {
inject: ['message'],
created() {
console.log(this.message) // injected value
}
}
要提供应用程序级依赖关系,类似于Angular中的“providedIn:'root”,请使用Vue.js应用程序实例中的“提供”方法:
import { createApp } from 'vue'
const app = createApp({})
app.provide(/* key */ 'message', /* value */ 'hello!')
Js依赖注入框架
到目前为止,您已经在JavaScript框架的上下文中考虑了依赖注入。但是,如果有人想在不使用任何上述框架的情况下启动一个项目,并且他们不想从头开始实现依赖注入容器/解析器,该怎么办?
有几个库为这个场景提供依赖注入功能,即injection-js、InversifyJS和TSyringe。在下一节中,您将重点介绍InversifyJS,但可能需要看看其他包,看看它们是否更适合您的需求。
InversifyJS是JavaScript的依赖注入容器。它旨在尽可能减少运行时开销,同时促进和鼓励良好的面向对象编程(OOP)和IoC实践。项目的存储库可以在GitHub上找到。
要在项目中使用InversifyJS,需要将其添加到项目的依赖项中。在这里,您将使用整个游戏示例来设置InversifyJS。因此,使用“npm init”创建一个新项目。打开终端,按顺序运行以下命令:
mkdir inversify-di
cd inversify-di
npm init
接下来,使用npm或Yarn将“inversify”添加到项目的依赖项中:
# Using npm
npm install inversify reflect-metadata
# or if you use yarn:
# yarn add inversify reflect-metadata
InversifyJS依赖于Metadata Reflection API,因此您需要安装并使用“reflect-Metadata”包作为polyfill。
接下来,通过首先创建两个空文件来添加“NSSConsole”和“GameReader”类的代码:
touch game-reader.mjs nssconsole.mjs
然后继续将以下代码分别附加到每个文件:
// game-reader.mjs
export default class GameReader {
constructor(input = "TurboCars Racer") {
this.input = input;
}
readDisc() {
console.log("Now playing: ", this.input);
}
changeDisc(input) {
this.input = input; this.readDisc();
}
}
// nssconsole.mjs
export default class NSSConsole {
constructor(gameReader) {
this.gameReader = gameReader;
}
play() {
this.gameReader.readDisc();
}
playAnotherTitle(input) {
this.gameReader.changeDisc(input);
}
}
最后,将项目配置为使用InversifyJS:
touch config.mjs index.mjs
// config.mjs
import { decorate, injectable, inject, Container } from "inversify";
import GameReader from "./game-reader.mjs";
import NSSConsole from "./nssconsole.mjs";
// Declare our dependencies' type identifiers
export const TYPES = {
GameReader: "GameReader",
NSSConsole: "NSSConsole",
};
// Declare injectables
decorate(injectable(), GameReader);
decorate(injectable(), NSSConsole);
// Declare the GameReader class as the first dependency of NSSConsole
decorate(inject(TYPES.GameReader), NSSConsole, 0);
// Declare bindings
const container = new Container();
container.bind(TYPES.NSSConsole).to(NSSConsole);
container.bind(TYPES.GameReader).to(GameReader);
export { container };
// index.mjs
// Import reflect-metadata as a polyfill
import "reflect-metadata";
import { container, TYPES } from "./config.mjs";
// Resolve dependencies
// Notice how we do not need to explicitly declare a GameReader instance anymore
const myConsole = container.get(TYPES.NSSConsole);
myConsole.play();
myConsole.playAnotherTitle("Some other game");
您可以通过从项目的根目录运行“node-index.mjs”来测试新设置。
这个例子虽然很小,但对于大多数希望使用InversifyJS的项目来说,可以作为一个很好的起点。
总结
在本文中,您了解了JavaScript中的依赖注入、它的优缺点、流行JavaScript框架中的示例,以及如何在普通JavaScript项目中使用它。
如果您正在寻找JavaScript的替代依赖注入库或审查其维护和包运行状况评分,请查阅Snyk Advisor列出的23个最佳JavaScript依赖注入库。