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

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

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

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

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

an0_ap2tarzeyt1hdnzncghyroc.png

Как обычно используется 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(
    '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 данных перед их использованием. Фактически наш компонент нуждается в одних данных, а мы внедряем в него другие и выполняем логику преобразования внутри него.

is79ikuht1p_qsro10wy9xf_du8.jpeg

Давайте посмотрим сразу на солидном примере. Эрин Коглар в своем докладе 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 специально для компонента, в котором и будут происходить необходимые преобразования.

Для удобства мы выносим провайдеры в отдельный файл рядом с компонентом, получая такую структуру файлов:

hbk-m27wqbtf0uilpvy2751pre0.png

В файле 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}}

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

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

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

Заключение


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

Тем не менее я надеюсь, что частные провайдеры помогут вам упростить компоненты с большим количеством зависимостей или дадут альтернативу при постепенном рефакторинге больших кусков логики.

© Habrahabr.ru