Скрытый потенциал функции 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<string> =    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<string> = useQueryParam('id');   public readonly entity$: Observable<SomeEntity> = 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<T> extends Observable<T> {   constructor(     private readonly paramKey: string,     private readonly activatedRoute: ActivatedRoute,     private readonly router: Router   ) {} }

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

export class QueryParam<T> extends Observable<T> {   // заведём поток для получения значений из 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<T> extends Observable<T> {   private readonly paramValue$; // = ...    costructor(/* ... */) {     // ...   };    public getValue(): T {   // ...   }      public update(value: T): Promise<boolean> {     return this.router.navigate([], {       queryParams: { [this.paramKey]: value },       queryParamsHandling: 'merge',     });   } }

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

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

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

@Component({ /* ... */ }) export class SomeComponent { protected readonly id$: QueryParam<string> = useQueryParam('id'); //                                                        ~~~~~~ // нам достаточно передать только ключ параметра, и уже можно пользоваться  public readonly entity$: Observable<SomeEntity> = 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);   }   // ... } 

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

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

export class DialogOverviewExampleDialog {   constructor(     public dialogRef: MatDialogRef<DialogOverviewExampleDialog>,     @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

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

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

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

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

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

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

Вариант 1

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

Вариант 2

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

Снимок экрана 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

А что, если мы всё-таки гордые? Согласитесь, неприятно, когда обновляешь 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<T, D = any, R = any>(template: ComponentType<T> | TemplateRef<T>, config?: MatDialogConfig<D>): MatDialogRef<T, R> {       const newConfig: MatDialogConfig<D> = { 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. Поэтому объявляю интерактив!

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

Эти функции

59.26% похожи на хуки16
40.74% вообще не хуки11

Проголосовали 27 пользователей. Воздержались 10 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/812449/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *