Используем DI в Angular по максимуму — концепция частных провайдеров
В Angular очень мощный механизм Dependency Injection. Он позволяет передавать по вашему приложению любые данные, преобразовывать и переопределять их в нужных частях.
Поэтому мы можем делать архитектуру приложений более простой и гибкой: понятный поток данных, минимальная связанность кода, легкость при тестировании или замене зависимостей.
Тем не менее DI в приложениях используется достаточно скромно. Как правило, это внедрение сервисов или передача каких-то глобальных данных сверху вниз по дереву внедрения зависимостей.
В этой статье я хотел бы показать альтернативный вариант работы с полученными из DI данными. Цель: упростить компоненты, директивы и сервисы, которые эти данные используют.
Как обычно используется DI в Angular
Я ежедневно провожу ревью Angular-кода на работе и в опенсорсе. Как правило, в большинстве приложений DI сводится к следующей функциональности:
- Получить сущности Angular из дерева зависимостей: ChangeDetectorRef, ElementRef и проч.
- Получить сервис, чтобы использовать его в компоненте.
- Получить какой-нибудь глобальный конфиг по токену, который объявлен где-то наверху. Например, задать токен API_URL в рутовом модуле и получать его из DI в любом месте приложения при необходимости.
Реже встречаются случаи, когда разработчики идут дальше и преобразуют уже существующий глобальный токен в более удобную форму. Хороший пример такого преобразования — токен на получение WINDOW из пакета @ng-web-apis/common.
Angular предоставляет токен DOCUMENT, чтобы можно было получить объект страницы из любого места приложения: ваши компоненты не зависят от глобальных объектов, легко тестировать, ничего не сломается при SSR.
Если вам регулярно нужен доступ до объекта WINDOW, можно написать такой токен:
import {DOCUMENT} from '@angular/common';
import {inject, InjectionToken} from '@angular/core';
export const WINDOW = new InjectionToken(
'An abstraction over global window object',
{
factory: () => {
const {defaultView} = inject(DOCUMENT);
if (!defaultView) {
throw new Error('Window is not available');
}
return defaultView;
},
},
);
Когда кто-то запросит токен WINDOW в первый раз из дерева DI, выполнится фабрика токена — он получит объект DOCUMENT у Angular и получит из него ссылку на объект window.
Далее я предлагаю рассмотреть иной подход к таким преобразованиям — когда они выполняются непосредственно в providers компонента или директивы, который и инжектит результат.
Частные провайдеры
В нашей команде мы активно используем DI при работе с Angular и стали замечать, что зачастую нам нужно совершить какие-то преобразования полученных из DI данных перед их использованием. Фактически наш компонент нуждается в одних данных, а мы внедряем в него другие и выполняем логику преобразования внутри него.
Давайте посмотрим сразу на солидном примере. Эрин Коглар в своем докладе The Architecture of Components на большой международной конференции Angular Connect показала такой пример:
Если вам неудобно открывать и смотреть видео, то давайте распишу кейс здесь.
Имеем:
— Компонент, который отвечает за показ информации по некой сущности — организации.
— Query-параметр роута, который указывает id организации, с которой мы работаем в текущий момент.
— Сервис, который по id возвращает Observable с информацией об организации.
Что хотим сделать
Взять из query-параметров id организации, передать его в метод сервиса, а в ответ получить стрим с информацией об организации. Эту информацию вывести в компоненте.
Рассмотрим три способа добиться желаемого и разберем их.
Как делать не нужно
Иногда я встречаю вот такой стиль работы с данными в компонентах. Пожалуйста, не делайте так:
@Component({
selector: 'organization',
templateUrl: 'organization.template.html',
styleUrls: ['organization.style.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationComponent implements OnInit {
organization: Organization;
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly organizationService: OrganizationService,
) {}
ngOnInit() {
this.activatedRoute.params
.pipe(
switchMap(params => {
const id = params.get('orgId');
return this.organizationService.getOrganizationById$(id);
}),
)
.subscribe(organization => {
this.organization = organization;
});
}
}
Чтобы использовать полученные данные в шаблоне:
{{organization.name}} from {{organization.city}}
Этот код будет работать, но у него есть ряд проблем:
— Неопределенность поля organization: между моментом объявления поля при создании класса и присвоения ему значения пройдет некоторое время. Все это время в данном примере поле будет undefined. Мы либо нарушаем типизацию (такое возможно при отключенном strict у TypeScript), либо предусматриваем это в типе (organization?: Organization) и обрекаем себя на ряд дополнительных проверок.
— Такой код тяжелее поддерживать. Завтра нам понадобится вытащить еще один параметр, мы продолжим заполнять ngOnInit, и код начнет постепенно превращаться в кашу с кучей неявных переменных и тяжелым для понимания потоком данных.
— При подобном обновлении полей можно столкнуться с проблемами проверки изменений при использовании стратегии OnPush.
Сделаем хорошо
В докладе Эрин из видео, что я прикладывал выше, сделано хорошо. С ее вариантом получается примерно так:
@Component({
selector: 'organization',
templateUrl: 'organization.template.html',
styleUrls: ['organization.style.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationComponent {
readonly organization$: Observable = this.activatedRoute.params.pipe(
switchMap(params => {
const id = params.get('orgId');
return this.organizationService.getOrganizationById$(id);
}),
);
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly organizationService: OrganizationService,
) {}
}
Чтобы использовать полученные данные в шаблоне:
{{organization.name}} from {{organization.city}}
Этот код отлично работает и лишен недостатков прошлого подхода: смотрится достаточно аккуратно, в нем нет лишних полей. Если мы захотим расширить компонент аналогичным стримом, мы просто добавим еще один — сделать это проще, потому что для добавления нового стрима нам никак не нужно затрагивать код предыдущего.
Кроме того, поток данных становится более прозрачным: у нас есть только стрим, который создается в момент создания класса компонента. Когда он выдаст данные, будет показана информация в нашем шаблоне.
Сделаем еще круче: частные провайдеры
Давайте присмотримся внимательнее к прошлому решению.
На самом деле компонент не зависит от роутера и даже от OrganizationService. Он зависит от organization$. Но такой сущности в нашем дереве внедрения зависимостей нет, поэтому мы вынуждены выполнять преобразования в компоненте.
Но что если выполнить это преобразование перед тем, как данные попадут в компонент? Давайте напишем Provider специально для компонента, в котором и будут происходить необходимые преобразования.
Для удобства мы выносим провайдеры в отдельный файл рядом с компонентом, получая такую структуру файлов:
В файле organization.providers.ts будут находиться Provider для преобразования данных и токен для их получения в компоненте:
export const ORGANIZATION_INFO = new InjectionToken>(
'A stream with current organization information',
);
По этому токену будет идти стрим с необходимой компоненту информацией:
export const ORGANIZATION_PROVIDERS: Provider[] = [
{
provide: ORGANIZATION_INFO,
deps: [ActivatedRoute, OrganizationService],
useFactory: organizationFactory,
},
];
export function organizationFactory(
{params}: ActivatedRoute,
organizationService: OrganizationService,
): Observable {
return params.pipe(
switchMap(params => {
const id = params.get('orgId');
return organizationService.getOrganizationById$(id);
}),
);
}
Определим массив провайдеров для компонента. Значение для токена ORGANIZATION_INFO получим из фабрики, в которой сделаем необходимое преобразование данных.
Примечание по работе DI — deps позволяет взять из дерева DI необходимые сущности и передать их как аргументы в фабрику значения токена. В них можно получить любую сущность из DI, в том числе и с использованием DI-декораторов, например:
{
provide: ACTIVE_TAB,
deps: [
[new Optional(), new Self(), RouterLinkActive],
],
useFactory: activeTabFactory,
}
Объявим providers в компоненте:
@Component({
..
providers: [ORGANIZATION_PROVIDERS],
})
И мы готовы к использованию данных в компоненте:
@Component({
selector: 'organization',
templateUrl: 'organization.template.html',
styleUrls: ['organization.style.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ORGANIZATION_PROVIDERS],
})
export class OrganizationComponent {
constructor(
@Inject(ORGANIZATION_INFO) readonly organization$: Observable,
) {}
}
Класс компонента сводится к одной строчке с получением данных.
Шаблон остается прежним:
{{organization.name}} from {{organization.city}}
Что нам дает этот подход?
- Чистые зависимости: компонент не внедряет в себя и не хранит лишних сущностей. Он работает только с теми данными, которые ему нужны, при этом сам остается чистым и содержит только логику для отображения данных.
- Простота тестирования: мы можем легко протестировать сам провайдер, потому что его фабрика — обычная функция. Нам легче тестировать компонент: в тестах нам не нужно будет собирать дерево зависимостей и подменять много сущностей — мы просто передадим по токену
ORGANIZATION_INFO
стрим с мокаными данными. - Готовность к изменению и расширению: если компонент будет работать с другим типом данных, мы поменяем лишь одну строчку. Если нужно будет изменить преобразование — поменяем фабрику. Если потребуется добавить новых данных, то добавим еще один токен — мы можем сложить сколько угодно токенов в наш массив провайдеров.
После внедрения этого подхода наши компоненты и директивы стали выглядеть гораздо чище и проще. Разделение логики преобразования данных и их отображения ускорило процесс их доработки и расширения. Время поимки багов также сократилось за счет того, что можно сразу срезать область проблемы: либо проблема в преобразовании данных, либо в том, как они выводятся пользователю.
Заключение
Описанный подход не избавит вас от всех проблем проектирования. Добавлять провайдеры на любую мелочь тоже не стоит: иногда код получается понятнее, если воспользоваться преобразованием в методе или использовать Pipe.
Тем не менее я надеюсь, что частные провайдеры помогут вам упростить компоненты с большим количеством зависимостей или дадут альтернативу при постепенном рефакторинге больших кусков логики.