Вы когда-нибудь оказывались по уши в JavaScript‑приложении, следуя за цепочкой вызовов require() как по хлебным крошкам, чтобы понять, как всё связано? Один модуль импортирует другой, тот тянет глобальную переменную, и вот вы уже гоняетесь за ссылками по всему коду, просто чтобы протестировать один компонент. Это как готовить блюдо, где каждый ингредиент спрятан в отдельном шкафу — вы тратите половину времени на поиски, а не на готовку. Именно эту проблему и решает dependency injection (внедрение зависимостей): вместо того чтобы каждый класс сам добывал нужные зависимости, вы говорите центральной "кухне", что вам нужно — и получаете всё на блюдечке.

Эта идея не нова. На самом деле, в языках вроде Java и C# внедрение зависимостей встроено в сами фреймворки. Сервисы объявляют, что им нужно, а контейнер автоматически подставляет нужные зависимости. Результат — слабая связанность, лёгкое юнит‑тестирование и понятная структура приложения. В этой статье мы разберёмся, почему DI важен, почему он редко встречается в JavaScript и как новые библиотеки, вроде @wroud/di, могут это изменить.

1. Почему dependency injection важен

Прежде чем углубляться в особенности JavaScript, давайте ответим на очевидный вопрос: зачем вообще DI? Внедрение зависимостей — это частный случай инверсии управления: вместо того чтобы классы сами создавали свои зависимости, это делает внешний контейнер. Это простое изменение мышления даёт несколько суперсил:

  • Слабая связанность и удобство сопровождения. Когда сервисы зависят от абстракций, а не конкретных реализаций, вы можете заменять или рефакторить реализацию без затрагивания потребителей. Хотите поменять логгер? Меняете одну строку регистрации вместо всех new Logger().

  • Тестируемость. Зависимости внедряются, значит в тестах можно подставлять моки или фейки. DI часто называют способом упростить юнит‑тестирование классов.

  • Централизованная конфигурация. Время жизни сервисов и их реализации определяются в одном месте — обычно на старте — что упрощает структуру приложения и снижает количество шаблонного кода.

Все вместе эти преимущества позволяют писать модульный, предсказуемый и легко тестируемый код.

2. Почему DI редкость в JavaScript/React

Если DI так хорош, почему его так редко используют в JS? В JavaScript множество факторов делают DI непривычным. В отличие от C#, в языке нет встроенной рефлексии или метаданных для анализа конструкторов во время выполнения. Нет простого способа спросить у класса: "Что тебе нужно?" — не прибегая к декораторам или метаданным TypeScript. Angular решает это с помощью своего инжектора, а вот React полностью полагается на ручную композицию.

Есть ещё и культурный фактор. React продвигает композицию вместо наследования и базируется на простых примитивах: props, hooks, context. Эти паттерны решают многие те же задачи, что и DI, поэтому команды редко чувствуют необходимость во внедрении зависимостей. В небольших приложениях передавать зависимости через props или импорт модуля — вполне приемлемо. В итоге DI почти не используется в JS.

Но когда проект растёт, ручная передача зависимостей приводит к хрупким модулям, рассеянной конфигурации и вложенным props. Представьте себе игру в "испорченный телефон": каждый уровень компонентов передаёт зависимость дальше. Это приводит к "проп-дриллингу" и скрытой связанности. Вот тут-то DI начинает играть роль.

3. Где всё-таки используют DI в JavaScript

Несмотря на редкость, структурированное управление зависимостями используется в некоторых JS‑экосистемах:

  • Иерархический инжектор Angular. Angular позволяет предоставлять сервисы на уровне root, модуля или компонента. Каждая секция может иметь свои сервисы, но использовать и общие.

  • provide/inject во Vue. Для борьбы с проп‑дриллингом Vue позволяет родительскому компоненту предоставить значение, которое потомки могут внедрить.

  • Service locators. В больших кодовых базах, вроде Visual Studio Code, сервисы регистрируются глобально и извлекаются по запросу. Это не полноценный DI, но показывает, что структурированное управление зависимостями полезно в масштабируемых приложениях.

Эти примеры доказывают: при росте приложения разработчики всё равно приходят к структурированной работе с зависимостями — даже в JavaScript.

4. Сравнение DI в разных экосистемах

Разные экосистемы по‑разному подходят к внедрению зависимостей:

  • Spring / .NET Core. Классы аннотируются или регистрируются в контейнере, зависимости разрешаются автоматически. Конфигурация — декларативная, через аннотации и builder‑функции.

  • Angular. Сервисы аннотируются @Injectable() и регистрируются в иерархическом инжекторе. Конфигурация рядом с модулями и компонентами.

  • Vue. Значения передаются через provide() и получаются через inject(). Паттерн императивный, но лёгкий и понятный.

  • React. Зависимости подключаются вручную через props, hooks и context. Это явно, но приводит к проп‑дриллингу и сильной связанности в больших приложениях.

  • Service locator. Сервисы регистрируются глобально, модули получают их по запросу. Просто, но скрывает зависимости и усложняет тестирование.

Вывод? В JavaScript нет стандартного подхода к DI — каждый фреймворк решает это по‑своему или избегает совсем.

5. Знакомьтесь: @wroud/di и @wroud/di-react

Новое поколение библиотек стремится принести полноценный DI в JavaScript без тяжёлой рефлексии. @wroud/di — это лёгкий DI‑контейнер, написанный на TypeScript. Он вдохновлён системой .NET и поддерживает ES‑модули, декораторы, асинхронную загрузку сервисов и различные времена жизни (singleton, transient, scoped). Вот основные особенности:

  • Современный и гибкий. Использует ES‑модули и декораторы, позволяя описывать зависимости прямо рядом с классами.

  • DSL регистрации. Класс ServiceContainerBuilder позволяет регистрировать сервисы с явным временем жизни: addSingleton, addTransient и т.п.

  • Без рефлексии. Декоратор @injectable позволяет явно указать зависимости — без метаданных и полифилов. TypeScript выводит типы.

  • Асинхронность и области. Сервисы можно загружать лениво с помощью lazy() и создавать области (scopes) для компонентов, которым нужен собственный экземпляр.

Вот пример:

import { ServiceContainerBuilder, injectable } from "@wroud/di";

@injectable()
class Logger {
  log(message: string) {
    console.log(message);
  }
}

@injectable(({ single }) => [single(Logger)])
class Greeter {
  constructor(private logger: Logger) {}
  sayHello(name: string) {
    this.logger.log(`Hello ${name}`);
  }
}

const container = new ServiceContainerBuilder()
  .addSingleton(Logger)
  .addTransient(Greeter)
  .build();

const greeter = container.getService(Greeter);
greeter.sayHello("world");

Пакет @wroud/di-react интегрирует контейнер с React. Компонент ServiceProvider предоставляет сервисы в дерево компонентов, а хук useService() позволяет получать зависимости в функциях. API поддерживает React Suspense для ленивых сервисов и scoped‑контейнеры для изолированных инстансов. Пример:

import React from "react";
import { ServiceContainerBuilder, injectable } from "@wroud/di";
import { ServiceProvider, useService } from "@wroud/di-react";

@injectable()
class Logger {
  log(message: string) {
    console.log(message);
  }
}

@injectable(({ single }) => [single(Logger)])
class Greeter {
  constructor(private logger: Logger) {}
  sayHello(name: string) {
    this.logger.log(`Hello ${name}`);
  }
}

const container = new ServiceContainerBuilder()
  .addSingleton(Logger)
  .addTransient(Greeter)
  .build();

function GreetButton() {
  const greeter = useService(Greeter);
  return (
    <button onClick={() => greeter.sayHello("React")}>Greet</button>
  );
}

export default function App() {
  return (
    <ServiceProvider provider={container}>
      <GreetButton />
    </ServiceProvider>
  );
}

С такой настройкой ваши компоненты фокусируются на своём назначении — рендере UI и обработке событий — а контейнер заботится о зависимостях.

Заключение и призыв к действию

Dependency injection может казаться чуждым JavaScript‑разработчикам, привыкшим к ручной передаче зависимостей. Но его преимущества — слабая связанность, удобное тестирование, структурированная конфигурация — не менее ценны и в JS. По мере роста приложения стоимость ручной связки увеличивается. Библиотеки вроде @wroud/di предлагают простой способ внедрения инверсии управления без рефлексии. А в связке с @wroud/di-react это становится естественным дополнением к компонентной модели React.

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

Я бы хотел услышать ваш опыт внедрения зависимостей в JavaScript и React. Пробовали ли вы @wroud/di или похожие подходы? С какими сложностями или преимуществами вы столкнулись? Задавайте вопросы, делитесь своими наблюдениями или спорьте с доводами статьи — ваш взгляд может помочь другим.

Исходники и полезные утилиты доступны в моём репозитории на GitHub:
https://github.com/Wroud/foundation

