Привет! В этой заметке покажу, как можно использовать функцию 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. Поэтому объявляю интерактив!
Комментарии (7)
muaddibco
06.05.2024 07:25+1Относительно useQueryParam - предложенный подход имеет один очень серьезный недостаток называемый "скрытые зависимости". Поскольку использование функции useQueryParam скрыто, это сильно снижает читаемость кода и усложняет его тестируемость. С этой точки зрения использование сервиса более оправдано:
@Injectable({ providedIn: 'root' }) export class QueryParamService { constructor(private activatedRoute: ActivatedRoute, private router: Router) {} getQueryParam<T>(paramKey: string): QueryParam<T> { return new QueryParam<T>(paramKey, this.activatedRoute, this.router); } }
Shirbak Автор
06.05.2024 07:25использование функции useQueryParam скрыто
Как же скрыто?
@Component({ /* ... */ }) export class SomeComponent { protected readonly id$: QueryParam<string> = useQueryParam('id'); // ~~~~~~~~~~~~~~~~~~~ // вот она родимая, прямо в компоненте }
Я открыл файл и вижу, что здесь будет идти речь о query-параметре
id
.Можем, в принципе, воспользоваться вашим решением:
@Component({ /* ... */ }) export class SomeComponent { protected readonly queryParamService = inject(QueryParamService); protected readonly id$: QueryParam<string> = this.queryParamService.getQueryParam('id'); }
Если я правильно понял, под скрытыми зависимостями имеются в виду
ActivatedRoute
иRouter
. Ну в этом случае их здесь тоже нет — они скрыты в сервисе.Получается практически то же самое, разве что у нас появилось лишнее поле
queryParamService
. К тому же, этот сервисprovidedIn: root
и получает не совсем тот экземплярActivatedRoute
.А это важно, если мы будем работать с path-параметрами.
Xuxicheta
06.05.2024 07:25Это работает совершенно не так, как хуки в реакте, поэтому use тут только вводит в заблуждение людей, знакомых с реактом.
Если писать какую-то обертку над инжектами, то и называться он должен с inject.
-
С query params там не всё так просто, по факту вы создали Subject прибитый к роутеру, да, мы все делали подобные, иногда декораторами, иногда сервисом. Мне нравился вариант с отдельными провайдерами для каждого параметра, в котором зашито и его имя и тип и функция конвертации. Но там надо разруливать моменты с одновременными навигейтами и взаимозависимости, чуть сложнее будет.
Опять таки, это не use. И команда ангуляра сейчас предлагает переводить это в инпуты, сомнительно, но окей.
С диалогом, передача своего инжектора сделана для возможности подмешивания своих данных в контекст темплейта. Тут лучше для каждого диалогового окна сделать свой отдельный сервис, который умеет его открывать. Даст прибитую изоляцию и типизацию.
visirok
06.05.2024 07:25По статье чувствуется, что автор глубоко разбирается в теме. Поэтому рискну задать мой «детский» вопрос, внятного ответа на который я не нашёл ни на форумах ни у GitHub Copilot.
В TypeScript можно делать статические переменные и на их базе Singletons и Factories. В каких случаях их недостаточно и необходимо применять Angular’s injection?
nin-jin
Для начала покажу, как в $mol происходит работа с сервисами вообще и параметрами урла в частности:
Да, и это всё. Никаких токенов, параметров конструкторов, декораторов, инъекторов, хуков, возни со стримами, ручных подписок/отписок, и конечно никаких развесистых статей, как это правильно готовить.
А теперь расскажу, как это по настоящему правильно готовить в Ангуляре (пишу по памяти, много лет не трогал эту поделку):
Да, один конструктор, одинаковый для всех объектов. При этом зависимости создаются лениво, по мере потребности, что экономит ресурсы, а так же ускоряет запуск вообще, и прогон тестов в особенности. Возьни со стримами добавьте по вкусу.
daemonyeen
То есть вместо того чтобы получить один раз, мы будем получать сервис каждый раз как он нам понадобится? Что то сомневаюсь что это быстрее. Чем обоснована скорость?
nin-jin
Мемоизацию тоже добавьте по вкусу.