Привет. Меня зовут Дмитрий, я фронтенд-разработчик в компании «Цифровая индустриальная платформа». В своей работе мне часто приходится использовать Dependency Injection (DI) в Angular. Это мощный и популярный инструмент, который упрощает работу с зависимостями в наших приложениях. Он позволяет легко интегрировать необходимые сущности в компоненты, упрощает процесс тестирования и поддерживает принцип инверсии зависимостей. Однако часто мы не используем все возможности DI, потому что не знаем, как он работает под капотом. Давайте разберемся, как функционирует DI, что такое иерархия инжекторов и какие изменения принесла версия Angular 14.

Injector 101. Как работает инжектор

Чтобы составить общую картину работы DI в Angular, важно понять, как инжекторы находят и создают сервисы. Вот упрощенный алгоритм:

  • Angular берет текущий Injector.

  • Вызывает у него метод Injector.get(token), передавая токен сервиса.

  • Если Injector находит у себя этот токен, он либо создает и возвращает новый экземпляр сервиса, либо возвращает уже ранее созданный экземпляр.

Рассмотрим примерную реализацию инжектора:

type ProviderToken = string; // уникальный идентификатор

abstract class Injector {
  abstract get(token: ProviderToken): any; // функция для получения сервиса
}

type Record = {
  factory: () => any; // функция для создания сервиса
  value: any; // созданный сервис
}

export class ModuleInjector extends Injector {
  private records: Map<ProviderToken, Record>; // отвечает за хранение сервисов
  
  constructor(providers: Array<[ProviderToken, Record]>) {
      this.records = new Map(providers);
  }
 
  get(token: ProviderToken): any {
      if (!this.records.has(token)) { // если токена нет, то кидаем ошибку
          throw new Error(`Could not find the ${token}`);
      }
      
      const record = this.records.get(token);
      
      if (!record.value) { // если сервис не создан, то создаем его
          record.value = record.factory();
      }
      
      return record.value;
  }
}

Angular при создании инжектора собирает сервисы из providers в модулях и компонентах, а также классов, помеченных декоратором @Injectable({...}). Затем инжектор создает из них мапу, которая позволяет получать экземпляр сервиса по запросу. Вот так это упрощенно работает в Angular под капотом:

// собираем провайдеры и создаем инжектор
const injector = new Injector([
    ['SomeService', () => new SomeService()], 
    ['AnotherService', () => new AnotherService()]
    ...
]);

// когда создается компонент с этим сервисом, вызывается injector.get 
injector.get('SomeService') // => SomeService instance 

Однако в Angular существует не один инжектор, а целая иерархия инжекторов. При создании приложения формируется дерево инжекторов, которое может расширяться. В случае, если токен не найден в текущем инжекторе, Angular пытается найти его в инжекторе-родителе. Давайте добавим эту логику в наш инжектор:

export class ModuleInjector extends Injector {
  private records: Map<ProviderToken, Record>;
  private parent: Injector;
  
  constructor(providers: Array<[ProviderToken, Record]>, parentInjector: Injector) {
      this.records = new Map(providers);
      this.parent = parentInjector; // сохраняем инжектор-родитель
  }
 
  get(token: ProviderToken): any {
      if (!this.records.has(token)) {
          return this.parent.get(token); // пытаемся найти в родителе
      }
      ...
  }
}

При создании инжектора Angular передает не только массив токенов и записей, но еще и родительский инжектор. Что же будет, если токен не будет найден ни в одном из инжекторов? Для такого случая в Angular существует специальный NullInjector.

export class NullInjector implements Injector {
  get(token: ProviderToken): any {
    const error = new Error(`NullInjectorError: No provider for ${stringify(token)}!`);
    error.name = 'NullInjectorError';
    throw error;
  }
}

Angular передает NullInjector как родителя для первого созданного инжектора. Если токен не найден в цепочке инжекторов, то NullInjector выбрасывает всем знакомую ошибку “NullInjectorError: No provider for MyService!”.

Теперь, с пониманием этой концепции, давайте рассмотрим, какие инжекторы существуют в Angular и как они связаны между собой. Важно отметить, что сначала мы будем рассматривать, как работала иерархия инжекторов до 14-й версии Angular, а далее посмотрим, как она изменилась.

Иерархия инжекторов

Default Injectors

При запуске приложения создаются три инжектора:

  • NULL injector – выбрасывает ошибку, если токен не найден, за исключением случая с @Optional.

  • Platform injector – отвечает за сервисы, которые могут использоваться несколькими приложениями в рамках одного проекта Angular.

  • Root injector – хранит дефолтные сервисы Angular и сервисы, которые помечены декоратором @Injectable({providedIn: ‘root’}) или указаны в свойстве providers в метаданных AppModule.

При инжекте сервиса, Angular сначала ищет его в рутовом инжекторе, затем в платформенном. Если сервис не найден, NULL инжектор бросает ошибку. Схематично это можно представить так

Интересно, что если модуль с providers импортируется в AppModule, он не создает отдельный инжектор, а его провайдеры попадают в рутовый инжектор:

@NgModule({
    providers: [AnotherService]
})
export class AnotherModule {}

@NgModule({
    imports: [AnotherModule],
    providers: [Service]
})
// root инжектор будет хранить два сервиса: [Service, AnotherService]
export class AppModule {}

Эта ситуация была для меня неочевидной при знакомстве с Angular. Я неправильно считал, что каждый модуль создает свой инжектор.

Lazy-loading Module Injector

Помимо трех основных инжекторов, Angular создает свой инжектор для каждого lazy-loading модуля.

@NgModule({
    providers: [MyService]
})
export class LazyModule {
}

export const ROUTES: Route[] = [
  {
      path: 'lazy',
      loadChildren: () => import('./lazy-module')
                    .then(mod => mod.LazyModule)
  },
]

Если указать сервис в провайдерах lazy-loading модуля, то его инжектор создаст свой экземпляр сервиса для компонентов этого модуля. Остальные части приложения ничего не будут знать про этот сервис.

Если заинжектить сервис в компонент, который принадлежит lazy-loading модулю, то поиск сервиса будет начинаться с инжектора этого модуля, а далее перейдет к цепочке Root InjectorPlatform InjectorNull Injector:

Node Injector

Кроме модульных инжекторов в Angular существуют node (element) инжекторы.

  • Рутовый компонент всегда создает node инжектор для себя.

  • Node инжекторы создаются для каждого тега, соответствующего Angular компоненту, или для любого тега, на который применена директива.

Для добавления сервиса в node инжектор, его нужно добавить в providers или viewProviders метадаты компонента:

@Component({
  selector: 'app-child',
  template: '',
  providers: [Service], // provide to NodeInjector
  // or
  viewProviders: [Service]
})
export class ChildComponent {
    constructor(
        private service: Service  // injected from NodeInjector
    ) {
    }
}

Отличие node инжектора от модульного инжектора в том, что он создается и умирает вместе с компонентом, следовательно сервисы в node инжекторе создаются и умирают вместе с ним.

У node инжекторов есть своя иерархия. Цепочка поиска сервиса начинается с текущего node инжектора и идет к родительскому компоненту, пока не дойдет до рутового node инжектора. Рассмотрим такое дерево компонентов:

<app-root>
    <app-parent>
        <app-child></app-child>
        <app-child></app-child>
    </app-parent>
</app-root>

Если сервис инжектится в app-child компонент, то цепочка поиска будет такой: app-childapp-parentapp-root:

Полная картина

@Component({...})
export class Component {
    // как это работает?
    constructor(private readonly service: Service) {
    }
}

Итак, когда сервис инжектится в компонент, Angular сначала пытается найти его в текущем node инжекторе, затем идет вверх по иерархии node инжекторов, потом переходит к модульным инжекторам, начиная с lazy-loading модулей (если они есть), далее к рутовому инжектору, затем к платформенному инжектору, и, если ничего не найдено, NULL инжектор выбрасывает ошибку:

Я подготовил пример на StackBlitz, где можно подробно рассмотреть, как это работает. Рекомендую установить расширение Angular DevTools в браузере, чтобы можно было изучить дерево инжекторов в инструментах разработчика (Ctrl+Shift+IAngularInjector Tree).

Пример дерева инжекторов в Angular DevTools
Пример дерева инжекторов в Angular DevTools

Также хочу посоветовать интересную шпаргалку от Криса Кохлера (Chris Kohler), к которой всегда можно обращаться с вопросами по DI.

Standalone революция

Environment Injector

С приходом standalone компонентов в Angular 14-й версии ситуация в иерархии инжекторов изменилась. Вместо модульного инжектора теперь используется EnvironmentInjector. Так как в standalone приложении нет модулей, то Angular команда решила использовать более согласованное название, но на принцип работы это не повлияло. Иерархия инжекторов все та же:  Root Environment InjectorPlatform Environment InjectorNull Injector .

Чтобы запровайдить сервис в рутовый инжектор, теперь можно использовать следующие варианты:

// №1 - старый добрый декоратор Injectable
@Injectable({
    providedIn: 'root'
})
export class SomeService {}

// №2 - передать сервис в конфиг бутстрапа приложения
export const appConfig: ApplicationConfig = {
  providers: [SomeService],
};

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);

Route Environment Injector

В Angular 14 появилась функция loadComponent для lazy-loading компонентов, которая похожа на loadChildren. Кажется, что можно было бы использовать loadComponent для создания отдельного инжектора, как это делается с lazy-loading модулем и его инжектором. Однако это предположение ошибочно. В действительности loadComponent не создает новый инжектор.

export const ROUTES: Route[] = [
  {
      path: 'lazy',
      // не создаю новый инжектор :(
      loadComponent: () => import('./lazy.component').then(c => c.LazyComponent)
  },
]

Если необходимо создать инжектор для конкретного маршрута, аналогично LazyLoadingModule Injector можно использовать свойство providers в конфигурации маршрута, что создаст отдельный инжектор. Это будет работать и для обычных маршрутов, и для lazy-loading маршрутов.

