Change detection: Default vs OnPush × zone.js vs zoneless

Главный вопрос: «как ведёт себя приложение в рантайме с OnPush и без, и как это скрещивается с zone.js / zoneless». Контекст рантайма — 03-runtime.

Сразу суть: это ДВЕ независимые оси

Путаница возникает, потому что смешивают два разных вопроса:

  1. КТО будит change detection (что вообще планирует tick) — это про zone.js vs zoneless. Свойство ПРИЛОЖЕНИЯ.
  2. КАКИЕ view проверяются ВНУТРИ tick (как обходится дерево) — это про Default (CheckAlways) vs OnPush. Свойство КОМПОНЕНТА (флаг на его LView).

Эти оси ортогональны. ChangeDetectionStrategy.OnPush НЕ отключает zone.js и НЕ включает zoneless — он лишь меняет правило «рефрешить ли этот конкретный view при обходе». А zone.js/zoneless определяет, как часто и от чего этот обход вообще запускается.

Технически стратегия — это один бит на LView: LViewFlags.CheckAlways (1 << 4, interfaces/view.ts:423). Default → бит стоит, OnPush → бита нет.


Ось 1 — кто планирует tick

zone.js (provideZoneChangeDetection)

zone.js монки-патчит ВСЕ асинхронные API (setTimeout, addEventListener, Promise, XHR…). Когда микротаски заканчиваются, дёргается onMicrotaskEmpty

onMicrotaskEmpty → applicationRef.dirtyFlags |= ViewTreeGlobal → _tick()
                                        (ng_zone_scheduling.ts:59)

Ключевое: ставится флаг ViewTreeGlobal. Любой async где угодно (даже setTimeout, не меняющий ничего связанного с шаблоном) → tick в Global-режиме.

zoneless (provideZonelessChangeDetection, дефолт в v21)

zone.js нет. tick планирует только адресный notify(source) в scheduler'е (zoneless_scheduling_impl.ts:105). Что будит CD:

Источник NotificationSource что ставит
сигнал, прочитанный в шаблоне, изменился MarkAncestorsForTraversal ViewTreeTraversal
cdRef.markForCheck() MarkForCheck ViewTreeCheck
(click) и др. листенеры в шаблоне Listener ViewTreeCheck
смена @Input() SetInput ViewTreeCheck
AsyncPipe, resource, эффекты ViewEffect/RootEffect traversal/effects

Обычный setTimeout(() => this.x = 5), не трогающий сигнал и не зовущий markForCheck, tick НЕ планирует — в этом вся суть zoneless. Важно: tick(), вызванный НЕ из zone, в zoneless НЕ ставит ViewTreeGlobal (application_ref.ts:571 — только if (!zonelessEnabled)).

Отдельная деталь: в zone.js-режиме нотификация от листенера scheduler'ом игнорируется (zoneless_scheduling_impl.ts:106) — зону и так разбудит сам patched-обработчик. Листенеры зовут markViewDirty всегда, но «будит» tick в zone-режиме именно zone, а в zoneless — нотификация листенера.


Ось 2 — какие view рефрешатся при обходе

Сердце обхода — detectChangesInView (instructions/change_detection.ts:458). Для каждого view считается shouldRefreshView = ИЛИ из условий:

shouldRefreshView =
     (mode === Global && CheckAlways)            // Default-view, но ТОЛЬКО в Global
  || (mode === Global && Dirty && !checkNoChanges)// помеченный грязным, ТОЛЬКО в Global
  || RefreshView                                  // флаг «рефрешни именно меня» — в ЛЮБОМ режиме
  || (consumer.dirty && producersChanged)         // сигнал в шаблоне изменился — в ЛЮБОМ режиме
  • рефрешим → refreshView (update-проход rf&2), а его дочерние компоненты обходятся в Global (change_detection.ts:297) — Global «каскадит» вниз через каждый рефрешащийся view;
  • не рефрешим, но стоит HasChildViewsToRefresh → спускаемся в детей в Targeted-режиме (change_detection.ts:498);
  • иначе весь поддерево пропускается.

Режим на корне tick

synchronizeOnce (application_ref.ts:682):

mode = (useGlobalCheck && !zonelessEnabled) ? Global : Targeted
  • zone.js: ViewTreeGlobal есть → useGlobalCheckGlobal на корне.
  • zoneless: zonelessEnabledвсегда Targeted на корне, даже если флаг global выставлен.

Что значат флаги (LViewFlags)

  • CheckAlways — стратегия Default. Рефрешит только в Global.
  • Dirty — «view грязный». Тоже срабатывает только в Global.
  • RefreshView — «рефрешни именно этот view» — работает в любом режиме, в т.ч. Targeted. Этим живут сигналы и OnPush в zoneless.
  • HasChildViewsToRefresh — «у меня ниже есть что освежить» → нужно спуститься.

Кто и как ставит эти флаги

  • markViewDirty (mark_view_dirty.ts:26) — листенеры, markForCheck, SetInput. Ставит RefreshView | Dirty на сам view и на ВСЕХ предков до корня. То есть пробивает «дорожку» от корня к компоненту.
  • markViewForRefresh + markAncestorsForTraversal (view_utils.ts:208,264) — сигналы/transplanted-view. Ставит RefreshView только на сам view, а предкам — лишь HasChildViewsToRefresh (предки не рефрешатся целиком, только пропускают через себя). Сигнальный consumer view зовёт это из reactive_lview_consumer.ts:48.

