OnPush — ваш новый Default

В Angular есть два режима change detection: Default и OnPush. В этой статье мы разберем, как можно спокойно использовать OnPush всегда без лишнего труда и почему стоит начать это делать.

image-loader.svg

Вспомним

Angular использует Zone.js для отслеживания изменений. Эта библиотека патчит множество нативных сущностей вроде addEventListener, MutationObserver, setTimeout и других.

Когда такое событие происходит, выстреливает некий tick. Angular понимает, что нужно проверить приложение на изменения. Приложение разбито на дерево вьюх. У каждого view своя стратегия изменений. На гифке внизу показано, что происходит с приложением на Default-стратегии, когда пользователь кликает мышкой:

image-loader.svgimage-loader.svg

При OnPush-стратегии изменения поднимутся только от текущего view до корневого, не заходя в параллельные ветки.

OnPush

В любом сложном приложении Default-стратегия рано или поздно приводит к проблемам с производительностью.

Начните с бутылочных горлышек. Но даже если ваше приложение не тормозит, проверять все view на каждое событие — это большая трата вычислительных мощностей. Поэтому я рекомендую всем привыкнуть всегда работать в OnPush-стратегии. Для этого надо понять, как она устроена.

В OnPush есть три ситуации, в которых запустится проверка:

  1. Изменение значения @Input (сравнение идет по ===).

  2. Наступление события, на которое подписались в шаблоне через () или в коде через @HostListener.

  3. Проверка запущена руками — например, через ChangeDetectorRef.

Для большинства ситуаций хватает первых двух пунктов. Если вы слушаете события только средствами Angular и не мутируете данные — с OnPush проблем не будет.

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

Но что, если вы подписываетесь на события через fromEvent из RxJS или делаете запросы на сервер? Тут ситуация может усложниться, но на деле это не так. Все, что нужно сделать, — взять ChangeDetectorRef из зависимостей и вызвать markForCheck в случае асинхронного кода. Давайте копнем глубже!

ChangeDetectorRef

