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

Rxjs

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

23.06.2020 16:19:11 | Автор: admin
В 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.

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

5 советов для прокачки своих навыков в Angular

14.09.2020 16:14:47 | Автор: admin

Этим летом мы с Ромой запустили серию твитов с полезными советами и приемами по Angular. Сообщество тепло встретило эту инициативу, и я решил написать обобщающую статью.Вот мои 5 рекомендаций, которыми хочется поделиться с разработчиками. Эти советы будут подкреплены конкретными примерами из моего твиттера. Они помогут вам поднять свои навыки или как минимум дадут пару практических приемов.

1. Разберитесь в работе механизма проверки изменений

В интернете множество отличных углубленных статей про проверку изменений в Angular. Например, вот эта. Так что давайте просто освежим основы и перейдем к советам.

Основы

В Angular два режима проверки изменений: Default и OnPush. Первый запускает проверку на каждый tick внутри приложения. Этим управляет Zone.js, которая патчит все асинхронные операции вроде подписок на события и промисов. Второй режим помечает view для проверки, только если в нем случилось слушаемое событие или изменились входные данные.

Default vs OnPush

По правде говоря, вряд ли есть причины использовать режим Default. Просто пишите код так, как задумано принципами фреймворка, и вы не попадете в неприятности с OnPush. Это означает никогда не мутировать данные. Если ваши входные объекты или массивы немутабельные, OnPush подцепит изменения и обновит вью.

Подписки на события через @HostListener Angular заметит и в OnPush. Но что делать, если вы используете RxJS? Всегда можно заинжектить ChangeDetectorRef и вызвать markForCheck(), когда потребуется. Декларативным решением тут будет async пайп, если стрим в итоге доходит до шаблона. Он сам запустит проверку и возьмет на себя отписку.

Вам наверняка попадался такой паттерн:

<div *ngIf="stream$ | async as result"></div>

Но что делать, если вам важны также falsy-результаты? Можно выкинуть всю логику на условие из ngIf и сделать свою простую структурную директиву. Она будет использоваться, только чтобы объявить контекст для вложенного вью:

Код
@Directive({  selector: "[ngLet]"})export class LetDirective<T> {  @Input()  ngLet: T;  constructor(    @Inject(ViewContainerRef) container: ViewContainerRef,    @Inject(TemplateRef) templateRef: TemplateRef<LetContext<T>>  ) {    container.createEmbeddedView(templateRef, new LetContext<T>(this));  }}

NgZone

Если у вас нет возможности полностью перейти на OnPush, можно провести оптимизации. Заинжектите NgZone и выполняйте нагруженные операции в .runOutsideAngular(). Таким образом не будет возникать лишних тиков в механизме проверки изменений. Даже компоненты в режиме Default не будут реагировать на эти операции. Это уместно делать для частых событий, таких как mousemove или scroll. Это можно сделать декларативно в RxJS-стримах с помощью двух операторов: один для выхода из зоны, другой для возврата в нее, чтобы запустить проверку изменений:

Код
class ZonefreeOperator<T> implements Operator<T, T> {  constructor(private readonly zone: NgZone) {}  call(observer: Observer<T>, source: Observable<T>): TeardownLogic {    return this.zone.runOutsideAngular(      () => source.subscribe(observer)    );  }}export function zonefull<T>(zone: NgZone): MonoTypeOperatorFunction<T> {  return map(value => zone.run(() => value));}export function zonefree<T>(zone: NgZone): MonoTypeOperatorFunction<T> {  return source => source.lift(new ZonefreeOperator(zone));}

Еще один вариант, работающий с @HostListener, создать свой EventManagerPlugin. Мы выпустили open-source-библиотеку под названием ng-event-plugins. Она позволяет отсеивать лишние проверки изменений. Подробнее об этом читайте в этой статье.

2. Хорошенько разберитесь в RxJS

RxJS очень мощный инструмент. Мы все его используем в той или иной мере, но именно хорошее владение им очень сильно поможет вам. От простых стримов, которые позволят быстро перезагрузить компонент

до сложных комбинаций операторов. Например, чтобы понять, настоящий у вас скролл или инерционный:

Только посмотрите, как просто создать шапку, исчезающую при прокрутке сайта вниз. Совсем немного CSS и базовый RxJS:

Код
@Directive({  selector: "[sticky]",  providers: [DestroyService]})export class StickyDirective {  constructor(    @Inject(DestroyService) destroy$: Observable<void>,    @Inject(WINDOW) windowRef: Window,    renderer: Renderer2,    { nativeElement }: ElementRef<HTMLElement>  ) {    fromEvent(windowRef, "scroll")      .pipe(        map(() => windowRef.scrollY),        pairwise(),        map(([prev, next]) => next < THRESHOLD || prev > next),        distinctUntilChanged(),        startWith(true),        takeUntil(destroy$)      )      .subscribe(stuck => {        renderer.setAttribute(          nativeElement,           "data-stuck",           String(stuck)        );      });  }}

Сложно переоценить знание RxJS для Angular-разработчика в долгосрочной перспективе. Простой на первый взгляд RxJS требует некоего смещения парадигмы, чтобы начать мыслить потоками. Но, когда вы это сделаете, ваш код станет аккуратнее и легче в поддержке.

Тут особо нечего советовать, кроме как больше практиковаться . Если видите ситуацию, которую можно решить через RxJS, попытайтесь сделать это. Избегайте сайд-эффектов и вложенных подписок. Держите свои стримы в порядке и следите за утечками памяти (подробнее об этом дальше).

3. Выжимайте максимум из TypeScript

Мы все пишем Angular-приложения на TypeScript. Но, чтобы получить максимальную пользу, нужно задействовать его целиком. Я редко вижу проекты с включенным strict: true. Вам определенно следует сделать это. Оно спасет вас от множества cannot read property of null и undefined is not a function.

Дженерики

В TypeScript существуют дженерики для случаев, когда тип, с которым мы работаем, неизвестен. Комбинация дженериков, перегрузок и сужения типов позволит вам сделать крутой API. Почти никогда не придется делать тайпкаст. Посмотрите на этот пример типизированного RxJS-метода fromEvent:

Код
// Тип события с конкретным currentTargetexport type EventWith<  E extends Event,  T extends FromEventTarget<E>> = E & {  readonly currentTarget: T;};// Типизированный вариант fromEventexport function typedFromEvent<  E extends keyof GlobalEventHandlersEventMap,  T extends FromEventTarget<EventWith<GlobalEventHandlersEventMap[E], T>>>(  target: T,  event: E,  options: AddEventListenerOptions = {},): Observable<EventWith<GlobalEventHandlersEventMap[E], T>> {  return fromEvent(target, event, options);}

С ним вы будете уверены, что событие имеет конкретный тип, а currentTarget это элемент, на котором вы его слушаете.

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

Есть немало статей о продвинутой типизации и всевозможных трюках с TypeScript. Я очень советую расширять свои знания в этой области, это поможет вам писать надежный код. Мой последний совет: не используйте any. Если вы не можете применить дженерик, более безопасным выбором будет unknown.

Декораторы

Не забывайте про другие инструменты TypeScript, такие как декораторы. При умелом использовании они могут значительно улучшить ваш код. К примеру, бывают ситуации, когда тип переменной корректный, но значение не подходит: отрицательное или дробное число не подходит для обозначения количества, хотя тип и там, и там number. TypeScript такое не отловит, но мы можем защитить компоненты в runtime с помощью декоратора:

Код
export function assert<T, K extends keyof T>(  assertion: (input: T[K]) => boolean,  messsage: string): PropertyDecorator {  return (target, key) => {    Object.defineProperty(target, key, {      set(this: T, initialValue: T[K]) {        let currentValue = initialValue;        Object.defineProperty(this, key, {          get(): T[K] {            return currentValue;          },          set(this: T, value: T[K]) {            console.assert(assertion(value), messsage);            currentValue = value;          }        });      }    });  };}

Вы знали, что декорированный абстрактный класс не нуждается в пробросе аргументов в super()? Angular сам сделает это за вас, если не использовать конструктор в дочернем классе:

Еще один хороший пример для декораторов переиспользуемая логика обработки. Посмотрите кусок кода из нашей библиотеки для Web Audio API в Angular, где мы превращаем декларативный байндинг в императивные нативные команды с помощью строго типизированного декоратора. Подробнее про саму библиотеку можно почитать тут.

4. Dependency Injection. Используйте его почаще

DI одна из причин, почему Angular такой мощный фреймворк. Возможно, это даже главная причина. Но слишком часто его не используют по полной.

Могу порекомендовать нашу статью, посвященную DI, чтобы освоиться с этим инструментом.

RxJS

Что касается практических советов, выше я уже говорил быть внимательными к утечкам памяти в RxJS. Главным образом это означает, что, если вы подписались на стрим руками, вам же и придется от него отписаться. Идиоматическим решение в Angular будет инкапсуляция подобной логики в сервис:

Код
@Injectable()export class DestroyService extends Subject<void> implements OnDestroy {    ngOnDestroy() {        this.next();        this.complete();    }}

Вы также можете создавать общие стримы и добавлять их в DI. Нет необходимости создавать отдельные потоки на базе requestAnimationFrame в вашем приложении. Создайте токен и переиспользуйте его. Вы даже можете заложить в него операторы для выхода из зоны, описанные выше:

Токены

DI хороший инструмент для повышения абстрактности вашего кода. Если вы не зависите от глобальных объектов вроде window или navigator ваше приложение готово к использование в Angular Universal в серверном окружении. Такой код просто тестируется, так как все его зависимости легко подменить на заглушки. Глобальные объекты без труда превращаются в токены. Внутри фабрики при объявлении токена нам доступен глобальный инжектор. Нам потребуется всего пара строк для создания WINDOW токена на базе встроенного DOCUMENT:

Код
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;    },  },);

Чтобы не тратить на это время, используйте нашу open-source-библиотеку, где мы уже реализовали многие такие токены. А для Angular Universal есть ее библиотека-сестра с качественными заглушками. Не стесняйтесь, пишите нам, если вам нужен еще какой-то токен.

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

5. Бросайте императора в бездну, как Вейдер

Angular со своим синтаксисом шаблонов и декораторами подталкивает нас к декларативному подходу. Я уже упоминал его. Не будем сейчас вдаваться в подробности о его преимуществах по сравнению с императивным кодом. Я просто дам вам четкий совет: пишите декларативный код. Когда вы к нему привыкнете, то заметите, насколько все стало лучше.

Геттеры

Что это означает писать декларативный код? В первую очередь постарайтесь использовать ngOnChanges как можно реже. Это плохо типизированный сайд-эффект, который нужен, по сути, только когда надо выполнить какое-то действие при изменении нескольких входных значений.

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

Производительность и геттеры тема для отдельной статьи, до которой я надеюсь вскоре добраться. А пока возьмите за правило не создавать новых объектов или массивов внутри геттеров. Это существенно более затратная операция, чем арифметика или сложение строк. Если вам требуется пересоздать объект, воспользуйтесь техникой мемоизации к примеру, чистым пайпом.

Это хороший пример для сеттера, который я опустил для краткости, но про который мне напомнил внимательный читатель.

Template reference variables

Вместо ручного запрашивания элементов из шаблона в Angular предусмотрен декоратор @ViewChild. Однако зачастую он не нужен, если можно завернуть передачу элемента в шаблоне:

<input #input><button (click)="onClick(input)">Focus</button>

Здесь мы передаем template reference variable непосредственно в метод, где она нужна. Так код нашего компонента остается чище. Думайте об этом как о подобии замыкания в шаблоне.

Но что, если мы хотим получить DOM-элемент, который является компонентом? Мы могли бы написать @ViewChild(MyComponent, {read: ElementRef}), но мы обойдемся без поля класса, если создадим директиву с exportAs:

Код
@Directive({    selector: '[element]',    exportAs: 'elementRef',})export class ElementDirective<T extends Element> extends ElementRef<T> {    constructor(@Inject(ElementRef) {nativeElement}: ElementRef<T>) {        super(nativeElement);    }}

Динамический контент

Люди часто используют ComponentFactoryResolver для императивного создания динамических компонентов. Зачем, если есть директива ngComponentOutlet? Потому что так мы получим доступ к экземпляру компонента и сможем передать в него данные. Хороший способ решения подобной задачи опять же, Dependency Injection. ngComponentOutlet позволяет передать Injector, который мы создадим и подложим в него данные через токен.

По сути, для создания динамического контента у нас есть интерполяция, шаблоны и компоненты. И принципиальной разницы между ними нет. У нас есть контекст и есть его представление.

Мы уже давно используем этот подход, не зависящий от типа переданного контента. Мы вынесли его в крошечную open-source-библиотеку под названием ng-polymorpheus. Она не делает ничего, кроме передачи контента в соответствующий встроенный инструмент, ngTemplateOutlet, ngContentOutlet или же простую интерполяцию с вызовом функции. Когда привыкаешь к такому, обратной дороги уже нет! Подробнее читайте в этой статье.

На этом все. Надеюсь, мои советы будут вам полезны. Приятного кодинга!

Подробнее..

Schedulers в RxJS

20.11.2020 10:22:07 | Автор: admin

Что вы знаете о Schedulers в RxJS? Они скрывают от разработчиков работу с контекстом выполнения Observable. Как те эльфы-домовики из Гарри Поттера, которые выполняют всю черную работу в Хогвартсе, а о них никто даже и не слышал. Давайте исправим это и узнаем о них чуть больше.

Что такое Scheduler

Scheduler позволяет определить в каком контексте выполнения Observable будет доставлять нотификации до Observer. (вольный перевод документации)

Другими словами, Scheduler управляет очередностью и временем выполнения операций в Observable. Пример ниже поможет разобраться нам, как это работает.

import { of } from "rxjs";console.log("Start");of("Observable").subscribe(console.log);console.log("End");// Logs:// Start// Observable// End

Два console.log зажали Observable между собой. Код выполняется синхронно. Если мы хотим, чтобы наш Observable выполнялся асинхронно, нужно добавить оператор observeOn и внутрь него прокинуть нужный нам Scheduler.

import { asyncScheduler, of } from "rxjs";import { observeOn } from "rxjs/operators";console.log("Start");of("Observable")  .pipe(observeOn(asyncScheduler))  .subscribe(console.log);console.log("End");// Logs:// Start// End// Observable

StackBlitz

Теперь наш Observable отдает данные асинхронно. Отлично. Это похоже, если бы мы обернули его в setTimeout(, 0), не правда ли?

Как вы уже успели заметить, мы использовали asyncScheduler в нашем коде. Это один из легендарных Schedulers. Но их гораздо больше.

