Когда наконец дописал свою вторую статью на Хабр
Когда наконец дописал свою вторую статью на Хабр

Всем привет! Сегодня я хочу поделиться приемами улучшения производительности фронтенда путем оптимизации 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!

Комментарии (12)


  1. 0Ld
    16.11.2023 18:46

    Автору респект! В статье и проблемы, и их решения. Контента по ангуляру меньше, чем хотелось бы. К сожалению...


  1. s1im
    16.11.2023 18:46

    С take(1) тоже надо отписываться в обязательном порядке, так как если в течение жизни компонента ни одного значения не придет — вы все ещё останетесь на него подписаны


    1. DefeNder93 Автор
      16.11.2023 18:46

      Спасибо за комментарий. В контексте статьи я хотел показать что после получения первого значения нужно завершать стрим чтобы не тратить ресурсы на его обработку ( конечно если это подходит по логике ). Но в общем случае вы правы - нужно отписываться. Сделал поправку, добавил takeUntil(this.onDestroy$) в пример с take(1).


  1. sergeivakhrushev
    16.11.2023 18:46

    Примечание: “холодный” стрим сам завершится после выдачи первого значения, для него данные операторы избыточны.

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

    Отличие от горячего, что он начинает подкапотную работу только после подписки на него.


    1. DefeNder93 Автор
      16.11.2023 18:46

      Вы правы, тут я был неосторожен. На проекте мы часто используем стримы выдающие 1 значение, но конечно упоминание "холодности" было некорректно. Убрал это примечание, оно там вообще не нужно.


  1. dmmischenko
    16.11.2023 18:46

    takeUntilDestroyed чисто ангуляровский оператор. Я предлагаю заменить его в статье на takeUntil, тогда не только ангулярщику будет понятно как можно регулировать время жизни подписки и безопасно отписываться.


    1. DefeNder93 Автор
      16.11.2023 18:46

      Согласен, заменил


  1. sergeivakhrushev
    16.11.2023 18:46

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

    takeUntil(this.onDestroy$), // также можно использовать takeUntilDestroyed

    shareReplay(1)

    );

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

    Во-вторых, этот оператор надо добавлять только непосредственно перед подпиской, в пайпе обсервабла он не нужен


    1. DefeNder93 Автор
      16.11.2023 18:46

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

      Это не так, takeUntil ставится перед shareReplay и некоторыми другими операторами. Пруф.

      Во-вторых, этот оператор надо добавлять только непосредственно перед подпиской, в пайпе обсервабла он не нужен

      Я его поставил перед shareReplay по причине выше. К тому же в статье приводятся отдельные куски кода исключительно для примера. В этом случае вообще нет подписки, она за скобками.


      1. sergeivakhrushev
        16.11.2023 18:46

        по ссылке никаких пруфов нет, есть только одно предложение что есть операторы которые могут себя не так вести, но все это от их неправильного использования. Например, toArray, который ожидает комплита, не должен быть завязан на комплит от takeUntil, а от чего то другого, например, получить n значений. takeUntil только для отписки, а не для комплита потока (если мы говорим только о случаях его использования для отписки, тк есть и другие кейсы, но мы их в данном случае опустим).

        Пряча takeUntil внутри pipe какого то observable, мы лишаемся понимания произойдет ли отписка и в принципе мы не должны о таком думать. Поэтому takeUntil используется только в связке с subscribe, в других случаях он либо избыточен или даже вреден.


      1. sergeivakhrushev
        16.11.2023 18:46

        возвращаясь к пункту 2 и взяв ваш пример с playersCount$.

        допустим есть несколько случаев его использования

        1. в темплейте `playersCount$ | async`

        1. создание другого observable `observable$ = playersCount$.pipe(switchMap(count) => apiCall)`

        2. подписка `playersCount$.pipe(map(...),mergeMap(...)).subscribe()`

        должен ли разработчик подумать надо ли добавить takeUntil в какой либо из пайпов или знания что в пайпе playersCount$ он есть этого достаточно?

        Но да, с shareReplay есть проблема при отписке и по хорошему надо при менять refCount с shareReplay когда это возможно, тогда проблемы не будет.


        1. sergeivakhrushev
          16.11.2023 18:46

          Либо shareу которого по умолчанию resetOnError , resetOnComplete и resetOnRefCountZero установлены в true. или ставить перед ним takeUntil, что выглядит не очень