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);
});
});