export const ROUTES: Route[] = [
    {
        path: 'route-with-providers',
        component: ChildComponent,
        providers: [SomeService], // тут создается новый инжектор
        children: [...]
    },
]

Данный инжектор будет применяться для маршрута route-with-providers и всех его детей:

Пример на StackBlitz

Обратная совместимость с NgModule

Команда Angular осознает, что переход на standalone компоненты будет постепенным, поэтому они добавили совместимость с подходом NgModule. В standalone компоненты можно импортировать модули наряду с компонентами:

@Component({
  standalone: true,
  selector: 'app-child',
  imports: [SomeModule], // импорт модуля
  template: ``,
})
export class ChildComponent {
}

Это облегчает миграцию на новые типы компонентов, но также приводит к некоторым трудностям. Проблема возникает, когда у standalone компонентов есть модули, которые содержат сервисы. Angular рассматривает standalone компоненты как независимые строительные блоки приложения. Было бы странно, если бы эти компоненты добавляли свои сервисы в рутовый инжектор.

Чтобы решить эту проблему, Angular теперь создает для standalone компонентов отдельный инжектор-обертку, если эти компоненты импортируют модули, независимо от того, содержат ли эти модули сервисы или нет. Этот инжектор-обертка собирает сервисы из импортированных модулей и включает их в себя.

Инжектор-обертка создается в следующих случаях:

  1. при старте приложения, если в рутовом компоненте используется компонент с модулем;

  2. для компонентов, которые создаются динамически и имеют модули;

  3. при использовании роутинга на компоненты с модулями;

  4. Рассмотрим эти случаи на примерах.

#1. Возьмем AppComponent и добавим ему в импорты компонент с модулем.


@Component({
  standalone: true,
  selector: 'app-root',
  imports: [ChildComponent], // компонент с модулем в импортах
  template: `
    <app-child /><app-child />
  `,
})
export class AppComponent {
}

Иерархия environment инжекторов будет выглядеть таким образом:

#2. Отдельный инжектор будет создаваться при динамическом создании компонентов с модулем.

@Component({
  ...
})
export class AppComponent {
  click(): void {
    // динамическое создание компонента с модулем в импортах
    const compRef = this.viewContainerRef.createComponent(ChildComponent);
    compRef.changeDetectorRef.detectChanges();
  }
}

#3. Если на компонент с модулем будет вести маршрут, то аналогично будет создан отдельный инжектор-обертка.

export const ROUTES: Route[] = [
    {
        path: 'child',
        // компонент с модулем в импортах
        component: ChildComponent,
    },
]

Для наглядности можно рассмотреть StackBlitz пример, где собраны варианты создания инжектора-обертки.

Вместо заключения

Спасибо, что дочитали мою статью до конца. Вместо того, чтобы подводить итоги, я хотел бы узнать ваше мнение. Кто-то считает, что все сервисы можно помещать в рутовый инжектор и не переживать насчет иерархии инжекторов в Angular. Я придерживаюсь иного мнения. А что вы думаете об этом?

И спасибо за внимание!

Комментарии (4)


  1. Cherezzabo
    30.07.2024 14:38
    +3

    Отличная статья с наглядными примерами и схемами! Спасибо за такое!

    Тоже придерживаюсь мнения, что пихать все сервисы в рутовый инжектор - это зло, с которым примеряешься, если не понимаешь, как оно там работает под капотом.

    Применять такой подход в больших, развесистых Angular приложениях - это обрекать себя на сражение с неявными утечками памяти и мучительное развязывание цикличностей, когда есть не один десяток зависимых и со зависимых сервисов. Не все должно быть синглтоном, хотя Ангуляр (даже в части плохо расписанной документации по этой теме) как будто бы специально подталкивает просто все делать синглтоном в рутовом скоупе.

    Я бы хотел спросить, каким еще средствами, методиками, подходами вы пользуетесь, чтобы следить за качеством и, главное, структурой дерева зависимостей внутри большого приложения/библиотеки Angular?


    1. ItNoN Автор
      30.07.2024 14:38
      +2

      Спасибо большое за лестный отзыв о статье. Рад, что вам понравилось.

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

      С 17ой версии можно смотреть дерево инжекторов в Angular DevTools, это очень помогает, крайне советую. Еще можно отметить, что мы следуем заложенной архитектуре, и таким образом внезапных зависимостей не появляется. Как то так.


  1. Fidget
    30.07.2024 14:38
    +2

    Отличная статья. Я бы ещё добавил абзац про tree shaking. Или это тема для отдельной статьи?


    1. ItNoN Автор
      30.07.2024 14:38
      +2

      Спасибо большое. Добавить про tree shaking в целом хорошая идея. Но думаю, что можно оставить на еще одну статью.
      У нас была мысль написать статью про рефлексию, и то как Angular работает с декоратором Injectable. Там, мне кажется, инфу про tree shaking было бы отлично видеть