Скрытый потенциал функции inject в Angular

Привет! В этой заметке покажу, как можно использовать функцию inject на сто процентов.

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

@Injectable()
export class SomeService {
  constructor(protected readonly duckService: DuckService) {}
  // ...
}

И стало:

@Injectable()
export class SomeService {
  protected readonly duckService = inject(DuckService);
  // ...
}

Удобно, конечно. Особенно тем, что от SomeService теперь легче наследоваться: не нужно в дочернем классе перечислять токены, а затем через super передавать их родителю. Кстати, по этой же причине я почти всегда объявляю сервисы через protected, чтобы ими можно было легко пользоваться в дочерних классах.

Но что, если я скажу, что это не всё, на что способна функция inject? Давайте посмотрим на паре примеров, как ещё её можно использовать.

Оборачиваем inject

Вы замечали, что с query-параметрами работать немного заморочено? Ну то есть: нам сначала надо заинжектить ActivatedRoute, чтобы получить данные о каком-то параметре:

private readonly route: ActivatedRoute = inject(ActivatedRoute);

Потом вытащить из него queryParams, а оттуда смапить объект на нужный нам параметр:

private readonly id$: Observable = 
  this.route.queryParams.pipe(map(({ id }) => id))

А если мы захотим параметр обновить, то нам придётся инжектить роутер:

private readonly router: Router = inject(Router);

И вспоминать его синтаксис, а потом не забыть выставить режим merge для query-параметров (иначе все остальные параметры испарятся):

this.router.navigate(
  [], 
  { queryParams: { id }, queryParamsHandling: 'merge' }
)

А если мы захотим считать параметр синхронно, а не через Observable, то придётся отдельно обращаться к снапшоту:

const id: string = this.route.snapshot.queryParams.id;

Если нам нужно будет поменять название параметра, то нам нужно будет пробежаться по трём местам; и не забываем, что queryParams не типизирован, и TS нас тут в случае ошибки не спасёт.

Вам не кажется, что это всё сильно похоже на работу BehaviorSubject? Этот тот самый, на который можно подписаться, через который можно обновить значение и синхронно получить последнее. Что, если весь этот распылённый код собрать воедино, и управлять пресловутым query-параметром через одну точку входа?

Ну как-нибудь вот так:

@Component({ /* ... */ })
export class SomeComponent {
  protected readonly id$: QueryParam = useQueryParam('id');
  public readonly entity$: Observable = this.id$.pipe(
    switchMap(/* ... */)
  );

  public updateForm(): void {
    const id: string = this.id$.getValue();
    // ...
  }

  public changeEntity(id: string): void {
    this.id$.update(id);
  }
}

Здесь нет ни Router, ни ActivatedRoute. Ведь всё, что нам нужно — это query-параметр, и у нас теперь есть объект, через который мы можем управлять этим параметром и получать о нём информацию. Можно ли так сделать? Можно! В этом нам как раз поможет функция inject.

У функции inject есть важный нюанс: ей необязательно пользоваться именно в конструкторе — это можно делать и в отдельной функции, которая к компоненту никак не относится. Главное, чтобы при вызове этой функции мы находились в injection-контексте, в противном случае Angular выкинет ошибку.

Мы находимся в injection-контексте на протяжении работы конструктора injectable-класса (и ещё в некоторых случаях, но они нам пока не интересны). Если во время работы конструктора мы вызовем функцию useQueryParam (как в примере выше), то она тоже будет находиться в контексте, а значит пользоваться функцией inject можно и там.

Таки давайте напишем функцию, которая будет создавать этот магический объект QueryParam. Для начала создадим сам класс, который унаследует Observable. Мы переносим в этот класс всю логику работы с роутером и активным роутом, поэтому передадим их через конструктор. Также нам понадобится ключ query-параметра, с которым мы будем работать — это строка, тоже передадим её через конструктор.