Матрица 2×2

Default (CheckAlways) OnPush
zone.js Любой async → tick (Global) → рефрешится ВСЁ дерево каждый раз. Просто и расточительно. Любой async → tick (Global), но чистые OnPush-поддеревья пропускаются. Рефрешатся только Dirty/RefreshView/сигнальные. Классическая оптимизация.
zoneless tick только от сигнала/markForCheck/листенера/input. Корень — Targeted, поэтому CheckAlways сам по себе НЕ рефрешит. Default «едет» вниз только когда рефрешится его родитель. То же планирование. Идиоматичный режим v21: OnPush + signals. Сигнал точечно рефрешит свой view в Targeted.

По клеткам — что реально происходит

1. zone.js + Default («классический» Angular)

  • Триггер: любой async (клик, таймер, ответ HTTP) → onMicrotaskEmpty → tick, режим Global.
  • Обход: корень в Global → каждый CheckAlways рефрешится → каскад Global вниз → перепроверяются ВСЕ компоненты и все биндинги.
  • Любое событие где угодно перетряхивает всё дерево. Дорого, но «всё всегда синхронно» — отсюда и репутация Angular как «магически реактивного».

2. zone.js + OnPush

  • Триггер: тот же — любой async будит tick (zone.js не знает про стратегию).
  • Обход: корень Global. OnPush-view — НЕ CheckAlways, поэтому рефрешится только если он Dirty/RefreshView/с грязным сигналом.
    • OnPush становится грязным, когда: пришло событие из ЕГО шаблона (markViewDirty → дорожка к корню), сменилась ССЫЛКА во входном @Input (SetInput), сработал AsyncPipe или явный markForCheck().
    • Чистый OnPush в Global-режиме пропускается вместе со всем поддеревом.
  • Tick запускается так же часто, но работы меньше. Классическая ловушка: мутация объекта-инпута по месту (без новой ссылки) НЕ метит view грязным → экран устаревает.

3. zoneless + Default

  • Триггер: автоматического async-триггера НЕТ. tick только от нотификации (сигнал/markForCheck/листенер/input/эффект).
  • Обход: корень всегда Targeted. В Targeted CheckAlways сам по себе не рефрешит → бит «Default» почти теряет смысл для запуска проверки.
  • Главная ловушка миграции: setTimeout(() => this.x = 5) с {{x}} в Default-шаблоне DOM НЕ обновит — некому разбудить tick. Нужны сигналы или markForCheck.
  • Остаточный смысл Default: когда родитель РЕФРЕШИТСЯ (а детей он обходит в Global, change_detection.ts:297), CheckAlways-ребёнок поедет с ним; чистый OnPush — нет. То есть Default = «проверяй меня, если проверяют родителя».

4. zoneless + OnPush (рекомендуемый режим v21)

  • Триггер: тот же набор нотификаций.
  • Обход: корень Targeted. Сигнал, прочитанный в шаблоне, при изменении: markAncestorsForTraversal (предкам HasChildViewsToRefresh) + свой consumer dirty=true. При обходе именно этот view ловится по consumer.dirty && producersChangedв Targeted, без пометки всей цепочки грязной. Точечный рефреш ровно нужного view.
  • OnPush + signals здесь не «оптимизация», а естественная модель: нет сигнала/ входа/события — нет и проверки.

Практические выводы

  • Default vs OnPush управляет «рефрешить ли меня, когда обход дошёл» — заметно в Global-режиме (zone.js). В zoneless разница почти схлопывается: всё решают сигналы и RefreshView.
  • zone.js vs zoneless управляет «как часто и от чего запускается обход». zone.js — от любого async; zoneless — только от сигнала/markForCheck/листенера/ input.
  • Самый дорогой режим — zone.js + Default; самый предсказуемый и дешёвый — zoneless + OnPush (+signals).
  • При миграции на zoneless опаснее всего «невидимые» обновления состояния без сигнала/markForCheck (таймеры, сторонние промисы) — раньше их спасала zone.

Привязка к исходникам

  • render3/interfaces/view.ts:398LViewFlags (CheckAlways/Dirty/RefreshView/…)
  • render3/instructions/change_detection.ts:458detectChangesInView (решение рефрешить), :159ChangeDetectionMode (Global/Targeted), :297 — каскад Global на детей
  • application/application_ref.ts:570tick() (+ViewTreeGlobal в zone), :682 — выбор режима корня
  • render3/instructions/mark_view_dirty.ts:26markViewDirty (дорожка к корню)
  • render3/util/view_utils.ts:208,264markViewForRefresh/markAncestorsForTraversal
  • render3/reactive_lview_consumer.ts:48 — сигнал view → markAncestorsForTraversal
  • change_detection/scheduling/zoneless_scheduling_impl.ts:105 — какие нотификации будят tick; ng_zone_scheduling.ts:59 — zone.js → tick(Global)
  • render3/view/listeners.ts:56 — листенер шаблона → markViewDirty