05 — OnPush и zoneless: когда Angular проверяет компоненты

Это та самая тема, где все путаются. Разложим по полочкам. Точная версия с исходниками — 01-cd-strategies в соседнем vault'е.

Главная мысль: тут ДВА разных вопроса

Люди мешают в кучу два независимых вопроса. Раздели их — и всё станет ясно:

  1. КТО будит проверку? (что вообще запускает tick) — это про zone.js vs zoneless. Свойство всего приложения.
  2. КАКИЕ компоненты проверяются внутри tick? — это про Default vs OnPush. Свойство конкретного компонента.

Это как две разные ручки настройки. Одна — «как часто включается уборка», вторая — «какие комнаты убираем».

Ось 1 — кто будит проверку (zone.js vs zoneless)

zone.js (старый способ)

zone.js — это «жучок», который влезает во ВСЕ асинхронные функции браузера (setTimeout, клики, запросы). После любого такого действия он говорит Angular: «эй, что-то произошло, проверь всё».

Минус: проверка дёргается даже когда ничего важного не поменялось (сработал посторонний таймер — а CD всё равно прошёлся по всему дереву).

zoneless (по умолчанию в v21)

Никакого жучка. Проверку планируют только конкретные понятные вещи:

  • сигнал, прочитанный в шаблоне, изменился;
  • сработало событие из шаблона ((click));
  • пришёл новый @Input;
  • явный markForCheck() / AsyncPipe.

Обычный setTimeout, не трогающий сигнал, проверку не запускает. В этом вся суть.

Ось 2 — какие компоненты проверяются (Default vs OnPush)

Когда проверка уже идёт, она решает по каждому компоненту: проверять его или пропустить.

  • Default — «проверяй меня всегда, когда обход дошёл».
  • OnPush — «проверяй меня, только если у меня реально что-то изменилось» (новый @Input по ссылке, событие из моего шаблона, мой сигнал, markForCheck).

OnPush = «не буди меня по пустякам». Это оптимизация: тяжёлый компонент не пересчитывается зря.

Матрица 2×2 — что реально происходит

Default OnPush
zone.js Любое действие → проверяется ВСЁ дерево. Просто, но расточительно. Любое действие будит проверку, но «спокойные» OnPush-ветки пропускаются. Классическая оптимизация.
zoneless (v21) Проверка только от сигнала/события. Но Default сам по себе уже почти не важен. Идеал v21: OnPush + сигналы. Сигнал точечно обновляет свой компонент.

На бытовых примерах

zone.js + Default — «уборщик-параноик»

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

zone.js + OnPush — «уборщик с правилом»

Уборщик всё равно встаёт на каждый чих, но в комнаты с табличкой «OnPush» заходит, только если там реально намусорили. Меньше работы.

zoneless + Default — «уборщик спит»

Уборщик не встаёт сам по себе. Если ты поменял обычное поле (this.x = 5) без сигнала — он даже не узнал, экран не обновился. Это ловушка миграции!

zoneless + OnPush + сигналы — «умный уборщик» (рекомендуется в v21)

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

Главная ловушка при переходе на zoneless

// ❌ НЕ обновит экран в zoneless:
export class Bad {
  x = 0;                                  // обычное поле
  ngOnInit() { setTimeout(() => this.x = 5, 1000); }  // никто не разбудит CD
}

// ✅ Обновит:
export class Good {
  x = signal(0);                          // сигнал
  ngOnInit() { setTimeout(() => this.x.set(5), 1000); } // сигнал разбудит CD
}

Правило: в zoneless всё, что показывается в шаблоне и меняется во времени, должно быть сигналом (или обновляться через AsyncPipe / markForCheck).

Короткий итог

  • zone.js vs zoneless = «как часто и от чего вообще запускается проверка».
  • Default vs OnPush = «проверять ли этот конкретный компонент, когда дошли до него».
  • Самый дорогой режим — zone.js + Default. Самый чистый и дешёвый — zoneless + OnPush + signals (дефолт идеологии v21).

Дальше: а из чего вообще «сделан» компонент в памяти? → 06 — view, LView и TView.