Внедрение зависимостей (Dependency Injection) в JavaScript: Полное руководство

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему ваш код нуждается в Dependency Injection?

Каждый разработчик JavaScript рано или поздно сталкивается с проблемой "жёстко связанного" (tightly coupled) кода. Представьте себе класс UserService, который внутри себя создаёт экземпляр Database и Logger. Что произойдёт, когда вы захотите протестировать UserService без реальной базы данных? Или заменить Logger на другую реализацию? В лучшем случае — вы перепишете весь класс, в худшем — сломаете всё приложение.

Решение этой проблемы — Dependency Injection (DI), или внедрение зависимостей. Это один из ключевых принципов проектирования, который лежит в основе концепции Inversion of Control (IoC). В этой статье мы разберём, что такое DI, как его реализовать в чистом JavaScript (Node.js) без использования фреймворков, и почему это критически важно для создания масштабируемых и тестируемых приложений.

Что такое Dependency Injection простыми словами?

Представьте, что вы строите дом. Вместо того чтобы вмуровывать розетки в стены навсегда, вы устанавливаете стандартные разъёмы. Сегодня вы подключаете к ним старую лампу, завтра — умный светильник. Вы не меняете стены — вы меняете зависимости.

В программировании Dependency Injection — это паттерн, при котором объект получает свои зависимости (другие объекты, сервисы, конфигурации) извне, а не создаёт их сам. Вместо того чтобы писать:

// ❌ Плохо: жёсткая связь
class UserService {
    constructor() {
        this.db = new Database('mongodb://...');
        this.logger = new ConsoleLogger();
    }
}

Мы пишем:

// ✅ Хорошо: внедрение зависимостей
class UserService {
    constructor(database, logger) {
        this.db = database;
        this.logger = logger;
    }
}

Теперь UserService не знает, как создаются Database и Logger. Его задача — только использовать их. Это и есть инверсия управления: контроль над созданием зависимостей переходит от класса к вызывающему коду.

Типы внедрения зависимостей

Существует три основных способа внедрения:

  • Через конструктор (Constructor Injection) — самый распространённый и предпочтительный способ. Зависимости передаются при создании объекта.
  • Через свойства (Property Injection) — зависимости устанавливаются после создания объекта. Удобно для опциональных зависимостей.
  • Через методы (Method Injection) — зависимость передаётся в конкретный метод, когда она нужна только для одного действия.

Пишем простой DI-контейнер на Node.js

В больших проектах управлять зависимостями вручную становится сложно. На помощь приходит DI-контейнер — объект, который знает, как создавать и хранить все зависимости вашего приложения. Давайте напишем простой, но рабочий контейнер на чистом JavaScript.

Наш контейнер будет поддерживать два режима: singleton (один экземпляр на всё приложение) и transient (новый экземпляр при каждом запросе).

// di-container.js
class DIContainer {
    constructor() {
        this.registry = new Map();
        this.instances = new Map();
    }

    // Регистрируем зависимость с фабричной функцией
    register(name, factory, { singleton = true } = {}) {
        this.registry.set(name, { factory, singleton });
        if (singleton) {
            this.instances.delete(name); // Сбрасываем кеш при перерегистрации
        }
    }

    // Получаем экземпляр зависимости
    resolve(name) {
        const item = this.registry.get(name);
        if (!item) {
            throw new Error(`Зависимость "${name}" не найдена`);
        }

        // Для singleton возвращаем закешированный экземпляр
        if (item.singleton) {
            if (!this.instances.has(name)) {
                this.instances.set(name, item.factory(this));
            }
            return this.instances.get(name);
        }

        // Для transient создаём новый каждый раз
        return item.factory(this);
    }

    // Очистка контейнера (полезно для тестов)
    clear() {
        this.instances.clear();
    }
}

module.exports = DIContainer;

Теперь давайте используем его на практике. Создадим простой сервис для работы с пользователями.

// app.js
const DIContainer = require('./di-container');

// Создаём контейнер
const container = new DIContainer();

// Регистрируем зависимости
container.register('config', () => ({
    dbUrl: 'mongodb://localhost:27017/mydb',
    logLevel: 'info'
}), { singleton: true });

container.register('logger', (c) => {
    const config = c.resolve('config');
    return {
        log: (message) => console.log(`[${config.logLevel}] ${message}`)
    };
}, { singleton: true });

container.register('database', (c) => {
    const config = c.resolve('config');
    const logger = c.resolve('logger');
    logger.log(`Подключение к БД: ${config.dbUrl}`);
    return {
        query: (sql) => {
            logger.log(`Выполнение запроса: ${sql}`);
            return [{ id: 1, name: 'Иван' }];
        }
    };
}, { singleton: true });

container.register('userService', (c) => {
    const db = c.resolve('database');
    const logger = c.resolve('logger');
    return {
        getUsers: () => {
            logger.log('Запрос списка пользователей');
            return db.query('SELECT * FROM users');
        }
    };
}, { singleton: false }); // Каждый раз новый экземпляр

// Используем
const userService1 = container.resolve('userService');
const users = userService1.getUsers();
console.log('Пользователи:', users);

// Проверка singleton
const logger1 = container.resolve('logger');
const logger2 = container.resolve('logger');
console.log('Один и тот же logger?', logger1 === logger2); // true

// Проверка transient
const service2 = container.resolve('userService');
console.log('Разные userService?', userService1 === service2); // false

Преимущества DI и реальные кейсы использования

1. Тестирование (Mock-объекты)

Главное преимущество DI — возможность легко подменять зависимости во время тестирования. Вместо реальной базы данных вы передаёте mock или stub.

// userService.test.js
const UserService = require('./userService');

// Создаём моки
const mockDb = {
    query: () => Promise.resolve([{ id: 1, name: 'Test' }])
};                

Блоги

Рекомендаций по книгам