- PVSM.RU - https://www.pvsm.ru -
В Angular очень мощный механизм Dependency Injection. Он позволяет передавать по вашему приложению любые данные, преобразовывать и переопределять их в нужных частях.
Поэтому мы можем делать архитектуру приложений более простой и гибкой: понятный поток данных, минимальная связанность кода, легкость при тестировании или замене зависимостей.
Тем не менее DI в приложениях используется достаточно скромно. Как правило, это внедрение сервисов или передача каких-то глобальных данных сверху вниз по дереву внедрения зависимостей.
В этой статье я хотел бы показать альтернативный вариант работы с полученными из DI данными. Цель: упростить компоненты, директивы и сервисы, которые эти данные используют.
Я ежедневно провожу ревью Angular-кода на работе и в опенсорсе. Как правило, в большинстве приложений DI сводится к следующей функциональности:
Реже встречаются случаи, когда разработчики идут дальше и преобразуют уже существующий глобальный токен в более удобную форму. Хороший пример такого преобразования — токен на получение WINDOW из пакета @ng-web-apis/common [1].
Angular предоставляет токен DOCUMENT, чтобы можно было получить объект страницы из любого места приложения: ваши компоненты не зависят от глобальных объектов, легко тестировать, ничего не сломается при SSR.
Если вам регулярно нужен доступ до объекта WINDOW, можно написать такой токен:
import {DOCUMENT} from '@angular/common';
import {inject, InjectionToken} from '@angular/core';
export const WINDOW = new InjectionToken<Window>(
'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 организации, передать его в метод сервиса, а в ответ получить стрим с информацией об организации. Эту информацию вывести в компоненте.
Рассмотрим три способа добиться желаемого и разберем их.
Как делать не нужно
Иногда я встречаю вот такой стиль работы с данными в компонентах. Пожалуйста, не делайте так:
@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;
});
}
}
Чтобы использовать полученные данные в шаблоне:
<p *ngIf="organization">
{{organization.name}} from {{organization.city}}
</p>
Этот код будет работать, но у него есть ряд проблем:
Сделаем хорошо
В докладе Эрин из видео, что я прикладывал выше, сделано хорошо. С ее вариантом получается примерно так:
@Component({
selector: 'organization',
templateUrl: 'organization.template.html',
styleUrls: ['organization.style.less'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationComponent {
readonly organization$: Observable<Organization> = 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,
) {}
}
Чтобы использовать полученные данные в шаблоне:
<p *ngIf="organization$ | async as organization">
{{organization.name}} from {{organization.city}}
</p>
Этот код отлично работает и лишен недостатков прошлого подхода: смотрится достаточно аккуратно, в нем нет лишних полей. Если мы захотим расширить компонент аналогичным стримом, мы просто добавим еще один — сделать это проще, потому что для добавления нового стрима нам никак не нужно затрагивать код предыдущего.
Кроме того, поток данных становится более прозрачным: у нас есть только стрим, который создается в момент создания класса компонента. Когда он выдаст данные, будет показана информация в нашем шаблоне.
Давайте присмотримся внимательнее к прошлому решению.
На самом деле компонент не зависит от роутера и даже от OrganizationService. Он зависит от organization$. Но такой сущности в нашем дереве внедрения зависимостей нет, поэтому мы вынуждены выполнять преобразования в компоненте.
Но что если выполнить это преобразование перед тем, как данные попадут в компонент? Давайте напишем Provider специально для компонента, в котором и будут происходить необходимые преобразования.
Для удобства мы выносим провайдеры в отдельный файл рядом с компонентом, получая такую структуру файлов:
В файле organization.providers.ts будут находиться Provider для преобразования данных и токен для их получения в компоненте:
export const ORGANIZATION_INFO = new InjectionToken<Observable<Organization>>(
'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<Organization> {
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>,
) {}
}
Класс компонента сводится к одной строчке с получением данных.
Шаблон остается прежним:
<p *ngIf="organization$ | async as organization">
{{organization.name}} from {{organization.city}}
</p>
Что нам дает этот подход?
ORGANIZATION_INFO
стрим с мокаными данными.
После внедрения этого подхода наши компоненты и директивы стали выглядеть гораздо чище и проще. Разделение логики преобразования данных и их отображения ускорило процесс их доработки и расширения. Время поимки багов также сократилось за счет того, что можно сразу срезать область проблемы: либо проблема в преобразовании данных, либо в том, как они выводятся пользователю.
Описанный подход не избавит вас от всех проблем проектирования. Добавлять провайдеры на любую мелочь тоже не стоит: иногда код получается понятнее, если воспользоваться преобразованием в методе или использовать Pipe.
Тем не менее я надеюсь, что частные провайдеры помогут вам упростить компоненты с большим количеством зависимостей или дадут альтернативу при постепенном рефакторинге больших кусков логики.
Автор: Роман Седов
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/354319
Ссылки в тексте:
[1] @ng-web-apis/common: https://github.com/ng-web-apis/common
[2] Источник: https://habr.com/ru/post/507906/?utm_source=habrahabr&utm_medium=rss&utm_campaign=507906
Нажмите здесь для печати.