Улучшаем производительность с RxJS
Когда наконец дописал свою вторую статью на Хабр
Всем привет! Сегодня я хочу поделиться приемами улучшения производительности фронтенда путем оптимизации RxJS стримов. Поскольку я ангуларщик, буду приводить примеры для фреймворка Angular, однако по сути данные приемы не зависят от конкретной технологии и могут пригодиться везде, где применяется реактивное программирование. Ну все, погнали!
Разбираемся с форменным безобразием
Сначала посмотрим на классический пример оптимизации изменений формы. Да простят меня опытные фронтендеры, но без этого примера статья была бы неполной.
Допустим, у нас есть имя в форме и по мере того, как пользователь вводит данные, мы должны проверить, существует ли такое имя или нет. Если существует — выводим одну табличку, если нет — другую.
userForm = this.fb.group({
Name: [''],
});
nameChanges$ = this.userForm.controls.name.valueChanges.pipe(
debounceTime(500), // ждем 500ms не будет ли юзер вводить чего то еще
distinctUntilChanged(), // как закончил ввод - проверяем не совпадает ли имя с предыдущим
);
nameExists$ = this.nameChanges$.pipe(switchMap((name) => this.api.checkIfNameExists(name))
);
This name already exists
This name is unique
В данном примерe функция checkIfNameExists посылает запрос на сервер чтобы проверить, существует ли имя и возвращает Observable
Примечание 1: в Angular для подобной задачи существуют асинхронные валидаторы , но для упрощения примера мы не будем их использовать.
Примечание 2: Я привожу пример для формы, но подобный подход будет работать для любого стрима, который достаточно быстро отсылает данные.
Сначала изменись, потом приходи
В прошлом примере мы уже использовали distinctUntilChanged, но этот оператор используется для оптимизации гораздо чаще и заслуживает отдельного рассмотрения. Почти любой стрим с данными в приложении может быть оптимизирован, если фильтровать значения, которые повторяются.
Для примера предположим, что к нам приходят изменения состояния пользователя через веб сокет webSocketService.getUser. У пользователя есть имя, дата рождения, адрес и другие данные. Но в конкретном компоненте нам нужно вывести только его имя.
user$ = this.webSocketService.getUser();
userName$ = this.user$.pipe(
map((user) => user.name),
distinctUntilChanged()
);
Hello, {{ userName$ | async }}!
distinctUntilChanged здесь очень важен : если его убрать, данные будут обновляться при любом изменении пользователя, а не только когда изменилось имя.
Примечание: Важно помнить, что distinctUntilChanged работает только с примитивами! Если вам нужно сравнить объекты по содержанию — следует передавать функцию для сравнения. Например, если нужно сравнить по id:
distinctUntilChanged((a, b) => a.id === b.id)
Ну ладно, только один раз…
Следующий оператор используется тогда, когда нужно получить первое значение из «горячего» стрима. После этого стрим нас больше не будет интересовать, нам не важно следить за его обновлениями. Следовательно, зачем тратить ресурсы на подписку? Используем take (1) или first () для того, чтобы взять первый элемент и отписаться сразу после получения.
Примечание: Отличие take (1) и first () в том, что first () выдаст ошибку, если стрим завершится так и не выдав никакого значения.
Для этого примера давайте договоримся, что мы используем для хранения данных NgRx. Например, мы хотим поприветствовать текущего юзера в хедере. Но нам не важно как дальше объект юзера будет изменяться. Для этого сначала получим его из NgRx стора, а потом выведем сообщение приветствия:
headerMessage$ = this.store
.select(UsersSelector.UserByUuid(currentUserUuid))
.pipe(
take(1),
map((user) => `Hi ${user.username}!`)
);
Примечание: «холодный» стрим сам завершится после выдачи первого значения, для него данные операторы избыточны.
Хватит донимать сервер
Последний на сегодня пример также как и первый поможет избежать лишних запросов к серверу (на этот раз мы не пользуемся NgRx стором, а получаем данные сразу с HTTP клиента). Предположим что у нас есть метод getSubsCount, который возвращает количество игроков через HTTP запрос:
playersCount$: Observable = this.api.getPlayersCount();
Далее нам нужно показать подписки, но не в одном месте, а в нескольких местах на странице:
{{ playersCount$ | async }}
При каждой подписке через subscribe или async на сервер будет посылаться еще один запрос. Один из способов избежать этого — добавить shareReplay (1):
playersCount$: Observable = this.api.getPlayersCount().pipe(
shareReplay(1)
);
Теперь запрос отправиться только один раз и каждый подписчик получит одни и те же данные из стрима.
Примечание: «расшаренный» подобным образом стрим никогда не изменится, поэтому нужно позаботиться об его обновлении самостоятельно. Например, можно использовать Subject:
update$ = new Subject();
playersCount$: Observable = this.update$.pipe(
startWith(null),
switchMap(() => this.api.getPlayersCount()),
shareReplay(1)
);
В этом примере первый запрос отправится когда сработает startWith (null), а все последующие обновления придется делать вручную, вызывая this.update$.next (). Такой механизм хорошо работает если playersCount$ находится в глобальном сервисе.
Если же playersCount$ находится в компоненте, можно обнулять данные каждый раз, когда компонент уничтожается (пользователь уходит на другую страницу или закрывает модальное окно).
Для этого завершим стрим при помощи takeUntilDestroyed ():
playersCount$: Observable = this.api.getPlayersCount().pipe(
takeUntilDestroyed(),
shareReplay(1)
);
Или доверимся async pipe, которая автоматически отпишется от стрима при уничтожении (в таком случае нужно передавать refCount: true чтобы стрим завершился при отписке последнего подписчика):
playersCount$: Observable = this.api.getPlayersCount().pipe(
shareReplay({bufferSize: 1, refCount: true})
);
Важно! Если стрим не завершить при уничтожении компонента — это приведет к утечке памяти.
Заключение
Спасибо за внимание, всем быстрых и красивых стримов и да прибудет с вами сила RxJS!