Это базовый класс Angular, от которого наследуется view. В нем всего пара методов. Можно отключиться и переподключиться к механизму проверки изменений целиком (detach и reattach). Это мы рассматривать не будем. И можно запустить проверку руками двумя способами:

  1. markForCheck — имитирует «естественно» возникающую проверку изменений. Этот метод сообщает Angular, что в этом view нужно проверить изменения. Проверка закидывается в очередь, и Angular выполнит ее, когда будет готов. Это асинхронный метод. Он также помечает все родительские view, как если бы случилось событие из @HostListener.

  2. detectChanges — этот метод проверяет текущий view и делает это синхронно. Значит, после вызова, на следующей же строке, все изменения, такие как QueryList`ы и код в lifecycle-хуках, уже произойдут. Это отличается от того, как проверка изменений обычно происходит. Поэтому используйте этот метод, когда понимаете, что вам нужен именно он.

Так как мы стараемся не подписываться на Observable руками, хорошо будет абстрагироваться от явного использования ChangeDetectorRef. Знаменитый async пайп под капотом уже делает это за нас. Так что если вы подписываетесь только через него, то у вас все хорошо. Иначе придется добавить markForCheck в подписку.

В Taiga UI мы добавили крошечный оператор watch для включения его в цепочку, а не в подписку. Подобное использование выглядит аккуратнее и декларативнее. Если метод markDirty когда-то доберется до публичного API, то необходимость в ChangeDetectorRef отпадет:

export function watch(
   ref: ChangeDetectorRef,
): MonoTypeOperatorFunction {
   return tap(() => ref.markForCheck());
}

Вот как его использовать:

interval(3000)
   .pipe(
      watch(this.changeDetectorRef),
      takeUntil(this.destroy$)
   ).subscribe(() => {
       // callback
   });

Теперь, когда мы разобрались в основах, давайте перейдем к хитрым ситуациям и примерам.

NgZone

Angular не работает с Zone.js напрямую. Вместо этого он предоставляет класс NgZone, с которым мы тоже можем взаимодействовать.

Как правило, класс нужен для оптимизации через метод runOutsideAngular. Он позволяет запускать код, не уведомляя Angular об изменениях. Его противоположный по смыслу метод run позволяет вернуться в зону. Это важно для нас по двум причинам:

  1. markForCheck не запустит проверку, если мы находимся вне зоны. Это может случиться, например, если событие прилетело из iframe, то есть, из другого документа, где Zone.js не пропатчила код, или если зону покинули вручную.

  2. Даже если ваш компонент в OnPush, Zone.js все равно будет создавать «тики», которые могут запустить проверку в других ваших компонентах, у которых стоит стратегия Default. Поэтому важно покидать зону для частых асинхронных колбэков. Например, для requestAnimationFrame.

Большинство асинхронного кода происходит в цепочках RxJS. Так что нам понадобится удобный способ работы с NgZone внутри стримов. Давайте сделаем операторы для покидания и возврата в зону. Вернуться в зону просто, нам надо только переключиться на новый Observable и обернуть все методы в zone.run:

export function zonefull(
  ngZone: NgZone
): MonoTypeOperatorFunction {
  return source =>
    new Observable(subscriber =>
      source.subscribe({
        next: value => ngZone.run(() => subscriber.next(value)),
        error: error => ngZone.run(() => subscriber.error(error)),
        complete: () => ngZone.run(() => subscriber.complete()),
      }),
    );
}

Если поток достигнет этого оператора, то все последующие действия произойдут уже внутри зоны Angular. Чтобы покинуть зону, нам тоже понадобится новый Observable, но в этот раз мы обернем саму подписку:

export function zonefree(
  ngZone: NgZone
): MonoTypeOperatorFunction {
  return source =>
    new Observable(subscriber =>
      ngZone.runOutsideAngular(() => source.subscribe(subscriber)),
    );
}

Zone.js также можно отключить для определенных событий во всем приложении.

Теперь у нас есть два оператора: один влияет на подписку, другой — на испускание значения. Мы можем объединить их в оптимизирующий оператор, который покидает зону и возвращается в нее при необходимости. Его можно поместить в конец цепи, и вся фильтрация, distinctUntilChanged и другие операторы будут идти выше:

export function zoneOptimized(
  ngZone: NgZone
): MonoTypeOperatorFunction {
  return pipe(zonefree(ngZone), zonefull(ngZone));
}

Посмотрите этот StackBlitz. Мы выстреливаем событие каждую секунду, но пропускаем только четные разы. Нечетные проходят мимо зоны и не создают «тики».

Эти операторы доступны в @taiga-ui/cdk — низкоуровневом пакете из Taiga UI. Он отлично тришейкается, так что можно смело брать их себе и не бояться, что что-то лишнее залетит в бандл.

Примеры

Важно помнить два факта:

  1. Данные идут сверху вниз.

  2. События всплывают снизу вверх.

Поэтому, когда происходит событие, для проверки помечаются все view от текущего до верхнего. Если вы не закручиваете код в узлы, markForCheck вам понадобится только для явных подписок. Поэтому то, насколько комфортно вам будет работать в OnPush, напрямую зависит от вашего знания RxJS. Я большой любитель этой библиотеки и всем советую потратить время на ее хорошее освоение.

Посмотрите эту серию RxJS челленджей, которую мы с Ромой делали некоторое время назад!

Давайте взглянем на такой пример.

У нас есть компонент с таблицей пользователей. Список приходит с сервера, можно добавлять и удалять записи. Кроме того, есть индикатор загрузки и возможны ошибки при совершении операций. Вот решение в лоб, полагающееся на то, что Default проверяет все и вся.

Императивные манипуляции с состоянием мешают использовать OnPush. Ничего не меняя, мы, конечно, можем просто влепить по markForCheck в каждый finalize.

Но давайте посмотрим на задачу под другим углом. Все запросы — это RxJS-стримы. Они реактивны. Мы покидаем реактивный мир в подписках, чтобы руками обновить состояние на каждое действие. Вместо этого давайте перепишем этот компонент на реактивный лад. Посмотрите обновленный StackBlitz со всеми комментариями.

Разумеется, иногда это перебор, и в простых ситуациях markForCheck может лучше читаться. Просто помните, что в OnPush, если вы что-то делаете руками, то проверку изменений тоже придется запускать самим.

Паттерн контроллера

Иногда у вас может быть несколько вложенных OnPush-компонентов. Возможно, у вас есть директива, которая контролирует компонент, лежащий на несколько уровней вглубь.

Или у вас может быть сложная форма, разбитая на компоненты. Можно почитать указанную выше статью и продолжение к ней, но вот краткая сводка, которая может пригодиться:

  1. Добавляете пустой Subject к директиве, контролирующей компонент.

  2. Получаете ее в OnPush-компоненте через DI.

  3. Подписываетесь и запускаете изменения — можно через async пайп.

  4. Вызываете next на этом Subject для запуска проверки — например, в ngOnChanges директивы.

Посмотрите этот StackBlitz — имейте в виду, что он очень упрощен для краткости.

В этом случае дочерний элемент зависит от родителя. Попробуйте кликнуть на пару детей, а затем переключить родителя — вы увидите, что включенные элементы тоже отключатся. При этом они покажут иконку, что они отключены из-за родительского правила. Но поскольку никакой инпут не изменился, со стратегией OnPush они не обновятся. Если у вас такой зависимый случай, этот паттерн вам поможет.

Это немного похоже на фокус с динамическим @ContentChildren, про который я писал ранее на Медиум. Можно придумать множество примеров, где поначалу переход на OnPush покажется сложным. К этой стратегии нужно привыкнуть. Если у вас есть интересный кейс, поделитесь им в комментариях, и давайте попробуем вместе его разобрать.

В итоге

Я знаю только одну ситуацию, в которой OnPush невозможен: компонент отображения ошибок формы. Это потому, что мы никак не можем узнать, в какой момент контрол станет touched. Это давняя проблема, которую наконец собрались закрыть тем, что добавили универсальные события изменения состояния полей форм. В остальном же я еще не встречал ситуации, в которой OnPush бы накинул столько накладных расходов, что я бы посоветовал его не использовать.

Поставьте OnPush по умолчанию для кода, созданного через CLI, добавив это в angular.json:

{
  ...
  "schematics": {
    "@schematics/angular:component": {
      "changeDetection": "OnPush"
    }
  },
  ...
}

© Habrahabr.ru