Работая с Angular волей-неволей будешь использовать RxJS, ведь он лежит в основе фреймворка. Это очень мощный инструмент для обработки событий и не только. Однако далеко не каждый проект использует его по полной. Часто это просто запросы на бэк, нехитрые преобразования данных и подписка. Мы с Ромой очень любим RxJS и решили собрать несколько интересных кейсов из нашей практики. Мы сделали из этого что-то вроде челленджа на 20 задачек, которые мы предлагаем решить с помощью RxJS и попрактиковать свои навыки.
Каждая задачка будет иметь некий бойлерплейт, чтобы вам было просто начать. Под спойлером я положу ссылку на свое решение и небольшое пояснение к нему. В целом задачи будут идти от простого к сложному, а полное собрание с ответами и пояснениями на английском доступно на GitHub.
StackBlitz
Решение
Для отслеживания изменения фокуса нам понадобятся события focusin и focusout, поскольку focus/blur не всплывают. Мы будем получать элемент через target и relatedTarget, потому что в момент этих событий мы еще не можем проверить document.activeElement — в коллбэке событий там будет body. Если текущий элемент вне отслеживаемой зоны, будем выдавать null. Так же подобную логику можно вынести в сервис — его потом будет удобно использовать в директивах. Поскольку мы не знаем момент, когда пользователь подпишется на наш стрим, добавим такую конструкцию для получения начального значения при подписке: defer(() => of(documentRef.activeElement)). Остается только собрать все потоки в merge:
@Injectable()
export class FocusWithinService extends Observable<Element | null> {
constructor(
@Inject(DOCUMENT) documentRef: Document,
{ nativeElement }: ElementRef<HTMLElement>
) {
const focusedElement$ = merge(
defer(() => of(documentRef.activeElement)),
fromEvent(nativeElement, "focusin").pipe(map(({ target }) => target)),
fromEvent(nativeElement, "focusout").pipe(
map(({ relatedTarget }) => relatedTarget)
)
).pipe(
map(element =>
element && nativeElement.contains(element) ? element : null
),
distinctUntilChanged(),
);
super(subscriber => focusedElement$.subscribe(subscriber));
}
}
StackBlitz с готовым решением
Решение
Тут ситуация довольно тривиальная. Единственный подвох — начальное значение. Если мы сразу проверим, видима страница или нет, это значение может оказаться неактуальным на момент подписки. Можно использовать defer, как в прошлый раз, а можно начать с произвольного значения, а реальное получать в map ниже:
export const PAGE_VISIBILITY = new InjectionToken<Observable<boolean>>(
"Shared Observable based on `document visibility changed`",
{
factory: () => {
const documentRef = inject(DOCUMENT);
return fromEvent(documentRef, "visibilitychange").pipe(
startWith(0),
map(() => documentRef.visibilityState !== "hidden"),
distinctUntilChanged(),
shareReplay()
);
}
}
);
В качестве бонуса в примере мы превратим этот поток в DI-токен для удобного использования. Этот токен есть в нашей микробиблиотеке токенов на глобальные сущности.
StackBlitz с готовым решением
StackBlitz
Решение
Давайте посмотрим, как мы можем ветвить потоки, на примере данной задачи. В такой ситуации типовое начало будет Subject, который мы будем дергать по нажатию на кнопку и Observable, который перебрасывает эти нажатия на запрос на сервер:
readonly submit$ = new Subject<void>();
readonly request$ = this.submit$.pipe(
switchMapTo(this.service.pipe(startWith(""))),
share(),
);
Теперь давайте разведем запросы на нужные нам потоки. Обратите внимание на share в конце, который поможет нам избежать повторных запросов на сервер при подписках на эти витки.
При ошибке сервис бросит реальную ошибку в поток. Таким образом, имя пользователя будет просто повторной попыткой запроса:
readonly user$ = this.request$.pipe(retry());
Сообщение об ошибке же будет как раз той ошибкой, которую бросит запрос, показанной в течение 5 секунд:
readonly error$ = this.request$.pipe(
ignoreElements(),
catchError(e => of(e)),
repeat(),
switchMap(e => timer(5000).pipe(startWith(e)))
);
Мы игнорируем элементы и показывает только ошибки. На этом примере хорошо видна разница между repeat и retry: первый перезапускает поток, который успешно завершился, второй перезапускает поток, закончившийся ошибкой. Аналогичным образом мы можем отвести поток, отвечающий за блокировку кнопки.
StackBlitz с готовым решением
Попробуем применить похожую технику ветвления стрима для отображения прогресса.
StackBlitz
Решение
Начнем мы, как и в прошлый раз, с Subject и общего потока. Общий поток мы разведем на два — прогресс и результат. Для первого мы используем фильтрацию:
readonly progress$ = this.response$.pipe(filter(Number.isFinite));
А для второго — преобразование, чтобы можно было перезапускать процесс:
readonly result$ = this.response$.pipe(
map(response => typeof response === "string" ? response : null),
distinctUntilChanged()
);
StackBlitz с готовым решением
StackBlitz
Решение
Для обратного отсчета можно сделать простую утилитную функцию, где мы воспользуемся малоизвестным вторым аргументом takeWhile:
function countdownFrom(start: number): Observable<number> {
return timer(0, 1000).pipe(
map(index => start - index),
takeWhile(Boolean, true)
);
}
Благодаря второму аргументу, 0, который нарушит условие, тоже провалится дальше. Сам стрим будет просто использовать switchMapTo от Subject, который мы запускаем по кнопке, как в прошлых примерах. Использовать можно так:
<ng-container *ngIf="countdown$ | async as value else resend">
Resend code in {{ value }} sec.
</ng-container>
<ng-template #resend>
<button (click)="resend$.next()">Resend code</button>
</ng-template>
Заключительный 0 не пройдет ngIf и переключит шаблон назад на кнопку.
CSS-решение будет полагаться на анимацию псевдоэлементов и перезапуск ее с помощью :active состояния нажатой кнопки. Загляните в CSS-файл странички с решением:
StackBlitz с готовым решением
Источник статьи: https://habr.com/ru/company/tinkoff/blog/559346/
Каждая задачка будет иметь некий бойлерплейт, чтобы вам было просто начать. Под спойлером я положу ссылку на свое решение и небольшое пояснение к нему. В целом задачи будут идти от простого к сложному, а полное собрание с ответами и пояснениями на английском доступно на GitHub.
#1. Создайте Observable для отслеживания фокуса на странице
В этой задаче вам предлагается область страницы со всевозможными фокусируемыми элементами внутри. Нужно сделать стрим, который будет отслеживать фокус внутри этой области.StackBlitz
Решение
Для отслеживания изменения фокуса нам понадобятся события focusin и focusout, поскольку focus/blur не всплывают. Мы будем получать элемент через target и relatedTarget, потому что в момент этих событий мы еще не можем проверить document.activeElement — в коллбэке событий там будет body. Если текущий элемент вне отслеживаемой зоны, будем выдавать null. Так же подобную логику можно вынести в сервис — его потом будет удобно использовать в директивах. Поскольку мы не знаем момент, когда пользователь подпишется на наш стрим, добавим такую конструкцию для получения начального значения при подписке: defer(() => of(documentRef.activeElement)). Остается только собрать все потоки в merge:
@Injectable()
export class FocusWithinService extends Observable<Element | null> {
constructor(
@Inject(DOCUMENT) documentRef: Document,
{ nativeElement }: ElementRef<HTMLElement>
) {
const focusedElement$ = merge(
defer(() => of(documentRef.activeElement)),
fromEvent(nativeElement, "focusin").pipe(map(({ target }) => target)),
fromEvent(nativeElement, "focusout").pipe(
map(({ relatedTarget }) => relatedTarget)
)
).pipe(
map(element =>
element && nativeElement.contains(element) ? element : null
),
distinctUntilChanged(),
);
super(subscriber => focusedElement$.subscribe(subscriber));
}
}
StackBlitz с готовым решением
#2. Создайте поток видимости вкладки
Хороший пример работы с событиями в RxJS — использование Page Visibility API. Подобный стрим также удобно завернуть в InjectionToken. Бойлерплейта для этой задачи нетРешение
Тут ситуация довольно тривиальная. Единственный подвох — начальное значение. Если мы сразу проверим, видима страница или нет, это значение может оказаться неактуальным на момент подписки. Можно использовать defer, как в прошлый раз, а можно начать с произвольного значения, а реальное получать в map ниже:
export const PAGE_VISIBILITY = new InjectionToken<Observable<boolean>>(
"Shared Observable based on `document visibility changed`",
{
factory: () => {
const documentRef = inject(DOCUMENT);
return fromEvent(documentRef, "visibilitychange").pipe(
startWith(0),
map(() => documentRef.visibilityState !== "hidden"),
distinctUntilChanged(),
shareReplay()
);
}
}
);
В качестве бонуса в примере мы превратим этот поток в DI-токен для удобного использования. Этот токен есть в нашей микробиблиотеке токенов на глобальные сущности.
StackBlitz с готовым решением
#3. Покажите сообщение об ошибке на 5 секунд
Допустим, у нас есть кнопка логина. При нажатии на нее идет запрос на сервер, на это время кнопка будет заблокирована. При успешном логине мы выведем имя пользователя, в противном случае покажем ошибку на 5 секунд и разблокируем кнопку для повторной попытки. В примере ниже заготовлен муляж сервиса логина:StackBlitz
Решение
Давайте посмотрим, как мы можем ветвить потоки, на примере данной задачи. В такой ситуации типовое начало будет Subject, который мы будем дергать по нажатию на кнопку и Observable, который перебрасывает эти нажатия на запрос на сервер:
readonly submit$ = new Subject<void>();
readonly request$ = this.submit$.pipe(
switchMapTo(this.service.pipe(startWith(""))),
share(),
);
Теперь давайте разведем запросы на нужные нам потоки. Обратите внимание на share в конце, который поможет нам избежать повторных запросов на сервер при подписках на эти витки.
При ошибке сервис бросит реальную ошибку в поток. Таким образом, имя пользователя будет просто повторной попыткой запроса:
readonly user$ = this.request$.pipe(retry());
Сообщение об ошибке же будет как раз той ошибкой, которую бросит запрос, показанной в течение 5 секунд:
readonly error$ = this.request$.pipe(
ignoreElements(),
catchError(e => of(e)),
repeat(),
switchMap(e => timer(5000).pipe(startWith(e)))
);
Мы игнорируем элементы и показывает только ошибки. На этом примере хорошо видна разница между repeat и retry: первый перезапускает поток, который успешно завершился, второй перезапускает поток, закончившийся ошибкой. Аналогичным образом мы можем отвести поток, отвечающий за блокировку кнопки.
StackBlitz с готовым решением
#4. Отобразите состояние загрузки в виде полосы прогресса
Если ваш сервис репортит прогресс загрузки, удобно было бы отобразить это для пользователя. Например, тут я прикручивал RxJS и прогресс к нативному fetch:Попробуем применить похожую технику ветвления стрима для отображения прогресса.
StackBlitz
Решение
Начнем мы, как и в прошлый раз, с Subject и общего потока. Общий поток мы разведем на два — прогресс и результат. Для первого мы используем фильтрацию:
readonly progress$ = this.response$.pipe(filter(Number.isFinite));
А для второго — преобразование, чтобы можно было перезапускать процесс:
readonly result$ = this.response$.pipe(
map(response => typeof response === "string" ? response : null),
distinctUntilChanged()
);
StackBlitz с готовым решением
#5. Сделайте обратный отсчет с перезапуском
Представьте, что вам надо сделать таймер, ведущий отсчет перед повторной отправкой кода. Отличная микрозадача на RxJS. В качестве бонуса в ответе я приведу еще и решение на CSS, которое позволяет форме подтверждения платежа по SMS в Тинькофф работать даже с отключенным JavaScriptStackBlitz
Решение
Для обратного отсчета можно сделать простую утилитную функцию, где мы воспользуемся малоизвестным вторым аргументом takeWhile:
function countdownFrom(start: number): Observable<number> {
return timer(0, 1000).pipe(
map(index => start - index),
takeWhile(Boolean, true)
);
}
Благодаря второму аргументу, 0, который нарушит условие, тоже провалится дальше. Сам стрим будет просто использовать switchMapTo от Subject, который мы запускаем по кнопке, как в прошлых примерах. Использовать можно так:
<ng-container *ngIf="countdown$ | async as value else resend">
Resend code in {{ value }} sec.
</ng-container>
<ng-template #resend>
<button (click)="resend$.next()">Resend code</button>
</ng-template>
Заключительный 0 не пройдет ngIf и переключит шаблон назад на кнопку.
CSS-решение будет полагаться на анимацию псевдоэлементов и перезапуск ее с помощью :active состояния нажатой кнопки. Загляните в CSS-файл странички с решением:
StackBlitz с готовым решением
Заключение
Это была первая неделя нашего челленджа. Впереди вас ждут еще 15 более сложных задач с использованием RxJS. Ссылки будут добавлены ниже по мере публикации.Источник статьи: https://habr.com/ru/company/tinkoff/blog/559346/