company_banner

Возможности Angular DI, о которых почти ничего не сказано в документации

    Angular — это достаточно большой фреймворк. Задокументировать и написать примеры использования для каждого кейса просто невозможно. И механизм внедрения зависимостей не исключение. В этой статье я расскажу о возможностях Angular DI, о которых вы почти ничего не найдете в официальной документации.

    Что вы знаете о функции inject?

    Документация говорит нам следующее:

    Injects a token from the currently active injector. Must be used in the context of a factory function such as one defined for an InjectionToken. Throws an error if not called from such a context.

    И дальше мы видим использование функции inject в примере с tree shakable токеном:

    class MyService {
      constructor(readonly myDep: MyDep) {}
    }
    
    const MY_SERVICE_TOKEN = new InjectionToken<MyService>('Manually constructed MyService', {
      providedIn: 'root',
      factory: () => new MyService(inject(MyDep)),
    });
    

    Это все, что говорит нам документация.

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

    1. В фабриках tree shakable провайдеров.

    2. В фабриках провайдеров.

    3. В конструкторах сервисов.

    4. В конструкторах модулей.

    import { Injectable, inject } from "@angular/core";
    import { HelloService } from "./hello.service";
    
    @Injectable({ providedIn: "root" })
    export class AppService {
      private helloService = inject(HelloService);
    
      constructor(){
        this.helloService.say("Meow");
      }
    }
    

    Так как фабричная функция InjectionToken не может иметь аргументы, inject — единственный способ получить данные из инжектора. Но зачем нам эта функция в сервисах, если можно просто указать необходимые зависимости прямо в параметрах конструктора?

    Рассмотрим небольшой пример.

    Допустим, мы имеем абстрактный класс Storage, который зависит от класса Logger:

    @Injectable()
    abstract class Storage {
      constructor(private logger: Logger) { }
    }

    Любой другой класс, реализующий класс Storage, должен предоставить ему его зависимость в ручном режиме. Если зависимостей много, это может оказаться очень неприятным делом:

    @Injectable()
    class LocalStorage extends Storage {
      constructor(logger: Logger,
                  private selfDependency: SelfDepService){
        super(logger);
      }
    }

    Есть два выхода из ситуации — передавать в родительский класс инжектор, из которого будут извлекаться все необходимые зависимости, либо просто использовать функцию inject! Так мы избавим дочерние классы от проксирования лишних зависимостей:

    @Injectable()
    abstract class Storage {
      private logger = inject(Logger);
    }
    
    @Injectable()
    class LocalStorage extends Storage {
      constructor(private selfDependency: SelfDepService){
        super();
      }
    }
    

    Профит!

    Ручная установка контекста для функции inject

    Давайте обратимся к исходному коду. Нас интересует приватная переменная _currentInjector, функция setCurrentInjector и сама функция inject.

    Если внимательно посмотреть, то работа функции inject становится совершенно очевидной:

    • вызов функции setCurrentInjector присваивает в приватную переменную _currentInjector переданный инжектор, возвращая предыдущий;

    • функция inject достает из _currentInjector значение по переданному токену.

    Это настолько просто, что мы совершенно спокойно можем заставить работать функцию inject даже в компонентах и директивах:

    import { Component, Injector, Injectable, Directive, INJECTOR, Inject } from "@angular/core";
    import {
      inject,
      ɵsetCurrentInjector as setCurrentInjector
    } from "@angular/core";
    import { HelloService } from "./hello.service";
    
    @Component({
      selector: "my-app",
      template: ''
    })
    export class AppComponent {
      constructor(injector: Injector) {
        try {
          const former = setCurrentInjector(injector);
    
          const service = inject(HelloService);
    
          setCurrentInjector(former);
          service.say("AppComponent");
        } catch (e) {
          console.error("Error from AppComponent: ", e);
        }
      }
    }
    

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

    Injection flags

    InjectFlags — это аналог модификаторов Optional, Self, SkipSelf и Host. Используются в функциях inject и Injector.get. Документация и здесь не подвела — ее почти нет:

    enum InjectFlags {
      Default = 0,
      Host = 1,
      Self = 2,
      SkipSelf = 4,
      Optional = 8
    }
    

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

    enum InjectFlags {
      Default = 0b0000,
      Host = 0b0001,
      Self = 0b0010,
      SkipSelf = 0b0100,
      Optional = 0b1000
    }
    

    Использование одного флага

    Вот так можно получить поток событий роутера, не беспокоясь о том, что модуль роутинга не подключен:

    export const ROUTER_EVENTS = new InjectionToken('Router events', {
      providedIn: "root",
      factory() {
        const router = inject(Router, InjectFlags.Optional);
    
        return router?.events ?? EMPTY;
      }
    });

    Выглядит просто. А на деле — еще и безопасно, без неожиданных падений и лишних событий.

    Комбинация флагов

    Комбинацию флагов можно использовать при проверке, что модуль импортировался один раз. А комбинируются они при помощи побитового ИЛИ:

    @NgModule()
    class SomeModule {
      constructor(){
        const parent = inject(SomeModule, InjectFlags.Optional | InjectFlags.SkipSelf);
    
        if (parent) {
          throw new Error('SomeModule is already exist!');
        }
      }
    }
    

    Значение нужного бита получается с помощью побитового И:

    const flags = InjectFlags.Optional | InjectFlags.SkipSelf;
    const isOptional = !!(flags & InjectFlags.Optional);

    Tree shakable сервисы и *SansProviders

    *SansProviders — сокращение для базовых интерфейсов обычных провайдеров ValueSansProvider, ExistingSansProvider, StaticClassSansProvider, ConstructorSansProvider, FactorySansProvider, ClassSansProvider.

    Tree shakable сервисы — это специальный способ сказать компилятору, что сервис не нужно включать в сборку, если он нигде не используется. При этом сервис не указывается в модуле, скорее наоборот — модуль указывается в сервисе:

    import {Injectable} from '@angular/core';
    
    @Injectable({providedIn: SomeModule})
    export class SomeService {
    
    }
    

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

    @NgModule({
      providers: [SomeService]
    })
    export class SomeModule {
    
    }

    providedIn также может содержать специальные значения: 'root', 'platform' и 'any'. Это достаточно хорошо описано в документации, но там вообще ничего не сказано о возможности использования фабрик (я нашел небольшое упоминание в одном из гайдов).

    Но если мы посетим исходники, то увидим, что мы можем использовать не только фабрики, но и все существующие способы провайдинга — useFactory, useValue, useExisting и т. д.

    Самый полезный, по моему мнению, способ использования фабрики в tree shakable сервисах выглядит так:

    import { Injectable, Optional } from "@angular/core";
    import { SharedModule } from "./shared.module";
    
    @Injectable({
      providedIn: SharedModule,
      useFactory: (instance: SingletonService) => instance ?? new SingletonService(),
      deps: [[new Optional(), SingletonService]]
    })
    export class SingletonService {
      constructor() {
        console.count("SingletonService constructed");
      }
    }
    

    Плюсы такого определения сервиса:

    1. Исключено случайное использование сервиса. Для работы с ним необходимо импортировать модуль сервиса.

    2. Самому модулю не нужно выделять статические методы forRoot и forChild.

    3. Гарантировано создание одного экземпляра сервиса.

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

    Например:

    @Injectable({
      providedIn: 'root',
      useValue: jQuery
    })
    abstract class JqueryInstance {
    
    }
    

    В этом случае по токену JqueryInstance мы будем получать инстанс jQuery.

    Для остальных типов провайдеров я предлагаю придумать use-кейсы вам самим. Буду рад, если вы поделитесь ими в комментариях.

    Взаимодействие компонент

    В документации перечислены все существующие способы взаимодействия компонент и директив между собой:

    1. Input binding with a setter, ngOnChanges.

    2. Child events (outputs).

    3. Template variables.

    4. View/parent queries.

    5. Общий сервис.

    Но ни слова не сказано о том, что дочерняя директива/компонент совершенно спокойно может получить инстанс родителя через DI.

    UPD: нашлась все таки ссылочка на документацию, где описывается этот способ.

    import { Directive, HostListener } from "@angular/core";
    import { CountComponent } from "./count.component";
    
    @Directive({
      selector: "[increment]"
    })
    export class IncrementDirective {
      constructor(private countComponent: CountComponent) {}
    
      @HostListener("click")
      increment() {
        this.countComponent.count += 1;
      }
    }
    

    На примере видно, что дочерняя директива получает инстанс компонента родителя, упрощая их взаимодействие. Обрабатывая клик по хостовому элементу, директива может напрямую поменять значение родительского компонента. Использование любого другого способа было бы как минимум более многословным.

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

    Вместо вывода

    Как я уже говорил, Angular — очень большой фреймворк. И я уверен, что в его исходниках можно найти намного больше интересных возможностей.

    Своими находками я всегда делюсь в своем Твиттере. Например, советы по Angular вы можете найти по хэштегу #AngularTip. А перевод самых интересных твитов — по хэштегу #AngularTipRus! Буду рад, если вы поделитесь своими наблюдениями и советами со мной и сообществом. Спасибо за внимание!

    Список хороших статей об Angular DI

    Я вспомнил еще 2 статьи об Angular DI от @MarsiBarsi на русском языке. Пишите где угодно, предлагайте и давайте его пополнять!

    Tinkoff
    it’s Tinkoff — просто о сложном

    Комментарии 9

      +2
      Афигенная статья! Автору, Спасибо!
      P.S. странно но про инжектирование компонентов я знал =)
        +1
        Хорошая статья, сразу увидел пару мест в текущем проекте, где можно оптимизировать ))
        Автору, большое спасибо!
          0
          Делайте пожлауйста больше статей о DI в Ангуляр. Я прям капец не догоняю его смысла. Недавно наткнулся на одно видео, где человек обьянял и показывал на реальном примере патерн Bridge, очень понравилось и сразу понятно
            0
            Поделись пожалуйста ссылкой!
            0

            Спасибо за статью! Очень интересно, особенно применение в абстрактных классах.

              0

              Пример useFactory для сервиса:


              Первоначальный вариант


              import * as jwtDecode from "jwt-decode";
              
              @Injectable({ providedIn: "root" })
              export class SessionService {
                public getUserId(): string {
                  const token = // get token from cookies
                  const decoded = jwtDecode(token);
                 return decoded.public.user_id;
                }
              }

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


              Используем DI


              export const JwtInjectionToken = new InjectionToken();
              
              @Injectable({ providedIn: "root" })
              export class SessionService {
                constructor(@Inject(JwtInjectionToken) private jwtDecode: typeof jwtDecode) {}
              
                getUserId() {
                  ... то же самое, но вызываем this.jwtDecode(token);
                }
              }

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


              А теперь применим useFactory:


              @Injectable({
                providedIn: "root",
                useFactory: () => new SessionService(jwtDecode)
              })
              export class SessionService {
                ...
              }

              Этим самым мы совместили плюсы обоих подходов: подменить jwt-decode в тесте тривиально, и не надо ничего дополнительно провайдить (как это сказать по-русски?) на уровне приложения — сервис сразу готов к использованию!

                0
                У токена тоже можно объявить фабрику. Довольно удобно использовать для jwt. И ненужно для каждого сервиса отдельную фабрику писать, можно только для токена.
                  0

                  Можно и так. Просто токен — это дополнительная сущность. Если написать useFactory прямо в сервисе, то токен не нужен.


                  С токеном:


                  const Token = new InjectionToken("token", () => jwt)
                  
                  class Service {
                    constructor(@Inject(Token) jwt) {}
                  }

                  Без токена:


                  @Injectable({useFactory: () => new Service(jwt)})
                  class Service {
                    constructor(jwt) {}
                  }
                0
                Самый полезный, по моему мнению, способ использования фабрики в tree shakable сервисах выглядит так:

                Пример кмк не очень удачный (как и плюсы), если нужен app-wide синглтон, то есть рекомендованный providedIn: 'root'.


                Это работает точно так же, как и следующий пример:@NgModule({providers: [SomeService]})

                Вот только в этом случае tree shakable невозможен (из статьи это немного неочевидно, да и вообще оно deprecated). И работает кстати тоже не совсем так как ожидаешь, при @Injectable({providedIn: SomeModule}) SomeModule не сможет использовать этот сервис внутри себя из-за cyclic dependency))) Приходится создавать отдельный пустой модуль для сервисов и импортировать его в SomeModule. Неудобно :(


                @Injectable({providedIn: SomeModule})

                И тут мы еще получаем одну неочевидную проблему с приоритетом (кто последний тот и папа), что затрудняет переопределение провайдеров определенных в импортируемых модулях (иногда оно нужно).


                Поэтому в 99% providedIn: 'root' предпочтительнее.

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

                Самое читаемое