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 планируется гонкой
requestAnimationFramevssetTimeout— всегда после текущего кода, вовремя и в видимой, и в фоновой вкладке. markViewDirtyметит путь вверх до корня → tick заходит только в грязные ветки (так работает пропуск чистых OnPush-поддеревьев).- Сигнал уведомляет сам (reactive consumer), обычное поле — нет (нужен
markForCheck).
Это была последняя глубокая глава. Полный путь: код → компиляция → сборка → старт →
runtime → view/LView → селекторы → порталы → DI → директивы → lifecycle → queries →
projection → host → scheduler. Карта всего — в prosto.canvas.