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

Вспомним

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

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

При 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<T>(
   ref: ChangeDetectorRef,
): MonoTypeOperatorFunction<T> {
   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<T>(
  ngZone: NgZone
): MonoTypeOperatorFunction<T> {
  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<T>(
  ngZone: NgZone
): MonoTypeOperatorFunction<T> {
  return source =>
    new Observable(subscriber =>
      ngZone.runOutsideAngular(() => source.subscribe(subscriber)),
    );
}

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

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

export function zoneOptimized<T>(
  ngZone: NgZone
): MonoTypeOperatorFunction<T> {
  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"
    }
  },
  ...
}

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


  1. nin-jin
    04.10.2021 15:29
    -4

    А можно глупый вопрос? Зачем брать такую технологию, где архитектурно заложено такое огромное число граблей, что по каждой нужно отдельное исследование проводить, писать статьи, вставлять костыли и, конечно, рефакторить половину приложения, когда эти проблемы становятся совсем уж неприемлемыми?


    1. Justerest
      04.10.2021 16:11
      +8

      А можно глупый ответ? Чтобы не использовать $mol)

      Собственно, при написании интерфейса нужно понимать, что паттерн MVC, предполагает, что View как-то должно обновиться при изменении Model.

      При стратегии OnPush в Angular View узнает об изменениях Model через паттерн Observable. Никакого волшебства в ChangeDetection. Если хочешь чтобы что-то обновилось, дерни ручку – subject.next(), markForCheck(), setState() (ой, это уже React).

      Изначально Angular снимали с разработчика обязанность по логике оповещения об изменениях через zone.js, но решили больше так не делать.

      Насколько я понимаю, $mol решает этот вопрос под капотом, не предоставляя разработчику ручного управления процессом ChangeDetection? Это ваше архитектурное решение)


      1. nin-jin
        04.10.2021 16:24
        -4

        В $mol и нет ChangeDetection, там просто все изменяемые свойства помечаются декоратором и всё, не надо никаких subject.next(), markForCheck(), setState().


        1. Justerest
          04.10.2021 16:28

          В Angular Default тоже было не надо. Но не обманывайте ребятишек, под капотом же как-то View узнает про изменение? Видимо, декоратор и вызывает ручку для перерисовки, когда декорированная функция вызывается?)


          1. nin-jin
            04.10.2021 18:15
            -2

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


          1. mayorovp
            05.10.2021 12:32
            +2

            Справедливости ради, подход по умолчанию в $mol и в Ангуляре сильно различается, и вовсе не в пользу Ангуляра: в то время когда Ангуляр на каждый чих пересчитывает все привязки в поддереве, $mol проходит по автоматически определённому ациклическому орграфу зависимостей и точечно применяет обновления.


            Однако, вы тоже правы, потому что даже такой хороший алгоритм иногда нужно отключать. В сложных анимациях, например.


            1. nin-jin
              05.10.2021 12:58

              А что за сложные анимации такие, где нужно отключать реактивность?


              1. mayorovp
                05.10.2021 13:13

                Любые, которые нагружают процессор на заметную величину.


                1. nin-jin
                  05.10.2021 14:27

                  И чем тут может помочь выключение реактивности?


                  1. mayorovp
                    05.10.2021 23:17

                    Снижением накладных расходов на никому не интересные действия, конечно же.


                    Когда некоторая величина нетривиально анимируется, она зависит от времени и заведомо меняется (почти) каждый кадр. При этом нас перестают волновать изменения зависимостей между кардами.


                    1. nin-jin
                      05.10.2021 23:39

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

                      Выходит такая анимация за пределы видимой области или вырождается в изменения меньше пикселя и кэш внезапно начинает работать, а отслеживание зависимостей очень даже начинают интересовать.


                      1. mayorovp
                        05.10.2021 23:46

                        Ну да, для тривиальной анимации (т.е. анимации которая ничего не делает) выходной кеш и правда немного поможет. Но не делать такую анимацию вовсе будет ещё дешевле.


                        А вот для невидимой анимации выходной кеш ничего не исправит. И, опять-таки, решение "не делать такую анимацию" тоже работает.


                        Что же до динамического отслеживания зависимостей — оно тут лишнее в любом случае, что с выходным кешем, что без него.


                      1. nin-jin
                        06.10.2021 01:25

                        Реактивный кеш как раз таки поможет выключать анимацию автоматически, а не вручную. При выходе из видимой области произойдёт отписка от таймера, и при возвращении - подписка.

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


                      1. mayorovp
                        06.10.2021 10:21

                        Причём тут реактивный кеш?


                      1. nin-jin
                        06.10.2021 10:26
                        -1

                        При том, что в $mol именно он.


    1. zartdinov
      04.10.2021 16:20
      -5

      Может потому что там все Google Developer Expert по Angular. А если серьезно, то присоединяюсь к вопросу. Все прошлые статьи у TInkoff были про React, про общую библиотеку компонентов в едином стиле и т.д., зачем разводить зоопарк?


      1. nin-jin
        04.10.2021 16:26
        -4

        Ну вот представьте. У вас есть только шило и мыло, а вам нужно гвозди забивать. Берёте мыло, насаживаете его на шило и у вас получается импровизированный микросервисный портал для забивания гвоздей.


      1. Waterplea Автор
        04.10.2021 16:30
        +6

        У нас в компании используются и React, и Angular. Но почти все статьи, в том числе и про библиотеку компонентов, это был Angular (1389 против 231 местных очков вклада в хаб). Так что не знаю, откуда вы взяли про "все прошлые статьи были про React" :)


        1. vakhramoff
          05.10.2021 18:54
          +1

          Отдельное спасибо хочу выразить ребятам из Тинькофф: в частности Саше Инкину и Роме Седову. На их статьях по Angular я вырос как разработчик: изучая исходный код в материалах и репозиториях, пытаясь разобраться, как и почему он работает. 

          Не стоит недооценивать важность статьи, ведь ещё сам буквально недавно я боялся писать компоненты с OnPush, тыкая везде Default. Когда я попытался перевести уже готовое приложение на OnPush, я опускал руки, потому что казалось, что нужен вагон времени. Эта статья сэкономит часы или даже дни времени будущем читателям, здесь выжимка того, что автор сам изучал не за один день. Попадись мне такая в своё время – она точно бы сэкономила пару дней времени, и явно бы внесла больше ясности.


          1. nin-jin
            06.10.2021 10:33
            -1

            На OnPush проще переходить одновременно с переходом на какой-нибудь MobX, который сам будет дёргать ререндер компонента.


  1. BaZzing0
    05.10.2021 13:33

    Не всегда понимаю, когда стоит использовть runOutsideAngular. Можно чуть больше примеров помимо анимации?


    1. Waterplea Автор
      05.10.2021 13:46
      +1

      События, которые стреляют очень часто, например scroll, mousemove, dragmove могут быть хорошими кандидатами на запуск вне зоны. Но чаще всего это всё же requestAnimationFrame.


  1. Dima716
    05.10.2021 17:25

    А что значит "если вы не закручиваете код в узлы?


    1. Waterplea Автор
      05.10.2021 17:27
      +1

      Иногда в проектах можно встретить очень странный RxJS код, приправленный сайдэффектами, tap`ами и перебрасываниями туда сюда в стор чего-либо, что иногда может сказаться. Я не имел ввиду какой-то термин :)


  1. yuriy-bezrukov
    06.10.2021 08:03

    Было бы отлично добавить два пример на StackBlitz, с рендерингом 200 компонентов на onPush и без него, тогда каждый поставит его по умолчанию в схематикс =)



  1. Sparksjj
    07.10.2021 10:36
    +1

    Статья огонь, спасибо ребятам из TINKOFF, очень клевые статьи про angular и не только)


  1. mommys_friends_son
    09.10.2021 08:26

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

    тоже столкнулся с такой проблемой, в итоге было найдено (неидеальное, но рабочее) решение

    type ArgumentsType<F> = F extends (...args: infer A) => any ? A : never;
    type ObjectLike<O extends object, P extends keyof O = keyof O> = Pick<O, P>;


    extractTouchChanges(control: ObjectLike<AbstractControl, 'markAsTouched' | 'markAsUntouched'>): Observable<boolean> => {

    const prevMarkAsTouched = control.markAsTouched;
    const prevMarkAsUntouched = control.markAsUntouched;
    const touchedChanges$ = new Subject<boolean>();

    function nextMarkAsTouched(...args: ArgumentsType<AbstractControl['markAsTouched']>): void {
    prevMarkAsTouched.bind(control)(...args);
    touchedChanges$.next(true);
    }

    function nextMarkAsUntouched(...args: ArgumentsType<AbstractControl['markAsUntouched']>): void { prevMarkAsUntouched.bind(control)(...args); touchedChanges$.next(false);
    }

    control.markAsTouched = nextMarkAsTouched;
    control.markAsUntouched = nextMarkAsUntouched;

    return touchedChanges$;
    };


    Дальше, соответственно:
    touched$ = extractTouchChanges(formControl)
    .pipe(startWith(formControl.touched));


    1. mayorovp
      09.10.2021 09:05

      Почему вы используете bind вместо apply?


      1. mommys_friends_son
        09.10.2021 13:08
        -1

        вот поэтому я и не люблю оставлять комментарии
        всегда найдется какой-нибудь умник, обративший внимание на не относящуюся к основному вопросу вещь


  1. non4me
    09.10.2021 17:08

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

    Не считаете ли вы что использование данного подхода излишне усложняет проект? Появляется большое кол-во технического (не решающего бизнес-логику) кода только для того что бы по сути отказаться от одной из основных фишек фреймворка и поменять дефолтную стратегию (согласен, не всегда оптимальную) на собственный велосипед (тоже не оптимальный).
    Есть ощущение что накладные расходы (особенной на сложных проектах) связанные с необходимостью поддержки подобного кода будут превышать профит.
    Редизайн большого проекта с использованием подобного подхода вряд ли возможен. По сути, нам нужно разбить интерфейс на кучу несвязанных осколков и самостоятельно заботиться об их обновлении. Особенно тех что находятся в параллельных ветках.


    1. Waterplea Автор
      10.10.2021 11:14

      OnPush, по сути, что-то меняет только для асинхронных операций. Если они написаны через RxJS, без императивных подписок и ручного изменения внутренних состояний, то OnPush ничего не добавит. Мне комфортно так писать и поддерживать код. RxJS требует особой подстроки мышления. Если её нет, то может возникать много непонимания, фрустрации и неприязни. Тогда с OnPush будет тяжело. Самостоятельно заботиться об обновлении мне не приходится. Практически всегда я не подстраиваюсь под OnPush, а просто пишу так, что при его включении всё будет работать и так. Разбиение интерфейса, если это вдумчивая декомпозиция, приложению только на пользу. В какой-то степени OnPush похож на strict в TypeScript. Можно сказать, что без него проще что-то накидать и не нужен лишний код проверок, но кодовая база с ним становится более предсказуемая, более аккуратная и надёжная. Ну и мигрировать приложение на него, если он сразу не был включен тоже будет довольно трудно ????


      1. non4me
        10.10.2021 12:17

        Декомпозиция это само собой разумеющиеся вещь. Без этого вообще невозможно создание более мене сложного приложения. Я про то другое.
        Как вы сами же пишете в статье при OnPush стратегии не обновляются параллельные ветки.
        Таким образом скажем клик на кнопку в компоненте А в шапке страницы не будет автоматически обновлять компонент Б где нибудь в подвале поскольку они не находятся в одной ветке и вообще не связаны между собой напрямую. Соответсвенно нам нужно будет добавлять какую то дополнительную логику которая бы сообщила компоненту Б что ему требуется обновиться.
        Именно это меня и смущает в подобном подходе. Поскольку очевидно ведет к усложнению и повышению хрупкости кода. Это накладные расходы которые нам требуется заплатить за предполагаемое повышение производительности. Причем не важно есть ли с ней проблемы или нет.
        Поэтому подход, решать проблемы по мере возникновения, мне все же кажется более оптимальным.


        1. Waterplea Автор
          10.10.2021 12:32

          Но ведь если компонент А никак не связан с компонентом Б — почему он должен обновляться? Связь сверху вниз можно осуществить через инпуты и OnPush это подхватит. Связь параллельных вьюх надо осуществлять через сервис. Уж точно не надо строить своё приложение, полагаясь на волшебную связь всего со всем.


  1. non4me
    10.10.2021 13:31

    Он должен обновляться потому что того например требует бизнес-логика. Я это привел просто как пример для более сложного случая.
    Ладно, на этом пожалуй откланяюсь. Свою точку зрения я высказал. Возможно кому-то она покажется разумной.


  1. non4me
    10.10.2021 14:17

    Еще дополню для ясности.
    Речь о том что все имеет свою цену и предлагаемый вами подход тоже. Вопрос в том если стоимость решения не превышает пользу. Я не уверен, что это оправдано для всего множества случаев. Что если мы просто одни проблемы меняем на другие. Особенно в контексте того что данный подход предлагается использовать как дефолтный.


    1. non4me
      10.10.2021 14:19

      Прошу прощения, почему то Хабра вместо продолжения, делает новые ветки и нет возможности это поправить.