Dependency Injection без магии: простой контейнер объектов для современного JavaScript
Дата публикации: 2023-07-31IoC уменьшает связанность 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’});