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

в 10:31, , рубрики: angular, dependency, di, javascript, tips, TypeScript, Блог компании Tinkoff, Разработка веб-сайтов
Возможности Angular DI, о которых почти ничего не сказано в документации - 1

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 тоже не входит в лучшие практики. Поэтому не советую заниматься подобными вещами.

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

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 на русском языке. Пишите где угодно, предлагайте и давайте его пополнять!

Автор: Игорь Кацуба

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js