IoC в обычном JavaScript (ES6+)
Дата публикации: 2023-07-17Инверсию управления (IoC) в JavaScript бывает сложнее почувствовать, чем в PHP, Java или TypeScript: в языке нет встроенных интерфейсов. Здесь рассматривается ES6+ код, разложенный на ES-модули и связанный через import/export.
Импорт как зависимость
На уровне модуля любой статический или динамический импорт — зависимость от другого модуля. Например, у service есть зависимость от конкретного logger:
import logger from './logger.js';
export class Service { /* ... */ }
Разработчик сервиса открывает импорт и видит реализацию:
export default {
error: (msg) => console.error(msg),
info: (msg) => console.info(msg),
};
Затем он использует её прямо внутри модуля:
import logger from './logger.js';
export class Service {
exec() { logger.info('Service is running.'); }
}
Так исходники оказываются жёстко собраны import-ами в единую иерархию ещё при написании кода.
Инверсия управления
Представим, что логгеров несколько: консольный, файловый, сетевой, для базы данных. Разные разработчики могут сделать их независимо, но сначала им нужно договориться о контракте:
/** @interface */
class ILogger {
error(msg) {}
info(msg) {}
}
Это не исполняемый код, а соглашение, которое JSDoc делает понятным человеку и IDE. Реализации следуют ему:
class LoggerFile { error(msg) {} info(msg) {} }
class LoggerNet { error(msg) {} info(msg) {} }
Автор Service не знает, какой логгер будет выбран при запуске, поэтому принимает зависимость извне — классически через конструктор:
export class Service {
constructor(logger) { this.logger = logger; }
exec() { this.logger.info('Service is running.'); }
}
Это и есть IoC: источник зависимости выбирает не сервис, а внешний для него код — обычно объектный контейнер. Связи задаются во время выполнения и могут зависеть от условий, а не закрепляются во всех модулях статическими импортами.
Что даёт такой подход
В проекте с DI почти все импорты могут остаться в composition root — месте, где приложение собирают. Сами модули должны разрешать внедрение нужных зависимостей через конструктор или setter. Тогда они слабее связаны и переносятся между проектами, если новая среда предоставляет нужные контракты.
Особенно наглядно это в тесте. Вместо файлового или сетевого логгера передаём простую заглушку:
const logger = { error(msg) {}, info(msg) {} };
const service = new Service(logger);
service.exec();
Тест управляет поведением зависимости, не ссылаясь на конкретную реализацию. В архитектурном смысле IoC сначала отвечает за то, как изготовлены отдельные «кирпичи», и лишь затем — как они сложены в здание. Хорошо подготовленные модули можно соединять по-разному.
Поэтому ES6-модули, не привязанные статическими импортами к конкретным службам, могут быть строительными блоками и браузерного, и Node.js-приложения.
Дополнительные фрагменты исходного кода
class LoggerNet {
error(msg) { } info(msg) { }
}
export class Service {
logger; constructor(logger) {
this.logger = logger;
}
exec(opts) {
this.logger.info(Service is running.);
}
}
import assert from ‘assert’;
import {describe, it} from ‘mocha’;
import {Service} from ‘./service.js’; const logger = {
error(mg) {},
info(msg) {}
};
describe(‘Service’, () => {
it(‘does the job’, () => {
const service = new Service(logger);
service.exec({});
assert(true);
});
});