Pull to refresh

Улучшаем производительность с RxJS

Level of difficultyMedium
Reading time5 min
Views6.5K
Когда наконец дописал свою вторую статью на Хабр
Когда наконец дописал свою вторую статью на Хабр

Всем привет! Сегодня я хочу поделиться приемами улучшения производительности фронтенда путем оптимизации 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))
);
<form [formGroup]="userForm">
 <input type="text" formControlName="Name" />
</form>

<ng-container *ngIf="nameExists$ | async; else nameUnique">This name already exists</ng-container>

<ng-template #nameUnique>This name is unique</ng-template>

В данном примерe функция checkIfNameExists посылает запрос на сервер чтобы проверить, существует ли имя и возвращает Observable<boolean>. Мы “слушаем” изменения инпута name и пропускаем их только тогда, когда активный набор букв завершен с помощью debounceTime (нет изменений в течении 500ms). Также мы дополнительно проверяем, что имя не совпадает с предыдущем проверенным, используя distinctUntilChanged (если набрать букву и сразу ее удалить - запрос не отправится). То есть для оптимизации используется связка debounceTime + distinctUntilChanged. Если убрать оба эти оператора, на сервер будет отсылаться большое количество запросов - каждый раз когда пользователь введет новую букву. Это сильно перегрузит сервер ненужными запросами. Не повторяйте это дома! :)

Примечание 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),
       takeUntil(this.onDestroy$),
       map((user) => `Hi ${user.username}!`)
   );
<header>
 <div class="header-message">{{ headerMessage$ | async }}</div>
</header>

Хватит донимать сервер

Последний на сегодня пример также как и первый поможет избежать лишних запросов к серверу  ( на этот раз мы не пользуемся NgRx стором, а получаем данные сразу с HTTP клиента ). Предположим что у нас есть метод getSubsCount, который возвращает количество игроков через HTTP запрос:

playersCount$: Observable<number> = this.api.getPlayersCount();

Далее нам нужно показать подписки, но не в одном месте, а в нескольких местах на странице:

{{ playersCount$ | async }}

При каждой подписке через subscribe или async на сервер будет посылаться еще один запрос. Один из способов избежать этого - добавить shareReplay(1):

playersCount$: Observable<number> = this.api.getPlayersCount().pipe(
 shareReplay(1)
);

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

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

update$ = new Subject<void>();
playersCount$: Observable<number> = this.update$.pipe(
   startWith(null),
   switchMap(() => this.api.getPlayersCount()),
   shareReplay(1)
);

В этом примере первый запрос отправится когда сработает startWith(null), а все последующие обновления придется делать вручную, вызывая this.update$.next(). Такой механизм хорошо работает если playersCount$ находится в глобальном сервисе.

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

Для этого завершим стрим при помощи takeUntil(this.onDestroy$):

playersCount$: Observable<number> = this.api.getPlayersCount().pipe(
 takeUntil(this.onDestroy$),  // также можно использовать takeUntilDestroyed
 shareReplay(1)
);

Или доверимся async pipe, которая автоматически отпишется от стрима при уничтожении ( в таком случае нужно передавать refCount: true чтобы стрим завершился при отписке последнего подписчика ):

playersCount$: Observable<number> = this.api.getPlayersCount().pipe(
 shareReplay({bufferSize: 1, refCount: true})
);

Важно! Если стрим не завершить при уничтожении компонента - это приведет к утечке памяти.

Заключение

Спасибо за внимание, всем быстрых и красивых стримов и да прибудет с вами сила RxJS!

Tags:
Hubs:
Total votes 4: ↑4 and ↓0+4
Comments18

Articles