Русский
Русский
English
Статистика
Реклама

Используем DI в Angular по максимуму концепция частных провайдеров

В Angular очень мощный механизм Dependency Injection. Он позволяет передавать по вашему приложению любые данные, преобразовывать и переопределять их в нужных частях.

Поэтому мы можем делать архитектуру приложений более простой и гибкой: понятный поток данных, минимальная связанность кода, легкость при тестировании или замене зависимостей.

Тем не менее DI в приложениях используется достаточно скромно. Как правило, это внедрение сервисов или передача каких-то глобальных данных сверху вниз по дереву внедрения зависимостей.

В этой статье я хотел бы показать альтернативный вариант работы с полученными из DI данными. Цель: упростить компоненты, директивы и сервисы, которые эти данные используют.




Как обычно используется DI в Angular


Я ежедневно провожу ревью Angular-кода на работе и в опенсорсе. Как правило, в большинстве приложений DI сводится к следующей функциональности:

  1. Получить сущности Angular из дерева зависимостей: ChangeDetectorRef, ElementRef и проч.
  2. Получить сервис, чтобы использовать его в компоненте.
  3. Получить какой-нибудь глобальный конфиг по токену, который объявлен где-то наверху. Например, задать токен 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<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 организации, с которой мы работаем в текущий момент.
Сервис, который по 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;           });   }}

Чтобы использовать полученные данные в шаблоне:
<p *ngIf="organization">   {{organization.name}} from {{organization.city}}</p>


Этот код будет работать, но у него есть ряд проблем:
Неопределенность поля 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<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>


Что нам дает этот подход?

  1. Чистые зависимости: компонент не внедряет в себя и не хранит лишних сущностей. Он работает только с теми данными, которые ему нужны, при этом сам остается чистым и содержит только логику для отображения данных.
  2. Простота тестирования: мы можем легко протестировать сам провайдер, потому что его фабрика обычная функция. Нам легче тестировать компонент: в тестах нам не нужно будет собирать дерево зависимостей и подменять много сущностей мы просто передадим по токену ORGANIZATION_INFO стрим с мокаными данными.
  3. Готовность к изменению и расширению: если компонент будет работать с другим типом данных, мы поменяем лишь одну строчку. Если нужно будет изменить преобразование поменяем фабрику. Если потребуется добавить новых данных, то добавим еще один токен мы можем сложить сколько угодно токенов в наш массив провайдеров.


После внедрения этого подхода наши компоненты и директивы стали выглядеть гораздо чище и проще. Разделение логики преобразования данных и их отображения ускорило процесс их доработки и расширения. Время поимки багов также сократилось за счет того, что можно сразу срезать область проблемы: либо проблема в преобразовании данных, либо в том, как они выводятся пользователю.

Заключение


Описанный подход не избавит вас от всех проблем проектирования. Добавлять провайдеры на любую мелочь тоже не стоит: иногда код получается понятнее, если воспользоваться преобразованием в методе или использовать Pipe.

Тем не менее я надеюсь, что частные провайдеры помогут вам упростить компоненты с большим количеством зависимостей или дадут альтернативу при постепенном рефакторинге больших кусков логики.
Источник: habr.com
К списку статей
Опубликовано: 23.06.2020 16:19:11
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании tinkoff.ru

Angular

Javascript

Typescript

Dependancy injection

Rxjs

Providers

Ng

Категории

Последние комментарии

© 2006-2020, personeltest.ru