Типы Schedulers

Для понимания всей мощи Schedulers нужно знать, как работает Event Loop в JavaScript. Освежить знания можно с помощью этого видео или этой статьи на хабре.

Если кратко, то в браузере свой порядок выполнения кода:

  1. Сначала выполняется синхронный код (callstack)

  2. Дальше очередь микрозадач (Promise)

  3. Потом очередь макрозадач (setTimeout, setInterval, XMLHttpRequest и т.д.).

  4. Отдельно стоит очередь для задач, которые выполняются сразу перед следующим циклом перерисовки контента. (requestAnimationFrame)

В RxJS есть Scheduler на каждый из этих пунктов:

queueScheduler

планирование синхронного кода

asapScheduler

планирование кода в очередь микрозадач

asyncScheduler

планирование кода в очередь макрозадач

animationFrameScheduler

планирование кода в очередь перед перерисовкой контента

Еще существуют VirtualTimeScheduler иTestScheduler, которые используются для тестов. О них читайте здесь.

Взгляните на код ниже.

import { of, merge, asapScheduler,asyncScheduler,queueScheduler, animationFrameScheduler } from "rxjs";import { observeOn } from "rxjs/operators";const async$ = of("asyncScheduler").pipe(observeOn(asyncScheduler));const asap$ = of("asapScheduler").pipe(observeOn(asapScheduler));const queue$ = of("queueScheduler").pipe(observeOn(queueScheduler));const animationFrame$ = of("animationFrameScheduler").pipe(  observeOn(animationFrameScheduler));merge(async$, asap$, queue$, animationFrame$).subscribe(console.log);console.log("synchronous code");// Logs:// queueScheduler// synchronous code// asapScheduler// animationFrameScheduler// asyncScheduler

StackBlitz

Как вы видите, "queueScheduler" отработал синхронно, так как он перед "synchronous code". А "asapScheduler" раньше "asyncScheduler", потому что в нем используется очередь микрозадач.

Как использовать Schedulers

Scheduler используется с операторами observeOn и subscribeOn. Оба принимают в себя первым аргументом Scheduler, а вторым аргументом delay, который по умолчанию равен нулю.

import { of, asyncScheduler } from "rxjs";import { observeOn, subscribeOn } from "rxjs/operators";of("observeOn")  .pipe(observeOn(asyncScheduler, 100))  .subscribe(console.log);of("subscribeOn")  .pipe(subscribeOn(asyncScheduler, 50))  .subscribe(console.log);// Logs:// subscribeOn// observeOn

StackBlitz

Различие их в том, что observeOn планирует в каком контексте будут выполняться методы observer next, error и complete выполняются в соответствующем с Scheduler контексте. А subscribeOn влияет на subscriber метод subscribe будет выполняться в другом контексте.

Интересный факт, что если задать delay не равный нулю в observeOn/subscribeOn, то вне зависимости какой используется Scheduler, будет использоваться asyncScheduler. Бессмысленный код observeOn(animationFrameScheduler, 100).

До версии RxJS 6.5.0 можно было добавить Scheduler вторым аргументом для of, from, merge, range и т.д. В новых версиях RxJS это поведение deprecated, и необходимо использоватьфункцию scheduled для этого.

import { of, scheduled, asapScheduler } from 'rxjs';// DEPRECATED// of(2, asapScheduler).subscribe(console.log);scheduled(of('scheduled'), asapScheduler).subscribe(console.log);

Пример использования Scheduler

Мы не задумываемся о Schedulers при работе с RxJS, так как авторы библиотеки провели великолепную работу по абстрагированию этой логики. Но бывает, когда использование Scheduler будет органично, и вы должны быть к этому готовы. На моей практике был один такой случай. Меня попросили реализовать кэширование запросов отличающихся по индетификатору. Примерно такую функцию я написал:

const cache = new Map<number, any>();function get(id: number): Observable<any> { if (cache.has(id)) {   return of(cache.get(id)); } return http.get(some-url\ + id).pipe(   tap(data => {     cache.set(id, data);   }), );}

Код работает. Но есть один нюанс. В первый раз, когда использовали мою функцию, она возвращала значение асинхронно, а во второй раз - синхронно. Когда функция ведет себя так непресказуемо для вызывающего, может случится трагедия. Выходит, что я высвободил Залго.

Добавим scheduled с asyncScheduler в 4 строчке, чтобы исправить это.

return scheduled(of(cache.get(id)), asyncScheduler);

Теперь все работает предсказуемо. Залго низвержен туда, где был.

Заключение

Schedulers влияют на время и порядок выполнения задач. Четверть операторов RxJS использует их под капотом. С большой вероятностью на практике осведомленность о них не нужна. Но никогда не знаешь, в какой момент жизни пригодится Scheduler.

Я буду рад услышать о вашем опыте использования Schedulers в комментариях. Спасибо за внимание!

Подробнее..

Перевод RxJS и Angular искусство отписки от уведомлений

01.12.2020 22:06:36 | Автор: admin

Будущих студентов курса "JavaScript Developer. Professional" приглашаем участвовать в открытом вебинаре на тему "Делаем интерактивного Telegram бота на nodejs".

А пока делимся традиционным переводом полезной статьи.


Если вы используете Angular и библиотеку RxJS, здесь вы узнаете все способы, которые вам могут понадобиться, чтобы подписываться на уведомления от объектов Observable и отписываться от них!

Работать с кодом в динамическом режиме можно вэтом онлайн-редакторе. Полный исходный код для этой статьи размещен вэтом репозитории GitHub.

Введение

Мы используем RxJS во всех приложениях Angular, которые пишем. RxJS оказывает значительное влияние на поток данных в наших приложениях, их производительность и многое другое.

Чтобы избежать утечек памяти, важно вовремя отписываться от уведомлений, выпускаемых объектами Observable. В этой статье описано большинство доступных способов, с помощью которых в компонентах Angular можно отписываться от уведомлений, выпускаемых объектами Observable.

Начнем с создания демонстрационного сервиса (DummyService), которой поможет нам отслеживать устанавливаемые подписки.

У нашего демонстрационного сервиса будет единственный методgetEmissions, который принимаетscopeдля регистрации событий и возвращает объект Observable, выпускающий ${scope} Emission #nкаждую n-ю секунду.

1 стандартный способ

Самым простым способом подписаться на уведомления Observable и отписаться от них является подписка в методеngOnInit, создание свойства класса, в котором будет храниться наша подписка(Subscription), и отписка в методеngOnDestroy. Чтобы все немного упростить, можно инициализировать свойство подписки со значениемSubscription.EMPTY, чтобы избежать его проверки на null при отписке.

Чтобы проверить, что все работает, мы удалим компонент из DOM через 3 секунды и увидим, что отписка выполняется:

Как можно заключить из заголовка, это самый простой способ отписки. Он хорош, когда у вас естьоднаподписка. Когда же подписок несколько, для каждой из них потребуется собственное свойство класса и вызовunsubscribeв методеngOnDestroy.

2 метод Subscription.add

В подписках RxJS имеется встроенный методSubscription.add, с помощью которого можно включить несколько условий отписки в один экземпляр Subscription. Сначала создадим свойство класса, инициализированное какnew Subscription(). Затем, вместо назначения наших подписок свойствам класса, вызовем методSubscription.add. Наконец, выполним отписку в методеngOnDestroy.

