Dependency Injection без магии: простой контейнер объектов для современного JavaScript

Дата публикации: 2023-07-31

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

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 — а не учебный парсер из статьи.

Дополнительные фрагменты исходного кода

export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
}
import fLogger from ‘./logger.js’;
import fService from ‘./service.js’; const logger = await fLogger();
const serv = await fService({[‘./logger.js’]: logger});
serv({name: ‘The Spec’});
function Factory(
{
}
) { }
function parser (def) {
const res = [];
const parts = /function Factory({(.)})./s.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
res.push(path);
}
}
return res;
}; const paths = parser(factory.toString());
const deps = {}; const FN = /function Factory({(.)})./s;
function parser(def) {
const res = [];
const parts = FN.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
res.push(path);
}
}
return res;
}
async function get(key) {
if (deps[key]) return deps[key];
else {
const {default: factory} = await import(key);
const def = factory.toString();
const paths = parser(def);
const spec = {};
for (const path of paths)
spec[path] = await get(path);
const res = factory(spec);
deps[key] = res;
return res;
}
}
export default {get};
import container from ‘./container.js’; const serv = await container.get(‘./service.js’);
serv({name: ‘The Object Container’});
import logger from ‘./logger.js’; export default function {
logger.info(Service is running with: ${JSON.stringify(opts)});
}
export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
} In both cases, we use “details” (speaking in terms of the Dependency Inversion Principle). In this step, we remove the details from the dependency specification and leave only abstractions:
export default async function Factory({logger, config}) {
return function (opts) {
logger.info(Service '${config.appName}' is running with: ${JSON.stringify(opts)});
};
}
import container from ‘./container.js’; const map = {
};
container.setMap(map);
const serv = await container.get(‘service’);
serv({name: ‘The Resolver’});