Первоначально опубликовано на https://snyk.io/blog/dependency-injection-in-javascript/

Методы инверсии управления (IoC) дают разработчикам возможность вырваться из традиционного потока программирования и предлагают им большую гибкость и больший контроль над своим кодом. Внедрение зависимостей, одна из форм IoC, представляет собой шаблон, целью которого является разделение проблем создания объектов и их использования.

В этой статье вы узнаете, что такое внедрение зависимостей, когда его следует использовать и в каких популярных JavaScript-фреймворках оно реализовано.

Что такое внедрение зависимостей?

Внедрение зависимостей — это шаблон проектирования программного обеспечения, в котором объект или функция использует другие объекты или функции (зависимости), не беспокоясь об их базовых деталях реализации. Задача предоставления зависимостей объектам там, где они необходимы, возложена на инжектор, иногда называемый ассемблером, провайдером или контейнером.

Например, рассмотрим, как игровым консолям для работы нужен только совместимый диск или картридж. Разные диски или картриджи обычно содержат информацию о разных играх. Геймеру не нужно ничего знать о внутреннем устройстве консоли, и обычно он ничего не делает, кроме как вставляет или заменяет игровые диски, чтобы играть в игру.

Проектирование программного обеспечения или написание кода таким образом позволяет легко удалять или заменять компоненты (как в приведенном выше примере), писать модульные тесты и сокращать шаблонный код в кодовой базе.

Хотя внедрение зависимостей — это шаблон, обычно реализуемый и используемый в других языках, особенно в 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, — это метод readDisc() из экземпляра GameReader.

При внедрении зависимостей предыдущий код можно переписать следующим образом:

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 между различными фреймворками внедрения зависимостей.

Внедрение зависимостей в популярные фреймворки JavaScript

Внедрение зависимостей является ключевой функцией многих популярных фреймворков JavaScript, в частности Angular(), NestJS и Vue.js. .

УГЛОВОЙ

Angular — это среда веб-разработки на стороне клиента с инструментами, необходимыми для создания, тестирования и поддержки веб-приложений. Внедрение зависимостей встроено в фреймворк, что означает отсутствие необходимости в настройке, и это рекомендуемый способ разработки с использованием 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` декоратора `@Component`
  • На уровне NgModule, используя поле `providers` декоратора `@NgModule`
  • На корневом уровне приложения, добавив `providedIn: ‘root’` в декоратор `@Injectable` (как показано ранее)

Внедрить зависимость в Angular так же просто, как объявить зависимость в конструкторе класса. Ссылаясь на предыдущий пример кода, следующая строка показывает, как это можно сделать:

constructor(private logger: Logger) // Angular injects an instance of the LoggerService class

НЕСТЖС

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, вам необходимо настроить `scope` свойство параметров декоратора `@Injectable`. В Angular вы должны настроить `providedIn` свойство опций декоратора `@Injectable`.

VUE.JS

Vue.js — это декларативный и основанный на компонентах JavaScript-фреймворк для создания пользовательских интерфейсов. Внедрение зависимостей в Vue.js можно настроить с помощью параметров `provide` и `inject` при создании компонента.

Чтобы указать, какие данные должны быть доступны для потомков компонента, используйте опцию `provide`:

export default {
provide: {
message: 'hello!',
}
}

Затем эти зависимости могут быть внедрены в компоненты, где они необходимы, с помощью параметра «inject»:

export default {
  inject: ['message'],
  created() {
    console.log(this.message) // injected value
  }
}

Чтобы предоставить зависимость на уровне приложения, аналогичную `providedIn: ‘root’’ в Angular, используйте метод `provide` из экземпляра приложения Vue.js:

import { createApp } from 'vue'
const app = createApp({})
app.provide(/* key */ 'message', /* value */ 'hello!')

Фреймворки внедрения зависимостей JavaScript

До сих пор вы рассматривали внедрение зависимостей в контексте JavaScript-фреймворка. Но что, если кто-то хочет начать проект без использования какого-либо из вышеупомянутых фреймворков и не хочет реализовывать контейнер/преобразователь внедрения зависимостей с нуля?

Есть несколько библиотек, обеспечивающих функциональность внедрения зависимостей для этого сценария, а именно injection-js, InversifyJS и TSyringe. . В следующем разделе вы сосредоточитесь на InversifyJS, но, возможно, стоит взглянуть на другие пакеты, чтобы убедиться, что они лучше подходят для ваших нужд.

ИНВЕРСИЯ

InversifyJS — это контейнер внедрения зависимостей для JavaScript. Он разработан, чтобы добавить как можно меньше накладных расходов во время выполнения, одновременно облегчая и поощряя хорошие методы объектно-ориентированного программирования (ООП) и IoC. Репозиторий проекта можно найти на GitHub.

Чтобы использовать InversifyJS в проекте, его необходимо добавить в зависимости проекта. Здесь вы настроите InversifyJS с использованием игрового примера. Итак, создайте новый проект с помощью `npm init`. Откройте терминал и выполните следующие команды по порядку:

mkdir inversify-di
cd inversify-di
npm init

Затем добавьте `inversify` к зависимостям проекта, используя npm или Yarn:

# Using npm
npm install inversify reflect-metadata
# or if you use yarn: 
# yarn add inversify reflect-metadata

InversifyJS опирается на Metadata Reflection API, поэтому вам необходимо установить и использовать пакет reflect-metadata в качестве полифилла.

Затем добавьте код для классов `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.