После открытия консоли мы должны увидеть две подписки:

В этом подходе мы пользуемся встроенными мощными возможностями RxJS, чтобы подписываться на уведомления от нескольких Observable и отписываться от них без использования дополнительных свойств классов.

3 AsyncPipe

В Angular есть много отличных встроенных фильтров (pipe). Одним из них являетсяAsyncPipe. AsyncPipe принимает объект Observable, автоматически подписывается на уведомления от него и отписывается от них при уничтожении компонента. В отличие от предыдущего примера здесь мыне устанавливаем подписку в нашем компоненте, а передаем объект Observable фильтру AsyncPipe:

По моему мнению, это один из самых удачных способов использования объектов Observable в Angular. Вам нужно просто создать необходимые Observable, и Angular будет подписываться и отписываться за вас.

4 оператор takeUntil

В RxJS есть множество полезных операторов. Одним из них являетсяtakeUntil. По сигнатуре оператора takeUntil понятно, что он принимает на вход объект Observable как параметрnotifier и, когда notifier выпускает значение, выполняет отписку отисходного Observable. В нашем случае нам нужно оповестить Observable об уничтожении компонента. Для этого мы добавляем свойство класса с именемcomponentDestroyed$(или любым другим именем) типаSubject<void>и используем его в качестве notifier. После этого остается только добавить оповещение в методngOnDestroy. Итоговый код имеет следующий вид:

В отличие от описанного ранее обычного способа, в этом варианте не требуется вводить дополнительные свойства класса, если подписок несколько. Нам нужно только добавитьtakeUntil(componentDestroyed$), а RxJS позаботится об остальном.

5 библиотека SubSink

SubSink это потрясающая библиотека, созданнаяУордом Беллом (Ward Bell). С ее помощью можно корректно отписываться от Observable внутри своего компонента.

Сначала установим SubSink, выполнив команду npm i subsink илиyarn add subsink. Затем создадим свойство класса типа SubSink.SubSink можно использовать двумя способами: простой способ с использованием сеттера и способ Array/Add.

Простой способзаключается в использовании метода-сеттераsink.Способ Array/Add имеет такой же синтаксис, что и собственный метод RxJSSubscription.add. Создадим подписку каждым из этих способов. Тогда наш компонент будет выглядеть вот так:

6 библиотека until-destroy

Примечание. Эта библиотека работает на Pre Ivy Angular по-другому. Дополнительные сведения см. вдокументации.

until-destroy это одна из многих прекрасных библиотек, созданныхngneat. С помощью декоратора UntilDestroy она определяет, какие свойства являются подписками, и автоматически отменяет эти подписки при уничтожении компонента. Кроме того, вместо свойств классов мы можем использовать ее специальный оператор для RxJS под названиемuntilDestroyed.

Для этого нам всего лишь нужно применить декоратор UntilDestroy к нашему компоненту и добавить операторuntilDestroyedв метод pipe() объекта Observable:

В заключение скажем, что until-destroy является очень мощной библиотекой, позволяющей автоматически подписываться на уведомления от объектов Observable и отписываться от них. Кроме того, у until-destroyесть много возможностей, которые не описаны в этой статье, поэтому обязательно посмотрите репозиторий GitHub!

Резюме

Мы представили множество способов, позволяющих подписываться на уведомления от объектов Observable и отписываться от них. У каждого из этих способов есть свои достоинства и недостатки, а также отличительный стиль написания кода.

Но какой бы вариант вы ни выбрали, самое важное действовать последовательно.


- Узнать подробнее о карьерных перспективах.

- Записаться на открытый вебинар "Делаем интерактивного Telegram бота на nodejs".

Подробнее..

Создание приложений на Angular с использованием продвинутых возможностей DI

01.06.2021 10:11:39 | Автор: admin

Меня зовут Андрей, и я занимаюсь разработкой фронтенда на Angular для внутренних продуктов компании. Фреймворк обладает обширными возможностями, одни и те же задачи можно решить огромным количеством способов. Чтобы облегчить свою работу и повысить продуктивность, я задался целью найти универсальный и не сложный подход, который бы упростил проектирование и позволил уменьшить объем кода при сохранении его читаемости. Перепробовав множество различных вариантов и учтя допущенные ошибки, я пришел к архитектуре, которой хочу поделиться в этой статье.

Слоеный пирог приложения

Как известно, приложение на Angular представляет собой дерево компонентов, внедряющих зависимости из модульных или элементных инжекторов. При этом его задачей как клиента является получение от сервера информации, которая преобразуется к нужному виду и отображается в браузере. А действия пользователя на странице вызывают изменение информации и визуального представления. Таким образом, приложение разбивается на три абстрактных уровня или слоя, взаимодействующих друг с другом:

  1. Хранение данных и осуществление операций с данными (слой данных).

  2. Преобразование информации к виду, требуемому для отображения, обработка действий пользователя (слой управления или контроллер).

  3. Визуализация данных и делегация событий (слой представления).

В контексте фреймворка они будут обладать следующими характерными особенностями:

  • элементы слоя представления компоненты;

  • зависимости слоя управления находятся в элементных инжекторах, а слоя данных в модульных;

  • связь между слоями осуществляется средствами системы DI;

  • элементы каждого уровня могут иметь дополнительные зависимости, которые непосредственно к слою не относятся;

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

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

Вообще говоря, под данными, передаваемыми между слоями, имеются в виду произвольные объекты. Однако, в большинстве случаев ими будут Observable, которые идеально подходят к описываемому подходу. Как правило, слой данных отдает Observable с частью состояния приложения. Затем в слое управления с помощью операторов rxjs данные преобразовываются к нужному формату, и в шаблоне компонента осуществляется подписка через async pipe. События на странице связываются с обработчиком в контроллере. Он может иметь сложную логику управления запросами к слою данных и подписывается на Observable, которые возвращают асинхронные команды. Подписка позволяет гибко реагировать на результат выполнения отдельных команд и обрабатывать ошибки, например, открывая всплывающие сообщения. Элементы слоя управления я буду дальше называть контроллерами, хотя они отличаются от таковых в MVC паттерне.

Слой данных

Сервисы слоя данных хранят состояние приложения (бизнес-данные, состояние интерфейса) в удобном для работы с ним виде. В качестве дополнительных зависимостей используются сервисы для работы с данными (например: http клиент и менеджеры состояния). Для непосредственного хранения данных удобно использовать BehaviourSubject в простых случаях, и такие библиотеки как akita, Rxjs или ngxs для более сложных. Однако, на мой взгляд, последние две избыточны при данном подходе. Лучше всего для предлагаемой архитектуры подходит akita. Ее преимуществами являются отсутствие бойлерплейта и возможность переиспользовать стейты обычным наследованием. При этом обновлять стейт можно непосредственно в операторах rxjs запросов, что гораздо удобнее, чем создание экшенов.

@Injectable({providedIn: 'root'})export class HeroState {  private hero = new BehaviorSubject(null);  constructor(private heroService: HeroService) {}  load(id: string) {    return this.heroService.load(id).pipe(tap(hero => this.hero.next(hero)));  }  save(hero: Hero) {    return this.heroService.save(hero).pipe(tap(hero => this.hero.next(hero)));  }  get hero$(): Observable<Hero> {    return this.hero.asObservable();  }}

Слой управления

