Change detection: Default vs OnPush × zone.js vs zoneless
Главный вопрос: «как ведёт себя приложение в рантайме с OnPush и без, и как это скрещивается с zone.js / zoneless». Контекст рантайма — 03-runtime.
Сразу суть: это ДВЕ независимые оси
Путаница возникает, потому что смешивают два разных вопроса:
- КТО будит change detection (что вообще планирует
tick) — это про zone.js vs zoneless. Свойство ПРИЛОЖЕНИЯ. - КАКИЕ 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есть →useGlobalCheck→ Global на корне. - 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-режиме пропускается вместе со всем поддеревом.
- OnPush становится грязным, когда: пришло событие из ЕГО шаблона
(
- 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) + свой consumerdirty=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:398—LViewFlags(CheckAlways/Dirty/RefreshView/…)render3/instructions/change_detection.ts:458—detectChangesInView(решение рефрешить),:159—ChangeDetectionMode(Global/Targeted),:297— каскад Global на детейapplication/application_ref.ts:570—tick()(+ViewTreeGlobal в zone),:682— выбор режима корняrender3/instructions/mark_view_dirty.ts:26—markViewDirty(дорожка к корню)render3/util/view_utils.ts:208,264—markViewForRefresh/markAncestorsForTraversalrender3/reactive_lview_consumer.ts:48— сигнал view → markAncestorsForTraversalchange_detection/scheduling/zoneless_scheduling_impl.ts:105— какие нотификации будят tick;ng_zone_scheduling.ts:59— zone.js → tick(Global)render3/view/listeners.ts:56— листенер шаблона →markViewDirty