Скрытый потенциал функции 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);
}
// ...
}
Работает. Ну впрочем, мы ничего сверъестественного и не сделали.
Теперь попробуем им воспользоваться внутри компонента модального окна:
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
Вот так оно выглядит до открытия модального окна: 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(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. Поэтому объявляю интерактив!