Так как каждый сервис слоя относится к конкретному компоненту с его поддеревом, логично назвать сервис контроллером компонента. Благодаря тому, что контроллер компонента находится в элементном инжекторе, в нем можно использовать OnDestroy hook и внедрять те же зависимости, что и в компоненте, например ActivatedRoute. Безусловно, можно не создавать отдельный сервис для контроллера в тех случаях, где это равноценно вынесению кода из компонента.

Помимо зависимостей из слоя данных, в контроллере могут быть внедрены зависимости управляющие визуализацией (например: открытие диалогов, роутер) и помогающие с преобразованием данных (например: FormBuilder).

@Injectable()export class HeroController implements OnDestroy {  private heroSubscription: Subscription;    heroForm = this.fb.group({    id: [],    name: ['', Validators.required],    power: ['', Validators.required]  });  constructor(private heroState: HeroState, private route: ActivatedRoute, private fb: FormBuilder) { }  save() {    this.heroState.save(this.heroForm.value).subscribe();  }  initialize() {    this.route.paramMap.pipe(      map(params => params.get('id')),      switchMap(id => this.heroState.load(id)),    ).subscribe();    this.heroSubscription = this.heroState.selectHero().subscribe(hero => this.heroForm.reset(hero));  }    ngOnDestroy() {    this.heroSubscription.unsubscribe();  }}

Слой представления

Функцией слоя представления является визуализация и связывание событий с их обработчиками. И то, и другое происходит в шаблоне компонента. При этом класс будет содержать только код, внедряющий зависимости уровня управления. Простые компоненты (в том числе из внешних библиотек), не использующие внедрение, будут относиться к дополнительным зависимостям. Они получают данные через Input поля и делегируют события через Output.

@Component({  selector: 'hero',  template: `    <hero-form [form]="heroController.heroForm"></hero-form>    <button (click)="heroController.save()">Save</button>  `,  providers: [HeroController]})export class HeroComponent {  constructor(public heroController: HeroController) {    this.heroController.initialize();  }}

Повторное использование кода

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

В демо-приложении я постарался уместить различные методы их использования. Возможно, здесь они немного избыточны, но будут полезны в реальных задачах.

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

Для начала опишем абстрактный класс для слоя данных, который будет использован в сервисах слоя управления. Его конкретная реализация будет указываться через useExisting провайдер.