Комментарии (3)


  1. GlennMiller1991
    07.08.2025 09:49

    Может я не прав и вы меня поправите, но в контексте реакта складывается ощущение, что некоторые недостатки никак не обыгрываются. Например, тот же props drilling. Вернее, обыгрываются но только для простых случаев.

    Сферическая ситуация на основе кода в вашей статье.
    Я компонент и я не хочу знать о существовании многих классов, имплементирующих некоторый интерфейс. Хочу знать только об одном интерфейсе логгера {log(): void}, ну или в крайнем случае о базовом классе, поскольку типов в рантайме нет. Если один из родителей хочет установить для своих детей в качестве логгера некоторый логгер, наследующий базовый класс, но отличающийся от зарегистрированного, ему по всей видимости нужно создать свой контейнер зависимостей, зарегать класс, и обернуть детей провайдером.
    Это при условии, что либа построит своё дерево контейнеров, и будет разрешать зависимости по всему дереву от текущей ветви к корню.
    В той же ситуации есть ещё один нюанс. Если некоторый класс, который поднимается внутри компоненты, захочет взять этот экземпляр логгера, то просто напросто не сможет либо из-за реактовского ограничения на использование хуков, либо из-за необходимости сослаться на контейнер текущей ветви, как на глобальную переменную.
    Итого, придётся как в старые добрые времена прокидывать всё добро аргументами. Ну разве что сейчас можно прокидывать не зависимости, а ближайший контейнер, если библиотека приспособлена.

    По статье, мне не хватило хоть какого-нибудь описания жиненных циклов. Одного упоминания маловато.

    Буду рад ошибиться, потому как реакт местами дико неудобный, и DI действительно теоеретически может помочь в больших проектах


    1. Wroud Автор
      07.08.2025 09:49

      В описанном кейсе действительно DI не решит проблему до конца. Но давай посмотрим с другой стороны:

      • можно ли зарегистрировать два логгера глобально и использовать нужный в нужном контексте (например, по token);

      • почему возникла необходимость в отдельном подтипе логгера? Может быть, это можно решить внутри самого логгера — например, создать от него "копию" с другим поведением и передать её уже средствами React (через контекст/пропсы);

      Когда мы говорим про использование DI в React-приложениях, важно понимать разницу в парадигмах. DI отлично подходит для бизнес-логики, инфраструктуры, управления состоянием вне UI — всего, что живёт "дольше", чем рендер компонента. React же предлагает свои механизмы для UI-слоя: props, context, state, hooks.

      Я намеренно не реализовал иерархию контейнеров в @wroud/di. Хотя такая фича часто востребована (см. Angular, InversifyJS), она влечёт за собой огромную сложность и тонны edge-кейсов. Зато без неё библиотека остаётся предсказуемой и простой в использовании, что особенно важно для frontend-проектов.

      Основная сложность, как мне кажется, не в том, что DI не работает, а в том, что React не даёт чёткой модели для архитектуры приложения. Он предоставляет мощный рендеринг-движок, но не говорит, как разделять бизнес-логику, работу с API, глобальное состояние и прочее. Поэтому и получается, что у каждого проекта свой подход.

      Наш опыт в CloudBeaver подтверждает, что DI в больших frontend-проектах даёт ощутимую пользу. Мы используем InversifyJS (на тот момент @wroud/di ещё не было), и да, поначалу многим было непросто. Но со временем стало понятно: DI помогает структурировать код и заставляет отделять бизнес-логику от UI, что делает приложение гораздо легче для поддержки.

      Что касается жизненного цикла: он очень простой. Контейнер обычно создаётся один раз на старте приложения и передаётся в React через Context. Есть три типа сервисов:

      • singleton — один экземпляр на всё приложение;

      • transient — новый экземпляр при каждом запросе;

      • scoped — для серверных приложений, где нужен отдельный scope на каждый запрос. В UI этот режим почти не используется.

      Надеюсь, это немного прояснит общую картину. DI — не серебряная пуля, но в определённых слоях приложения он может сильно упростить архитектуру.


    1. Vitaly_js
      07.08.2025 09:49

      Я компонент и я не хочу знать о существовании многих классов, имплементирующих некоторый интерфейс. Хочу знать только об одном интерфейсе логгера {log(): void}, ну или в крайнем случае о базовом классе, поскольку типов в рантайме нет. Если один из родителей хочет установить для своих детей в качестве логгера некоторый логгер, наследующий базовый класс, но отличающийся от зарегистрированного, ему по всей видимости нужно создать свой контейнер зависимостей, зарегать класс, и обернуть детей провайдером.

      Вы не поняли идею контейнера. Контейнер - это единственная точка входа для внедряемых зависимостей. Он по определению один на все приложение. Если у вас приложение использует в разных местах разные реализации логеров - это означает, что внедряемых логер является конфигурируемым. И тогда родитель устанавливает конфигурацию логера для своих потомков, но экземпляр логера они получают из DI, а вот конфигурацию из контекста.
      По сути, тоже самое выше ответили.