Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Работая с Angular волей-неволей будешь использовать RxJS, ведь он лежит в основе фреймворка. Это очень мощный инструмент для обработки событий и не только. Однако далеко не каждый проект использует его по полной. Часто это просто запросы на бэк, нехитрые преобразования данных и подписка. Мы с Ромой очень любим RxJS и решили собрать несколько интересных кейсов из нашей практики. Мы сделали из этого что-то вроде челленджа на 20 задачек, которые мы предлагаем решить с помощью RxJS и попрактиковать свои навыки.
![](https://habrastorage.org/getpro/habr/upload_files/0ba/23c/7f7/0ba23c7f77a1f9b3576e36419540fe06.png)
Каждая задачка будет иметь некий бойлерплейт, чтобы вам было просто начать. Под спойлером я положу ссылку на свое решение и небольшое пояснение к нему. В целом задачи будут идти от простого к сложному, а полное собрание с ответами и пояснениями на английском доступно на 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 в Тинькофф работать даже с отключенным JavaScript
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 с готовым решением
Заключение
Это была первая неделя нашего челленджа. Впереди вас ждут еще 15 более сложных задач с использованием RxJS. Ссылки будут добавлены ниже по мере публикации.