export abstract class EntityState<T> {    abstract get entities$(): Observable<T[]>; // список сущностей    abstract get selectedId$(): Observable<string>; // id выбранного элемента    abstract get selected$(): Observable<T>; // выбранный элемент    abstract select(id: string); // выбрать элемент с указанным id    abstract load(): Observable<T[]> // загрузить список    abstract save(entity: T): Observable<T>; // сохранить сущность}

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

@Injectable()export class EntityCardController {    isSelected$ = this.entityState.selectedId$.pipe(map(id => id !== null));    constructor(private entityState: EntityState<any>, private snackBar: MatSnackBar) {    }    save(form: FormGroup) {        this.entityState.save(form.value).subscribe({            next: () => this.snackBar.open('Saved successfully', null, { duration: 2000 }),            error: () => this.snackBar.open('Error occurred while saving', null, { duration: 2000 })        })    }}

В самом компоненте используем еще один способ внедрения зависимости через директиву @ContentChild.

@Component({    selector: 'entity-card',    template: `        <mat-card>            <ng-container *ngIf="entityCardController.isSelected$ | async; else notSelected">                <mat-card-title>                    <ng-content select=".header"></ng-content>                </mat-card-title>                <mat-card-content>                    <ng-content></ng-content>                </mat-card-content>                <mat-card-actions>                    <button mat-button (click)="entityCardController.save(entityFormController.entityForm)">SAVE</button>                </mat-card-actions>            </ng-container>            <ng-template #notSelected>Select Item</ng-template>        </mat-card>    `,    providers: [EntityCardController]})export class EntityCardComponent {    @ContentChild(EntityFormController) entityFormController: EntityFormController<any>;    constructor(public entityCardController: EntityCardController) {        this.entityCardController.initialize();    }}

Для того чтобы это было возможно, необходимо в провайдерах компонента, который проецируется в entity-card, указать реализацию EntityFormController:

providers: [{ provide: EntityFormController, useClass: HeroFormController }]

Шаблон компонента, использующего эту карточку, будет выглядеть следующим образом:

<entity-card><hero-form></hero-form></entity-card>

Осталось разобраться со списком: сущности содержат разные поля, так что преобразование данных отличается. Клик на элемент списка вызывает одну и ту же команду из слоя данных. Опишем базовый класс контроллера, содержащий общий код.

export interface Entity {    value: string;    label: string;}@Injectable()export abstract class EntityListController<T> {    constructor(protected entityState: EntityState<T>) {}    select(value: string) {        this.entityState.select(value);    }    selected$ = this.entityState.selectedId$;    abstract get entityList$(): Observable<Entity[]>;}

Для уточнения преобразования конкретной модели данных к отображаемому виду теперь достаточно объявить наследника и переопределить абстрактное свойство.

@Injectable()export class FilmsListController extends EntityListController<Film> {    entityList$ = this.entityState.entities$.pipe(        map(films => films.map(f => ({ value: f.id, label: f.title })))    )}

Компонент списка использует этот сервис, однако его реализация будет предоставлена внешним компонентом.

@Component({    selector: 'entity-list',    template: `        <mat-selection-list [multiple]="false"                             (selectionChange)="entityListController.select($event.options[0].value)">            <mat-list-option *ngFor="let item of entityListController.entityList$ | async"                             [selected]="item.value === (entityListController.selected$ | async)"                             [value]="item.value">                {{ item.label }}            </mat-list-option>        </mat-selection-list>    `})export class EntityListComponent {    constructor(public entityListController: EntityListController<any>) {}}

Компонент, являющийся абстракцией всей вкладки, включает список сущностей и проецирует содержимое с формой.

@Component({    selector: 'entity-page',    template: `        <mat-sidenav-container>            <mat-sidenav opened mode="side">                <entity-list></entity-list>            </mat-sidenav>            <ng-content></ng-content>        </mat-sidenav-container>    `,})export class EntityPageComponent {}

Использование компонента entity-page:

@Component({    selector: 'film-page',    template: `        <entity-page>            <entity-card>                <span class="header">Film</span>                <film-form></film-form>            </entity-card>        </entity-page>    `,    providers: [        { provide: EntityState, useExisting: FilmsState },        { provide: EntityListController, useClass: FilmsListController }    ]})export class FilmPageComponent {}

Компонент entity-card передается через проекцию содержимого для возможности использования ContentChild.

Послесловие

Описанный подход позволил мне значительно упростить процесс проектирования и ускорить разработку без ущерба качеству и читаемости кода. Он отлично масштабируется к реальным задачам. В примерах были продемонстрированы лишь базовые техники переиспользования. Их комбинация с такими фичами как multi-провайдеры и модификаторы доступа (Optional, Self, SkipSelf, Host) позволяет гибко выделять абстракции в сложных случаях, используя меньше кода, чем обычное переиспользование компонентов.

Подробнее..

Перевод Кастомные операторы RxJS

10.06.2021 18:12:02 | Автор: admin

Библиотека RxJS благодаря широкому выбору операторов по праву считается крайне мощным инструментом в арсенале разработчика. В этом посте я хочу представить вам концепцию кастомных операторов RxJS с примерами реализации.

Библиотека RxJS благодаря широкому выбору операторов по праву считается крайне мощным инструментом в арсенале разработчика. Недавно я подготовил несколько самописных операторов, чтобы повысить удобство повторного использования некоторых комбинаций операторов. В этом посте я хочу представить вам концепцию кастомных операторов RxJS с примерами реализации.

Оператор идентификации

Оператор RxJS это всего лишь функция, которая берет некие наблюдаемые (observable) данные в качестве входных и возвращает результирующий поток. Следовательно, задача написания кастомного оператора RxJS сводится к написанию обычной функции JavaScript (TypeScript). Начнем с базового оператора идентификации (identity), который просто зеркалирует наблюдаемые исходные данные:

import { interval, Observable } from "rxjs";import { take } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function identity<T>(source$: Observable<T>): Observable<T> {  return source$;}const results$ = source$.pipe(identity);results$.subscribe(console.log);  // console output: 0, 1, 2

Далее напишем кастомный оператор с кое-какой элементарной логикой.

Оператор логирования

Следующий кастомный оператор выполняет побочное действие (логирует значения в консоли) для каждого значения исходного потока:

<>Copyimport { interval, Observable } from "rxjs";import { take, tap } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function log<T>(source$: Observable<T>): Observable<T> {  return source$.pipe(tap(v => console.log(`log: ${v}`)));}const results$ = source$.pipe(log);results$.subscribe(console.log);  // console output: log: 0, log: 1, log: 2

В основе результирующего потока лежат данные source$, которые видоизменяются посредством применения встроенных операторов в составе метода pipe.

Фабрика оператора

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

import { interval, Observable } from "rxjs";import { take, tap } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function logWithTag<T>(tag: string): (source$: Observable<T>) => Observable<T> {  return source$ =>    source$.pipe(tap(v => console.log(`logWithTag(${tag}): ${v}`)));}const results$ = source$.pipe(logWithTag("RxJS"));results$.subscribe(console.log);  // console output: logWithTag(RxJS): 0, logWithTag(RxJS): 1, logWithTag(RxJS): 2

Описание возвращаемого типа можно упростить, воспользовавшись функцией MonoTypeOperatorFunction библиотекиRxJS. Кроме того, с помощью статической функции pipe можно сократить определение оператора:

import { interval, MonoTypeOperatorFunction, pipe } from "rxjs";import { take, tap } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function logWithTag<T>(tag: string): MonoTypeOperatorFunction<T> {  return pipe(tap(v => console.log(`logWithTag(${tag}): ${v}`)));}const results$ = source$.pipe(logWithTag("RxJS"));results$.subscribe(console.log);  // console output: logWithTag(RxJS): 0, logWithTag(RxJS): 1, logWithTag(RxJS): 2

Другие полезные советы по RxJS можно почитать здесь.

Уникальная для наблюдателя лексическая область видимости

Функция фабрики оператора вызывается лишь один раз в момент определения потока. В результате у всех наблюдателей будет общая лексическая область видимости:

import { interval, MonoTypeOperatorFunction, pipe } from "rxjs";import { take, tap } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function tapOnce<T>(job: Function): MonoTypeOperatorFunction<T> {  let isFirst = true;  return pipe(    tap(v => {      if (!isFirst) {        return;      }      job(v);      isFirst = false;    })  );}const results$ = source$.pipe(tapOnce(() => console.log("First value emitted")));results$.subscribe(console.log);results$.subscribe(console.log);  // console output: First value emitted, 0, 0, 1, 1, 2, 2

Чтобы у каждого наблюдателя была уникальная лексическая область видимости, можно применить функцию defer:

import { defer, interval, MonoTypeOperatorFunction } from "rxjs";import { take, tap } from "rxjs/operators";const source$ = interval(1000).pipe(take(3));function tapOnceUnique<T>(job: Function): MonoTypeOperatorFunction<T> {  return source$ =>    defer(() => {      let isFirst = true;      return source$.pipe(        tap(v => {          if (!isFirst) {            return;          }          job(v);          isFirst = false;        })      );    });}const results$ = source$.pipe(tapOnceUnique(() => console.log("First value emitted")));results$.subscribe(console.log);results$.subscribe(console.log);  // console output: First value emitted, 0, First value emitted, 0, 1, 1, 2, 2

Другой способ решения задачи tapOnce рассматривается в одном из моих предыдущих постов.

Практические примеры

Оператор firstTruthy:

import { MonoTypeOperatorFunction, of, pipe } from "rxjs";import { first } from "rxjs/operators";const source1$ = of(0, "", "foo", 69);function firstTruthy<T>(): MonoTypeOperatorFunction<T> {  return pipe(first(v => Boolean(v)));}const result1$ = source1$.pipe(firstTruthy());result1$.subscribe(console.log);// console output: foo

Оператор evenMultiplied:

import { interval, MonoTypeOperatorFunction, pipe } from "rxjs";import { filter, map, take } from "rxjs/operators";const source2$ = interval(10).pipe(take(3));function evenMultiplied(multiplier: number): MonoTypeOperatorFunction<number> {  return pipe(    filter(v => v % 2 === 0),    map(v => v * multiplier)  );}const result2$ = source2$.pipe(evenMultiplied(3));result2$.subscribe(console.log);  // console output: 0, 6

Оператор liveSearch:

import { ObservableInput, of, OperatorFunction, pipe  } from "rxjs";import { debounceTime, delay, distinctUntilChanged, switchMap } from "rxjs/operators";const source3$ = of("politics", "sport");type DataProducer<T> = (q: string) => ObservableInput<T>;function liveSearch<R>(  time: number,  dataProducer: DataProducer<R>): OperatorFunction<string, R> {  return pipe(    debounceTime(time),    distinctUntilChanged(),    switchMap(dataProducer)  );}const newsProducer = (q: string) =>  of(`Data fetched for ${q}`).pipe(delay(2000));const result3$ = source3$.pipe(liveSearch(500, newsProducer));result3$.subscribe(console.log);  // console output: Data fetched for sport

Заключение

Типовые комбинации операторов RxJS можно вынести в кастомные операторы и многократно использовать их в будущем при реализации аналогичного функционала. Применение дженериков обеспечивает корректное приведение типов выходных значений, обрабатываемых в дальнейшей pipe-последовательности.

Живой пример: [смотрите в оригинале]

Надеюсь, вам понравился мой пост и вы узнали что-то новое.


Перевод материала подготовлен в рамках курса "JavaScript Developer. Professional". Если вам интересно узнать о курсе подробнее, приглашаем на день открытых дверей онлайн, где преподаватель расскажет о формате обучения и программе.

Подробнее..

Учим HostBinding работать с Observable

26.02.2021 18:20:16 | Автор: admin

Как и многие другие Angular-разработчики, я мирился с одним ограничением. Если мы хотим использовать Observable в шаблоне, мы можем взять знакомый всем async пайп:

<button [disabled]=isLoading$ | async>

Но его нельзя применить к @HostBinding. Давным-давно это было возможно по ошибке, но это быстро исправили:

@Directive({  selector: 'button[my-button]'  host: {    '[disabled]': '(isLoading$ | async)'  }})export class MyButtonDirective {

Все потому, что хост байндинг относится к родительскому view и в нем этот пайп может быть не подключен. Это довольно желанная фича. Давайте посмотрим, как мы можем ее реализовать, пока нет официального решения.

Как работает асинхронный байндинг?

Байндинг работает с обычными данными. Когда у нас есть Observable на этом этапе необходимо покинуть реактивный мир. Нам нужно подписаться на поток, запускать проверку изменения по каждому значению и отписаться, когда поток больше не нужен. Примерно это за нас и делает async пайп. И это то, что ложится на наши плечи, когда мы хотим забайндить какие-то реактивные данные на хост.

Зачем это может понадобиться?

Мы часто работаем с RxJS в Angular. Большинство наших сервисов построены на Observable-модели. Вот пара примеров, где возможность завязываться на реактивные данные в @HostBinding была бы полезна:

  • Перевод атрибутов на другой язык. Если мы хотим сделать динамическое переключение языка в приложении мы будем использовать Observable. При этом обновлять ARIA-атрибуты, title или alt для изображений довольно непросто.

  • Изменение класса или стилей. Observable-сервис может управлять размером или трансформацией через изменение стилей хоста. Или, например, мы можем использовать реактивный IntersectionObserver для применения класса к sticky-шапке в таблице:

  • Изменение полей и атрибутов. Иногда мы хотим завязаться на BreakpointObserver для обновления placeholder или на сервис загрузки данных для выставления disable на кнопке.

  • Произвольные строковые данные, хранимые в data-атрибутах. В моей практике для них тоже иногда используются Observable-сервисы.

В Taiga UI библиотеке, над которой я работаю, есть несколько инструментов, чтобы сделать этот процесс максимально декларативным:

import {TuiDestroyService, watch} from '@taiga-ui/cdk';import {Language, TUI_LANGUAGE} from '@taiga-ui/i18n';import {Observable} from 'rxjs';import {map, takeUntil} from 'rxjs/operators';@Component({   selector: 'my-comp',   template: '',   providers: [TuiDestroyService],})export class MyComponent {   @HostBinding('attr.aria-label')   label = '';   constructor(       @Inject(TUI_LANGUAGE) language$: Observable<Language>,       @Inject(TuiDestroyService) destroy$: Observable<void>,       @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef,   ) {       language$.pipe(           map(getTranslation('label')),           watch(changeDetectorRef),           takeUntil(destroy$),       ).subscribe();   }}

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

@HostBinding('attr.aria-label')readonly label$ = this.translations.get$('label');

Это потребует серьезных доработок со стороны команды Angular. Но мы можем использовать хитрый трюк в рамках публичного API, чтобы заставить это работать!

Event-плагины спешат на помощь!

Мы не можем добавить свою логику к байндингу на хост. Но мы можем сделать это для @HostListener! Я уже писал статью на эту тему. Прочитайте ее, если хотите узнать, как добавить декларативные preventDefault/stopPropagation и оптимизировать циклы проверки изменений. Если кратко Angular позволяет добавлять свои сервисы для обработки событий. Подходящий сервис выбирается с помощью имени события. Давайте перепишем код следующим образом:

@HostBinding('$.aria-label.attr')@HostListener('$.aria-label.attr')readonly label$ = this.translations.get$('label');

Выглядит странно пытаться решить задачу @HostBinding через @HostListener но читайте дальше и вы всё увидите.

Мы будем использовать $ в качестве индикатора в имени события. Модификатор .attr добавим в конец, а не в начало. Иначе регулярное выражение в Angular решит, что мы байндим строковый атрибут.

У плагинов для обработки событий есть доступ к элементу, имени события и функции-обработчику. Последний аргумент для нас бесполезен, так как это обертка, созданная компилятором. Так что нам нужно как-то передать наш Observable через элемент. Вот тут-то нам и пригодится @HostBinding. Мы положим Observable в поле с тем же именем, и тогда у нас будет доступ к нему внутри плагина:

addEventListener(element: HTMLElement, event: string): Function {   element[event] = EMPTY;   const method = this.getMethod(element, event);   const sub = this.manager       .getZone()       .onStable.pipe(           take(1),           switchMap(() => element[event]),       )       .subscribe(method);   return () => sub.unsubscribe();}

Компилятор Angular

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

Возможно, вы видели такое раньшеВозможно, вы видели такое раньше

Плагины хороши тем, что подписка на события происходит раньше разрешения байндингов. Благодаря первой строке Angular считает, что у элемента присутствует это свойство. Дальше нам нужно убедиться, что Observable уже на месте ведь на момент подписки его еще нет. Хорошо, что у нас есть доступ до NgZone и мы можем дождаться ее стабилизации, прежде чем запросить свойство элемента.

NgZone испускает onStable когда не осталось больше микро- и макрозадач в очереди. Для нас это означает, что Angular завершил цикл проверки изменений и все байндинги обновлены.

А отписку за нас сделает сам Angular достаточно вернуть функцию, прерывающую стрим.

Этого хватит, чтобы код заработал в JIT, AOT же более щепетилен. Мы добавили несуществующее поле во время выполнения, но AOT желает знать про него на этапе компиляции. До тех пор, пока эта задача не будет закрыта, мы не можем создавать свои списки разрешенных полей. Поэтому нам придется добавить NO_ERRORS_SCHEMA в модуль с подобным байндингом. Это может звучать страшно, но все, что эта схема делает, перестает проверять, есть ли поле у элемента при байндинге. Кроме того, если у вас WebStorm, вы продолжите видеть предупреждение:

Это сообщение не мешает сборкеЭто сообщение не мешает сборке

Также AOT требует реализации Callable-интерфейса для использования @HostListener. Мы можем имитировать его с помощью простой функции, сохранив оригинальный тип:

function asCallable<T>(a: T): T & Function {    return a as any;}

Итоговая запись:

@HostBinding('$.aria-label.attr')@HostListener('$.aria-label.attr')readonly label$ = asCallable(this.translations.get$('label'));

Другой вариант вовсе отказаться от @HostBinding ведь нам надо назначить его лишь один раз. Если ваш стрим приходит из DI, что происходит довольно часто, можно создать FactoryProvider. В него можно передать ElementRef и назначить поле в нем:

export const TOKEN = new InjectionToken<Observable<boolean>>("");export const PROVIDER = {  provide: TOKEN,  deps: [ElementRef, IntersectionObserverService],  useFactory: factory,}export function factory(  { nativeElement }: ElementRef,  entries$: Observable<IntersectionObserverEntry[]>): Observable<boolean> {  return nativeElement["$.class.stuck"] = entries$.pipe(map(isIntersecting));}

Теперь достаточно будет оставить только @HostListener. Его даже можно написать прямо в декораторе класса:

@Directive({  selector: "table[sticky]",  providers: [    IntersectionObserverService,    PROVIDER,  ],  host: {    "($.class.stuck)": "stuck$"  }})export class StickyDirective {  constructor(@Inject(TOKEN) readonly stuck$: Observable<boolean>) {}}

Приведенный выше пример можно увидеть вживую на StackBlitz. В нем IntersectionObserver используется для задания тени на sticky-шапке таблицы:

Обновление полей

В коде вы видели вызов getMethod. Байндинг в Angular работает не только на атрибутах и полях, но и на классах и стилях. Нам тоже нужно реализовать такую возможность. Для этого разберем имя нашего псевдособытия, чтобы понять, что же делать со значениями из потока:

private getMethod(element: HTMLElement, event: string): Function {   const [, key, value, unit = ''] = event.split('.');   if (event.endsWith('.attr')) {       return v => element.setAttribute(key, String(v));   }   if (key === 'class') {       return v => element.classList.toggle(value, !!v);   }   if (key === 'style') {       return v => element.style.setProperty(value, `${v}${unit}`);   }   return v => (element[key] = v);}

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

{    provide: EVENT_MANAGER_PLUGINS,    useClass: BindEventPlugin,    multi: true,}

Это небольшое дополнение способно существенно упростить ваш код. Нам больше не надо беспокоиться о подписке. Описанный плагин доступен в новой версии 2.1.1 нашей библиотеки @tinkoff/ng-event-plugins, а также в @taiga-ui/cdk. Поиграться с кодом можно на StackBlitz. Надеюсь, этот материал будет для вас полезным!

Подробнее..

Обмен данными между компонентами Angular

24.04.2021 16:07:19 | Автор: admin

Проблема

В результате работы с фреймворком Angular, мы декомпозируем наше web-приложение. И по этому у нас возникает ситуация, когда нам нужно передавать данные между компонентами.

@Input()

Что бы передать данные в дочерний компонент, мы можем использовать декоратор @Input(). Он позволит нам передать данные из родительского компонента в дочерний. Рассмотрим простой пример:

import { Input, Component} from '@angular/core';      @Component({    selector: 'app-child',    template: `<h1>Title: {{ title }}</h1>`})export class ChildComponent {     @Input() title: string;}

В дочернем компоненте мы мы "задекорировали" нужное нам свойство title. Не забываем импортировать декоратор:

import { Input} from '@angular/core';

Осталось только передать параметр title в дочерний компонент из родительского:

import { Component } from '@angular/core';      @Component({    selector: 'app-component',    template: `<app-child [title]="title" [userAge]="age"></app-child>`})export class AppComponent {     public title = 'Hello world!';}

Параметры из класса мы передаем с помощью квадратных скобок [title]="title", простую строку мы можем передать и без использования квадратных скобок title="Hello world". Мы научились передавать параметры из родительского в дочерний, но что если нам надо сделать все наоборот?

@Output()

Благодаря директиве @Output() мы можем привязаться к событиям дочернего компонента. На первый взгляд не очень понятно, так что давайте рассмотрим пример:

import { Component } from '@angular/core';       @Component({    selector: 'app-counter',    template: `<h1>Count: {{ count }}</h1>              <app-add (buttonClick)="onAdd()"></app-add>`})export class AppCounter {     public count = 0;public onAdd(): void {    this.count++;}}
import { Component, EventEmitter, Output } from '@angular/core';@Component({    selector: 'app-add',    template: `<button (click)="add()"></button>`;})export class AppAdd {     @Output() buttonClick = new EventEmitter();public add(): void {    this.buttonClick.emit();}}

Думаю данный код требует некоторых объяснений. При клике на кнопку в компоненте AppAdd срабатывает событие click, которое вызывает функцию add(). Код this.buttonClick.emit() вызовет событие buttonClick в компоненте AppCounter. Очень важно правильно импортировать EventEmitter:

import { EventEmitter } from '@angular/core';

Но есть одно "но", мы не передали никакую информацию в родительский компонент. Рассмотрим уже другой вариант в котором мы будем передавать информацию в родительский компонент:

import { Component } from '@angular/core';@Component({    selector: 'app-better-counter',    template: `<h1>Count: {{ count }}</h1><app-buttons (buttonClick)="onChange($event)"></app-buttons>`})export class BetterCounterComponent {     public count = 0;public onChange(isAdd: boolean): void {      if (isAdd) {        this.count++;      } else {          this.count--;      } }}
import { Component, EventEmitter, Output } from '@angular/core';@Component({    selector: 'app-buttons',    template: `<button (click)="change(true)"></button>                 <button (click)="change(false)"></button>`})export class ButtonsComponent {     @Output() buttonClick = new EventEmitter<boolean>();public change(change: boolean): void {    this.buttonClick.emit(change);}}

Давайте рассмотрим список внесенных изменений:

  • Добавили тип передаваемых данных new EventEmitter<boolean>()

  • В метод emit передали нужную информацию this.buttonClick.emit(change)

  • Принимаем данные как $event в родительском компоненте (buttonClick)="onChange($event)"

@Input() и @Output() достаточно удобно, но не в ситуации, когда на надо передать данные в дочерний компонент, дочернего компонента и т.д., или же компоненты находятся в разных частях приложения.

Сервисы и RxJs

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

import { Injectable } from '@angular/core';import { Subject } from 'rxjs';@Injectable({  providedIn: 'root',})export class SimpleService {    public count$ = new Subject<number>();public changeCount(count: number) {   this.count$.next(count);   }}

Наш сервис готов. В нём мы создадим переменную count$. Знак доллара - это договорённость между программистами в обозначениях потоков. Теперь простыми словами про Subject. Subject - это труба, по которой мы можем передавать данные. Данные получают компоненты, которые оформили подписку на Subject. Давайте посмотрим, как изменять count из компонента:

import { SimpleService } from './services/simple.service.ts';@Component({    selector: 'app-any',    template: ``})export class AnyComponentComponent {     constructor(          private readonly simpleService: SimpleService    ) {}    public setAnyCount(): void {      this.simpleService.changeCount(Math.random());}}

Мы передали результат Math.random() и пустили его по всем подписчикам. Теперь посмотрим как следить за этими изменениями:

import { Component, OnInit } from '@angular/core';import { SimpleService } from './services/simple.service.ts';@Component({    selector: 'app-other',    template: ``})export class OtherComponentComponent implements OnInit {     constructor(       private readonly simpleService: SimpleService    ) {}      ngOnInit(): void {      this.simpleService.count$.subscribe((count) => this.log(count));    }    private log(data: number): void {  console.log(data);    }}

На инициализации мы подписываемся на изменения count, и при каждом вызове count$.next(...) где-либо сработает функция которую мы передали в subscribe. Единственная проблема которая осталась в коде - утечка памяти. При переходе между страницами нашего приложения, компонент будет дестроится, а когда он нам снова понадобится произойдёт повторная инициализация. Старая подписка не пропала, а новые с каждым разом будут только добавляться. Функция log() будет запускаться столько раз, сколько у нас есть подписок. Если бы мы имели там какой-нибудь сложный функционал, то пользовать приложения заметил бы снижение производительности. Этого можно избежать, отписавшись от count$ на OnDestroy. Для этого вынесем подписку в переменную и вызовем у неё метод unsubscribe():

import { Component, OnInit, OnDestroy } from '@angular/core';import { SimpleService } from './services/simple.service.ts';import { Subsription } from 'rxjs';@Component({    selector: 'app-other',    template: ``})export class OtherComponentComponent implements OnInit, OnDestroy {   private subs: Subsription;    constructor(      private readonly simpleService: SimpleService    ) {}    ngOnInit(): void {      this.subs = this.simpleService.count$.subscribe((count) => this.log(count));    }    ngOnDestroy(): void {  this.subs.unsubscribe();}    private log(data: number): void {  console.log(data);    }}

Мы можем подписаться на множество Subject из компонента, подписаться на один и тот же Subject из разных компонентов.

Итог

Мы можем обмениваться данными между компонентов с помощью @Input(), @Output(), а также RxJs. В данной статье я опустил store, так как статья рассчитана на новичков. Советую попрактиковаться в данной теме, что бы улучшить свои навыки.

Подробнее..

Категории

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

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru