Привет, Хабр! Меня зовут Игорь Поляков, работаю веб-программистом технологических приложений ВЕРТИКАЛЬ. В данной статье расскажу о стратегии обнаружения изменений в Angular с учетом обновлений фреймворка версии 17 и выше. Если вас беспокоит вопрос производительности и вы желаете разрабатывать оптимизированные приложения, добро пожаловать! ?
Давайте, наконец, разберемся:
Когда же запускается цикл обнаружения в режиме onPush?
Что происходит при обновлении @Input?
Триггерит ли таймер детектор изменений?
Что насчет обработчиков событий?
Как ведет себя onPush при изменении данных в AsyncPipe?
А если сигнал обновил свое значение?
Кроме того, стоит прояснить, в чем принципиальная разница между detectChanges и markForCheck.
Чтобы понимать о чем вообще идет речь, нужно уяснить, какие механизмы отвечают за перерисовку вашего компонента, за обновление его визуального представления. В разрезе данной статьи нужно зафиксировать два ключевых этапа:
Цикл обнаружения изменений.
Рендер компонента в DOM-дереве.
Допустим, произошло некоторое событие. Например, у нас сработал таймер, обновился input, запустился очередной этап жизненного цикла и тд. Когда такое событие зарегистрировано в ngZone, фреймворк стартует цикл обнаружения изменений. В каждом компоненте DOM дерева будут пересчитаны геттеры, функции и любые другие значения, которые мы выводим в шаблон. Если обновленные (пересчитанные) значения не совпадают с тем, что на данный момент отображается в дереве, тогда запускается второй этап - отрисовка (ре-рендер) данного компонента в DOM.
Примерно вот так будет выглядеть блок-схема компонентов, которые затронет цикл обнаружения изменений. Несмотря на то, что событие было зарегистрировано в SearchComponent, Angular пересчитает и сравнит ВСЕ выводимые значения для ВСЕХ представленных компонентов приложения.

А теперь давайте посмотрим, как будет выглядеть ситуация, когда для некоторой части наших компонентов мы выставили OnPush стратегию. Теперь, в случае регистрации события в ветке header компонента (подсвечена розовым), цикл обнаружения изменений не пойдет пересчитывать и сравнивать ветку компонентов Main (подсвечена белым). Уже намного лучше.

Что же, теперь, я надеюсь, вы готовы воспользоваться данной стратегией. Вот как она подключается:
Теперь Angular будет выполнять обнаружение только при определённых условиях
@Component({
selector: 'app-onpush',
template: `{{ counter }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
@Input() counter = 0;
}
Отныне Angular будет запускать цикл сравнения только в определенных условиях. Согласитесь, совсем без обнаружения изменений будет грустно — мы не хотим, чтобы наше приложение превратилось в статичную страницу, не реагирующую на действия пользователя. Поэтому даже при OnPush мы можем рассчитывать, что цикл будет запущен. Однако количество таких запусков кратно сократится, а производительность возрастет. Давайте разберемся, в каких случаях мы можем ожидать запуск обнаружения изменений при стратегии OnPush, а когда — нет.
@INPUT примитив
Если родительский компонент передает новое примитивное значение (number, string, boolean и тд.) через @Input, то дочерний компонент с включенной стратегий OnPush запускает обнаружение изменений и обновляет DOM.
Если родитель передает новое примитивное, Angular триггерит change detection
@Component({
selector: 'app-onpush',
template: `{{ counter }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
@Input() counter = 0;
}
<app-onpush [counter]="count"></app-onpush>
@INPUT ссылочный
Если родительский компонент передает ссылочное значение через @Input (объект или массив), дочерний компонент с включенной стратегий OnPush запускает обнаружение изменений и обновляет DOM только в том случае, когда поменялась ссылка на объект. Мутация объекта не запускает обнаружение изменений и не обновляет DOM.
@Component({
selector: 'app-child',
template: `{{ user.name }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
@Input() user!: { name: string };
}
✅︎ Смена ссылки запускает цикл!
this.user = { name: 'Alice' };
❌ Обнаружение изменений не будет запущено — ссылка не изменилась!
this.user.name = 'Bob';
Изменения в коде компонента
При изменении переменной count в коде компонента (например, в хуке AfterViewInit) механизм обнаружения изменений в DOM не запускается и не обновляет представление при включенной OnPush стратегии.
❌ Изменение состояния переменной в коде компонента не запускает механизм обнаружения изменений!
@Component({
selector: 'app-onpush',
template: `{{ count }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent implements AfterViewInit {
count = 0;
ngAfterViewInit() {
this.count++;
}
}
Таймер (setTimeout)
При изменении переменной value в коде таймера механизм обнаружения изменений в DOM не запускается и не обновляет представление при включенной OnPush стратегии.
❌ Изменение состояния переменной в таймере не запускает механизм обнаружения изменений!
@Component({
selector: 'app-onpush',
template: `{{ value }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent implements AfterViewInit {
value = 'empty';
ngAfterViewInit() {
setTimeout(() => {
this.value = 'updated';
}, 1000);
}
}
Обработчик событий
При изменении переменной count в обработчике событий increment(), механизм обнаружения изменений запускается и обновляет представление самостоятельно даже при включенной OnPush стратегии. Это логично, потому что мы обязаны среагировать на воздействие со стороны пользователя.
✅︎ Изменение состояния переменной в обработчике событий запускает механизм обнаружения изменений!
@Component({
selector: 'app-onpush',
Template: `<p>{{count}}</p><button (click)=`increment()`></button>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent implements AfterViewInit {
count = 0;
increment() {
this.count++;
}
}
Использование async pipe
Async pipe самостоятельно запускает механизм обнаружения и перерисовки DOM при каждом новом значении вне зависимости от стратегии. Пайпы мемоизируют вычисления, а значит оптимизация все равно присутствует.
✅︎ Async pipe подписывается и вызывает markForCheck при приходе нового значения — представление обновляется.
@Component({
selector: 'app-onpush',
template: `{{ data$ | async }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
data$ = this.http.get('/api/data');
}
Сигналы (Angular 17+)
Сигналы самостоятельно запускают механизм обнаружения и перерисовки DOM при каждом новом значении. Они так же мемоизируют вычисления и сохраняют оптимизицию. Сигналы и OnPush — это отличная связка!
✅︎ Сигналы работают без ручного вызова detectedChanges, они самостоятельно уведомляют об изменениях.
@Component({
selector: 'app-signal',
template: `{{ count() }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SignalComponent implements AfterViewInit {
count = signal(0);
ngAfterViewInit(): void {
setInterval(() => this.count.update(v => v + 1), 1000);
}
}
MarkForCheck и detectChanges
Не стоит забывать, что у разработчика остается возможность запустить цикл обнаружения изменений в ручном режиме. Чаще всего для этого используются два наиболее популярных метода.
markForCheck() |
Помечает компонент как «грязный», Angular проверит его при следующем цикле |
detectChanges() |
Немедленно запускает change detection для компонента и его потомков |
Личный опыт ??
Сейчас перед нашей командой стоит большая задача — разработка веб версии приложения «Вертикаль», это внушительная инженерная система автоматизированного проектирования технологических процессов. Одним из важных компонентов Вертикали является дерево техпроцесса. На этом примере отлично видно, что каждый узел дерева является отдельным компонентом, со своим жизненным циклом и набором ивентов. Очевидно, что в больших системах NgZone постоянно регистрирует огромное количество событий, поэтому в нашей команде стратегия OnPush оформилась как стандарт разработки компонентов Angular. Оптимизация и ответственное отношение к производительности улучшают пользовательский опыт и делает приложение удобным.
P.S.
Если вы готовы использовать OnPush как основной режим обнаружения изменений, можете воспользоваться правилом eslint, которое вообще запретит использовать дефолтную стратегию.
"@angular-eslint/prefer-on-push-component-change-detection": "error"