export class QueryParam extends Observable {
  constructor(
    private readonly paramKey: string,
    private readonly activatedRoute: ActivatedRoute,
    private readonly router: Router
  ) {}
}

Итак, этот класс в первую очередь Observable,  а значит, при подписке мы должны получать значения этого query-параметра. Свяжем наш класс со значением параметра вот таким образом:

export class QueryParam extends Observable {
  // заведём поток для получения значений из query-параметра
  private readonly paramValue$ = this.activatedRoute.queryParams.pipe(
    distinctUntilKeyChanged(this.paramKey), // отсеиваем одинаковые значения
    map(() => this.getValue()), // достаём из словаря с параметрами нужный
    share({
      connector: () => new BehaviorSubject(this.getValue()),
      resetOnRefCountZero: true,
    }) // зашейрим это значение, 
    // чтобы новый подписчик не инициировал каждый раз вызов getValue()
  );

  constructor(
    private readonly paramKey,
    private readonly activatedRoute: ActivatedRoute,
    private readonly router: Router
  ) {
    super((subscriber) => {
      // каждый новый подписчик будет проксироваться в наш paramValue$
      const subscription = this.paramValue$.subscribe(subscriber);

      // при отписке от нашего Observable разрываем прокси
      return () => subscription.unsubscribe(); 
    });
  }

  public getValue(): T {
    return this.activatedRoute.snapshot.queryParams[this.paramKey];
  }
}

Теперь у нас есть возможность получать значение по подписке и синхронно через функцию getValue. Осталось сделать возможность обновить значение. Добавим функцию update, которая будет пользоваться полем router и обновлять значение прямо в адресной строке.

export class QueryParam extends Observable {
  private readonly paramValue$; // = ...

  costructor(/* ... */) {
    // ...
  };

  public getValue(): T {
	  // ...
  }
  
  public update(value: T): Promise {
    return this.router.navigate([], {
      queryParams: { [this.paramKey]: value },
      queryParamsHandling: 'merge',
    });
  }
}

И теперь самое главное: напишем функцию-фабрику, которая будет генерировать нам такой объект:

export function useQueryParam(paramKey: string): QueryParam {
  return new QueryParam(paramKey, inject(ActivatedRoute), inject(Router));
}

Обращаем внимание, что в неё требуется передать только ключ параметра, остальное (роутер и роут) функция добудет сама через функцию inject. Чтобы посмотреть, как этим пользоваться, ещё раз взглянем на продемострированный выше пример:

@Component({ /* ... */ })
export class SomeComponent {
	protected readonly id$: QueryParam = useQueryParam('id');
	//                                                        ~~~~~~
	// нам достаточно передать только ключ параметра, и уже можно пользоваться
	
	public readonly entity$: Observable = this.id$.pipe(
	  switchMap(/* ... */)
	);

	public updateForm(): void {
	  const id: string = this.id$.getValue();
	  // ...
	}
	
	public changeEntity(id: string): void {
	  this.id$.update(id);
	}
}

Очевидно, что в такой же манере можно организовать работу и с path-параметрами. Здесь можно поиграться в StackBlitz.

Заменяем stateless-сервисы

Давайте разберём ещё один пример, где функция inject поможет избавиться от одного неудобства. На этот раз будем говорить про модальные окна. Откроем раздел Overview в документации по MatDialog и немного отредактируем его первый пример.

Там ничего особенного: просто кнопка, которая открывает модальное окно и передаёт туда информацию. Модальное окно, как мы знаем, открывается с помощью сервиса MatDialog, который всегда запровайжен в root.

А теперь давайте создадим некоторый сервис, который нужно запровайдить в компонент с кнопкой. Например такой:

@Injectable()
export class SomeService {
  a = 'hello';
}

Провайдим в компонент:

@Component({
	// ...
  providers: [SomeService] // <-- вот так
})
export class DialogOverviewExample {
  // ...
}

Это значит, что на каждый экземпляр компонента будет создаваться свой экземпляр SomeService. Проверим, что им можно пользоваться:

export class DialogOverviewExample {
  // ...
  constructor(public dialog: MatDialog, public someService: SomeService) {
	  console.log(this.someService.a);
  }
  // ...
}

Работает. Ну впрочем, мы ничего сверъестественного и не сделали.

a5f74b11200aa4a1542681c5cd24fb31.png

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

export class DialogOverviewExampleDialog {
  constructor(
    public dialogRef: MatDialogRef,
    @Inject(MAT_DIALOG_DATA) public data: DialogData,
    public someService: SomeService // <-- инжектим
  ) {
    console.log(this.someService.a); // <-- пользуемся
  }
}

Если вы хорошо знаете, как работает дерево DI, то уже поняли, какая ошибка будет выведена при открытии модального окна. Удостоверимся: нажмём на кнопку. Получим классический NullInjectorError. Модалка, понятное дело, не откроется. У некоторых вкатывающихся в Angular должны начаться флэшбэки.

Почему так? Почему в компоненте DialogOverviewExample мы можем пользоваться этим сервисом, а в компоненте DialogOverviewExampleDialog уже нет? Мы ведь его запровайдили в компоненте, и из него же вызвали модальное окно.

Давайте нарисуем дерево провайдеров.

Снимок экрана 2024-05-05 в 18.09.03.png

Снимок экрана 2024–05–05 в 18.09.03.png

Вот так оно выглядит до открытия модального окна: MatDialog, как и положено, находится в инжекторе root. От него наследуется инжектор под названием DialogOverviewExample (наследование показано стрелочкой). Внутри него есть сам компонент DialogOverviewExample и тот самый несчастный SomeService.

Что произойдёт, когда мы откроем модальное окно? Смотрим:

Снимок экрана 2024-05-05 в 18.12.42.png

Снимок экрана 2024–05–05 в 18.12.42.png

У нас добавился новый инжектор под названием DialogOverviewExampleDialog. Он уже относится к компоненту модалки. Стрелкой снова показано наследование, а пунктирной линией я показал, что этот инжектор был создан именно сервисом MatDialog.

Как видим, действительно, сервису SomeService в этой модалке взяться просто неоткуда: у него в арсенале есть собственный инжектор и родительский, и нигде нужного сервиса нет.

Что делать? Есть несколько вариантов.

Вариант 1

Перенести SomeService в root. Решило бы проблему, но нельзя, потому что нам нужен не синглтон, а свой экземпляр на каждый экземпляр компонента. Ну вот по бизнесу так надо. Не подходит.

Вариант 2

Перенести MatDialog внутрь инжектора DialogOverviewExample. Тогда модальное окно унаследуется уже от него, и сервис будет доступен. Создать лишний экземпляр сервиса мы можем, так как мы там не храним никаких данных, нам от него нужна лишь функция.

Снимок экрана 2024-05-05 в 20.00.30.png

Снимок экрана 2024–05–05 в 20.00.30.png

Это уже можно, и это решит проблему. Указываем MatDialog в списке провайдеров:

@Component({
  // ...
  providers: [SomeService, MatDialog] // <-- добавляем сюда
})
export class DialogOverviewExample { /* ... */ }

Открываем модалку и получаем… точно такой же NullInjectorError. У тех, кто обновлялся до 15 версии теперь тоже должны начаться флэшбэки. Потому что начиная с 15 версии MatDialog под капотом использует сервис Dialog, который также по умолчанию провайдится в root. А его-то мы не поднимали вверх по дереву, он там в корне висеть и остался. Добавим, мы не гордые:

@Component({
  // ...
  providers: [SomeService, MatDialog, Dialog] // <-- добавляем сюда
})
export class DialogOverviewExample {
  // ...
}

Открываем модалку и видим, что всё работает как положено.

Снимок экрана 2024-05-05 в 18.36.44.png

Снимок экрана 2024–05–05 в 18.36.44.png

