15 — Zoneless: что будит Angular на пальцах

Цель: понять, что именно запускает проверку (tick) в zoneless-режиме, как из множества событий получается один проход, как метятся «грязные» компоненты и чем это отличается от старого zone.js. Это углубление 04-runtime-cd и 05-onpush-zoneless. Точные file:line — в notes/v21/.

Вопрос главы

В 04-runtime-cd мы сказали: «сигнал изменился → tick → обновился DOM». Но кто ловит это «изменился» и как решает запустить tick один раз, а не сто? Этим занимается scheduler (планировщик).

Старый мир: zone.js (для контраста)

Раньше Angular оборачивал всё приложение в «зону» (Zone.js). Зона патчила все браузерные API: addEventListener, setTimeout, Promise, fetch... Идея:

любой async «чих» (клик, таймер, промис) → зона это перехватила →
когда микротаски закончились (onMicrotaskEmpty) → запустить tick → проверить ВСЁ дерево

Минусы: зона срабатывала на всё подряд, даже когда ничего для UI не поменялось (подвигал мышкой, тикнул чужой таймер) — лишние проверки. Плюс лишний вес Zone.js.

Новый мир: zoneless — «уведомляй явно»

Zoneless убирает зону (provideZonelessChangeDetection(), внутри NgZone = NoopNgZone). Теперь нет тотального перехвата. Вместо этого Angular сам себя уведомляет только тогда, когда реально что-то изменилось для UI:

сигнал изменился / событие в шаблоне / markForCheck / setInput / ... 
        ↓
  scheduler.notify(ИСТОЧНИК)
        ↓
  запланировать ОДИН tick

Ключевое слово — notify. Это единственная «дверь», через которую что угодно может сказать Angular «пора проверить».

Источники уведомлений (кто стучится в дверь)

Список конечен и явный (enum NotificationSource). Самые важные:

• сигнал, прочитанный в шаблоне, изменился   (через reactive consumer)
• markForCheck()  (ручная пометка, в т.ч. из async-пайпа)
• сработал listener в шаблоне  ((click) и т.п.)
• setInput()  (новый вход пришёл компоненту)
• @defer сменил состояние
• view прикрепили/открепили (ViewContainerRef)
• effect() стал грязным

Заметь: «подвигал мышкой над пустым местом» в этот список не входит — в zoneless такое Angular не будит. Отсюда экономия.

Как из многих notify получается ОДИН tick

Если за один синхронный кусок кода случилось 10 изменений (например, обработчик клика поменял 5 сигналов) — будет 10 вызовов notify, но tick один. Механика:

1. первый notify → у scheduler ещё нет запланированного tick
   (pendingRenderTaskId === null) → запланировать колбэк и запомнить его id.
   Заодно проставить в ApplicationRef флаг ЧТО проверять (dirty flag — тип работы).
2. остальные notify → pendingRenderTaskId уже стоит → новый колбэк НЕ планируем
   (просто выходим), флаг «что проверять» при необходимости дополняем.
3. колбэк срабатывает (см. ниже когда) → ApplicationRef.tick() → проверка дерева,
   pendingRenderTaskId сбрасывается.

То есть notify — идемпотентен по планированию: десять стуков в дверь = один поход проверять. За «уже запланировано? — выходим» отвечает pendingRenderTaskId в самом scheduler, а dirty-флаг в ApplicationRef — это про что именно проверять, а не про дедупликацию tick.

Когда именно срабатывает tick: rAF/timeout-гонка

Колбэк не вызывается синхронно сразу — Angular ждёт конца текущего кода и планирует проверку через гонку двух таймеров:

scheduleCallbackWithRafRace:
  запустить ОДНОВРЕМЕННО requestAnimationFrame И setTimeout(0)
  кто первый сработал — тот и запускает tick, второй отменяется

Зачем оба: requestAnimationFrame — для случая, когда вкладка видима (синхронно с кадром браузера, плавно). setTimeout — страховка для фоновых вкладок, где rAF заморожен. Так tick происходит максимально вовремя, но всегда после текущего синхронного кода (а не в середине него).

Как метятся «грязные» view (markViewDirty)

Запланировать tick — половина дела. Надо ещё знать, какие компоненты проверять (особенно при OnPush, см. 05-onpush-zoneless). Для этого используется пометка вверх по дереву:

компонент стал грязным
        ↓
markViewDirty: идём по цепочке LView ВВЕРХ до корня,
ставим флаг на каждом родителе («на пути ко мне есть грязный»)
        ↓
tick спускается от корня и заходит только в ветки с этим флагом

Аналогия: ребёнок испачкался — он вешает табличку «грязь внутри» на дверь каждой комнаты по пути к выходу. Уборщик (tick) идёт от входа и сворачивает только туда, где висит табличка. В чистые комнаты не заходит — это и есть пропуск нетронутых OnPush-веток.

Почему сигнал не требует markForCheck, а обычное поле требует

Вот ответ на ловушку из 05-onpush-zoneless:

СИГНАЛ в шаблоне:
  при первом чтении view «подписывается» на сигнал (reactive consumer).
  сигнал изменился → consumer помечается грязным → сам зовёт markAncestorsForTraversal
  → scheduler.notify. Всё автоматически.

ОБЫЧНОЕ поле (this.x = 5) при OnPush:
  никто не «подписан» — Angular не знает, что поле менялось.
  нужно вручную: markForCheck() (или async-пайп, или сигнал).

Поэтому сигналы и OnPush/zoneless так хорошо дружат: сигнал — это и есть встроенный «уведомитель». Обычное поле — «немое», его изменение Angular не видит.

Карта

ИЗМЕНЕНИЕ (сигнал / событие / markForCheck / setInput / ...)
        │
        ▼
scheduler.notify(source)  ──(pendingRenderTaskId уже стоит? выходим)
        │ первый раз
        ▼
запланировать tick (pendingRenderTaskId) + scheduleCallbackWithRafRace (rAF ⟷ setTimeout)
        │
        ▼
ApplicationRef.tick()
        │  спускается от корня,
        │  заходит только в ветки, помеченные markViewDirty (вверх до корня)
        ▼
detectChanges: проверить грязные view → обновить DOM (update-проход, rf&2)

zone.js (старое): браузерный «чих» → onMicrotaskEmpty → tick по ВСЕМУ дереву
zoneless (новое): только явный notify → один tick → только грязные ветки

Итог

  • В zoneless нет тотального перехвата событий — всё идёт через явный scheduler.notify(источник).
  • Много notify за один синхронный кусок = один tick (флаг + дедупликация).
  • Tick планируется гонкой requestAnimationFrame vs setTimeout — всегда после текущего кода, вовремя и в видимой, и в фоновой вкладке.
  • markViewDirty метит путь вверх до корня → tick заходит только в грязные ветки (так работает пропуск чистых OnPush-поддеревьев).
  • Сигнал уведомляет сам (reactive consumer), обычное поле — нет (нужен markForCheck).

Это была последняя глубокая глава. Полный путь: код → компиляция → сборка → старт → runtime → view/LView → селекторы → порталы → DI → директивы → lifecycle → queries → projection → host → scheduler. Карта всего — в prosto.canvas.