Дата публикации:

IoC уменьшает связанность JavaScript-кода: модуль не делает статический импорт конкретной реализации, а получает нужные объекты извне. Здесь — учебный путь к небольшому контейнеру объектов. Он рассчитан на обычный ES6+ JavaScript без транспиляции, а не на TypeScript-декораторы.

Цель не в готовой универсальной библиотеке, а в понимании механики: как загрузить ES-модули, создать их зависимости и передать их в правильные места. Демонстрационный контейнер занимает около 35 строк.

1. Composition Root

При прямом управлении сервис сам импортирует логгер:

import logger from './logger.js';
export default class Service {
  exec(opts) { logger.info(JSON.stringify(opts)); }
}

При инверсии сервис принимает зависимость в конструкторе:

export default class Service {
  constructor(logger) {
    this.exec = (opts) => logger.info(JSON.stringify(opts));
  }
}

Кто-то всё равно должен импортировать модули и создать объекты. Это место называется Composition Root:

import logger from './logger.js';
import Service from './service.js';
const service = new Service(logger);
service.exec({ name: 'Composition Root' });

2. Фабрики

Для упрощения учебного примера каждый модуль экспортирует асинхронную фабрику, которая получает зависимости и создаёт результат:

export default async function factory(logger) {
  return (opts) => logger.info(JSON.stringify(opts));
}

В реальном коде export может быть классом, функцией или объектом; фабричное соглашение здесь лишь делает следующий шаг понятнее.

3. Спецификация зависимостей

Имена обычных аргументов могут измениться при минификации. Поэтому зависимости передаются одним объектом:

function factory({ logger, config }) { /* ... */ }

Сначала ключом можно временно сделать путь к модулю:

export default async function factory({ ['./logger.js']: logger }) {
  return (opts) => logger.info(JSON.stringify(opts));
}

Так контейнер получает данные, которые нужно разобрать.

4. Парсер спецификации

Упрощённый пример превращает функцию в строку, извлекает блок параметров регулярным выражением и выделяет ключи зависимостей. Это учебный приём, не совет для production: в настоящем контейнере контракт зависимостей лучше задавать надёжной явной метаинформацией.

function parse(definition) {
  const params = /function\s+\w+\s*\(\s*\{([^}]*)\}/s.exec(definition)?.[1];
  if (!params) return [];
  return params.split(',').map((dependency) =>
    dependency.split(':')[0].trim().replace(/[\[\]'\"]/g, '')
  );
}
const paths = parse(factory.toString());

5. Контейнер

Контейнер рекурсивно импортирует модуль, находит его зависимости, строит объект спецификации и кэширует результат:

const cache = {};
async function get(key) {
  if (cache[key]) return cache[key];
  const { default: factory } = await import(key);
  const spec = {};
  for (const path of parse(factory.toString())) spec[path] = await get(path);
  return (cache[key] = await factory(spec));
}

Теперь Composition Root превращается в единственную строку:

const service = await container.get('./service.js');

6. Resolver и позднее связывание

Пути в спецификации всё ещё привязывают код к деталям. Последний шаг — оставить в модулях только абстракции:

export default async function factory({ logger, config }) {
  return (opts) => logger.info(`${config.appName}: ${JSON.stringify(opts)}`);
}

А соответствие абстракции реализации вынести в карту root:

container.setMap({
  service: './service.js',
  logger: './logger.js',
  config: './config.js',
});
const service = await container.get('service');

Это и есть позднее связывание: во время запуска можно выбрать консольный или файловый логгер, не переписывая сервис. Вместо «сварной» конструкции код становится «болтовой» — модуль легче отделить и применить в другом проекте.

В крупном проекте не поддерживают вручную карту для каждого имени. Соглашения преобразуют идентификатор вроде Vendor_Package_Mod в путь node_modules/@vendor/package/src/Mod.js; к идентификатору можно добавить срок жизни или область действия.

Вывод

DI особенно полезна там, где много файлов и пакетов: она снижает связанность и улучшает тестирование. В традиционном фронтенде бандлеры часто скрывают отдельные модули, но в PWA Service Worker может загрузить и кэшировать набор ES-файлов. Тогда одинаковый принцип связи подходит и Node.js, и браузеру. Для практического применения стоит использовать зрелый контейнер — например @teqfw/di — а не учебный парсер из статьи.