А что, если мы всё-таки гордые? Согласитесь, неприятно, когда обновляешь Angular до 15 версии, а у тебя перестали работать некоторые модалки, потому что теперь нужно поднимать на уровень компонента не один, а два провайдера. А если завтра их будет три? Нестабильно.

Вариант 3. Как правильно

Убираем MatDialog и Dialog из списка провайдеров, пользуемся теми, что лежат в root. Внутри корневого компонента инжектим Injector и передаём его при создании модалки:

@Component({
  // ...
  providers: [SomeService] // <-- ничего лишнего
})
export class DialogOverviewExample {
  constructor(
    public dialog: MatDialog, 
    public someService: SomeService, 
    public injector: Injector // <-- ижективно инжектим инжектор через инжекшен
  ) {
    console.log(someService.a);
  }

  openDialog(): void {
    const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
      data: { name: this.name, animal: this.animal }, 
      injector: this.injector // <-- и передаём его сюда
    });
	  // ...
  }
}

Вариант на все времена, работает как часы. Но чувствуете ли вы удовлетворение? Я нет. Мало того, что об этом не написано в документации, так я ещё должен вручную дополнительно доставать Injector и передавать его в специальный параметр. Это вообще не моя работа во-первых, во вторых у меня в классе теперь висит одно мутное лишнее поле, которого могло и не быть. Так сразу и говорите: чтобы пользоваться модалками, вам нужно всегда нужно доставать два провайдера.

Вариант 4. Как можно

Когда я рассказывал про второй вариант, где мы переносили провайдер на уровень компонента, я выделил там небольшой кусок предложения:

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

И вправду, перед нами пример stateless-сервиса — MatDialog. Да, я знаю, что он не совсем stateless, но по крайней мере, его можно спокойно поделить на две части — stateful и stateless. Первая часть будет хранить список открытых модалок и так далее, а вторая открывать модалки и передавать их в список первой. Первая часть нас вообще редко интересует, нам бы просто модалку открыть. Давайте напишем обёртку для MatDialog, которая сама разодобудет тот самый Injector из предыдущего решения, и сама его передаст при открытии модалки.

Напишем снова функцию с использованием inject:

export function useMatDialog() {
  const injector = inject(Injector);
  const matDialog = inject(MatDialog);

  return {
    // сигнатуру я скопировал из деклараций MatDialog
    open(template: ComponentType | TemplateRef, config?: MatDialogConfig): MatDialogRef {
      const newConfig: MatDialogConfig = { injector, ...config };
      return matDialog.open(template, newConfig)
    }
  }
}

Здесь мы получаем всю необходимую для нормальной работы информацию: Injector и MatDialog. Возвращаем объект с единственной функцией, которая проксирует метод open в сервис, перед этим подставляя в него параметр injector, если другой не указали извне.

Как пользоваться:

@Component({
  // ...
  providers: [SomeService], // <-- здесь только SomeService
})
export class DialogOverviewExample {
  dialog = useMatDialog(); // <-- получаем обёртку MatDialog
  
  // ...

  openDialog(): void {
    const dialogRef = this.dialog.open(DialogOverviewExampleDialog, {
      // в параметры передаём только бизнесовую информацию:
      data: { name: this.name, animal: this.animal } 
    });
    // ...
  }
}

Вот теперь я удовлетворён: здесь нет ничего лишнего, и следить ни за чем не нужно, всё самое страшное инкапсулировано в useMatDialog. Когда-то давно это сохранило бы мне 10 часов разбирательств, почему не открывается модалка, и я мог бы потратить их на что-то более полезное, например резать воду.

В общем, так можно поступать с любым stateless-сервисом, и забыть хотя бы о проблеме получения актуальных провайдеров. Ссылка на StackBlitz.

У меня всё. Я ещё иногда пишу в телеграм-канал.

P.S. У. меня стойкое желание назвать эти функции хуками. Особенно из-за того, что я использую слово «use» как префикс, и это перекликается с React. Поэтому объявляю интерактив!

© Habrahabr.ru