Привет! В этой заметке покажу, как можно использовать функцию 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 уже нет? Мы ведь его запровайдили в компоненте, и из него же вызвали модальное окно.
Давайте нарисуем дерево провайдеров.

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

У нас добавился новый инжектор под названием DialogOverviewExampleDialog. Он уже относится к компоненту модалки. Стрелкой снова показано наследование, а пунктирной линией я показал, что этот инжектор был создан именно сервисом MatDialog.
Как видим, действительно, сервису SomeService в этой модалке взяться просто неоткуда: у него в арсенале есть собственный инжектор и родительский, и нигде нужного сервиса нет.
Что делать? Есть несколько вариантов.
Вариант 1
Перенести SomeService в root. Решило бы проблему, но нельзя, потому что нам нужен не синглтон, а свой экземпляр на каждый экземпляр компонента. Ну вот по бизнесу так надо. Не подходит.
Вариант 2
Перенести MatDialog внутрь инжектора DialogOverviewExample. Тогда модальное окно унаследуется уже от него, и сервис будет доступен. Создать лишний экземпляр сервиса мы можем, так как мы там не храним никаких данных, нам от него нужна лишь функция.

Это уже можно, и это решит проблему. Указываем MatDialog в списке провайдеров:
@Component({ // ... providers: [SomeService, MatDialog] // <-- добавляем сюда }) export class DialogOverviewExample { /* ... */ }
Открываем модалку и получаем… точно такой же NullInjectorError. У тех, кто обновлялся до 15 версии теперь тоже должны начаться флэшбэки. Потому что начиная с 15 версии MatDialog под капотом использует сервис Dialog, который также по умолчанию провайдится в root. А его-то мы не поднимали вверх по дереву, он там в корне висеть и остался. Добавим, мы не гордые:
@Component({ // ... providers: [SomeService, MatDialog, Dialog] // <-- добавляем сюда }) export class DialogOverviewExample { // ... }
Открываем модалку и видим, что всё работает как положено.

А что, если мы всё-таки гордые? Согласитесь, неприятно, когда обновляешь 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. Поэтому объявляю интерактив!
