Angular 21 — Фаза 3: Runtime (выполнение, change detection)
Версия 21.0.3, zoneless. Здесь — что происходит «в работе»: как клик по кнопке
превращается в обновление DOM. Раздел подкреплён живыми трейсами профайлера,
снятыми с запущенного приложения (ng serve + Chrome DevTools).
Как стратегия компонента и режим приложения скрещиваются — runtime/01-cd-strategies.md: матрица Default vs OnPush × zone.js vs zoneless (две независимые оси).
Устройство view изнутри: runtime/02-view-data-structures.md (LView/TView/TNode/LContainer), runtime/03-selector-matching.md (поиск селекторов и привязка директив/компонентов), runtime/04-portals-and-dynamic-views.md (динамические view и порталы).
Как снимали живой трейс
В рантайме зарегистрировали внутренний профайлер Angular:
window.ng.ɵsetProfiler((event, instance) => buffer.push(event));
ɵsetProfiler публикуется в dev в глобальный объект ng (см.
packages/core/src/render3/util/global_utils.ts:78). Он вызывает колбэк до/после
ключевых операций рантайма (значения — enum ProfilerEvent из
packages/core/primitives/devtools/src/profiler_types.ts).
Что запускает change detection в zoneless
Нет zone.js. CD инициируется, когда меняется то, на что подписан view:
- сигнал, прочитанный в шаблоне (
count(),doubled()), при его изменении; - явный
markForCheck/ApplicationRef.tick; - асинхронные примитивы, интегрированные с CD (
AsyncPipe,resource, и т.п.).
Изменение сигнала помечает view «грязным» (dirtyFlags) и планирует один
tick через scheduler. Обычная setTimeout/Promise, не трогающая сигналы,
CD НЕ запускает — в этом суть zoneless.
Главный цикл: ApplicationRef.tick
packages/core/src/application/application_ref.ts:
tick() → _tick() → tickImpl():
setActiveConsumer(null) // выходим из любого реактивного контекста
synchronize(): // строки 625+
while (dirtyFlags !== None && runs < 10): // MAXIMUM_REFRESH_RERUNS
synchronizeOnce() // один проход
// dev-режим: повторная проверка checkNoChanges() на каждом view
synchronizeOnce() (строки 654+):
- сбрасывает «грязные» root effects (
rootEffectScheduler.flush()); - для каждого «потенциально грязного» view вызывает
detectChangesInternal(lView, mode);- zoneless →
ChangeDetectionMode.Targeted: проверяются только view, у которых выставленRefreshViewили изменился прочитанный сигнал (а не всё дерево «CheckAlways», как при zone.js global-режиме);
- zoneless →
- выполняет afterRender-хуки.
Цикл while крутится, пока есть грязные флаги (но не больше 10 раз — иначе
ошибка INFINITE_CHANGE_DETECTION). Это позволяет, например, эффекту,
поменявшему другой сигнал, доотрисоваться в том же tick.
detectChangesInternal в итоге вызывает update-проход template-функции
(rf & 2) — те самые ɵɵadvance / ɵɵtextInterpolate / ɵɵconditional /
ɵɵrepeater из фазы Compile.
Живой трейс №1: клик +1 (count 0 → 1)
Последовательность событий профайлера (декодировано):
OutputStart ← сработал (click) listener
OutputEnd ← inc() выполнен: count.update → сигнал изменился, tick запланирован
ChangeDetectionStart ← tickImpl
ChangeDetectionSyncStart ← synchronizeOnce, проход №1
ComponentStart ← обрабатываем App
TemplateUpdateStart ← App_Template(rf&2)
TemplateCreateStart ← @for: создаётся новый <li> (появился элемент списка)
TemplateCreateEnd
TemplateUpdateEnd
TemplateUpdateStart ← update вложенного repeater-view (<li>)
TemplateUpdateEnd
ComponentEnd
(ComponentStart/ComponentEnd — обход дочернего view)
AfterRenderHooksStart
AfterRenderHooksEnd
ChangeDetectionSyncEnd
ComponentStart…TemplateUpdate…ComponentEnd ← DEV: повторная проверка checkNoChanges
ChangeDetectionEnd
Видно ровно то, что в исходниках: Output → tick → synchronizeOnce → detectChanges(update-проход) → afterRender → dev-доппроверка.
Живой трейс №2: переход count 2 → 3 (появляется @if)
При пересечении порога count() > 2 в update-проходе срабатывает
ɵɵconditional(... ? 8 : -1) и создаётся ветка @if. В трейсе на этом цикле
два блока TemplateCreateStart/End вместо одного:
ComponentStart
TemplateUpdateStart
TemplateCreateStart / TemplateCreateEnd ← новый <li> (item 3) от @for
TemplateCreateStart / TemplateCreateEnd ← <p class="big">Больше двух!</p> от @if
TemplateUpdateEnd
... (update вложенных view) ...
ComponentEnd
DOM после трёх кликов (подтверждено): Счётчик: 3 | Удвоенный: 6 | Больше двух! | 1 | 2 | 3. То есть @if смонтировал блок, а @for создал три <li> — именно
точечно, по мере изменения данных.
Почему doubled() и items() пересчитываются «сами»
doubled/items — computed, зависящие от count. При чтении в update-проходе
они лениво пересчитываются, если их зависимость (count) изменилась. Связь
«сигнал → view» устанавливается во время чтения сигнала внутри реактивного
контекста view. Поэтому отдельно «обновлять» их не нужно — достаточно поменять
count.
Структуры данных (кратко)
LView— массив-«экземпляр» view: в ячейках (по индексам из фазы Compile) лежат DOM-узлы, значения биндингов, ссылки на дочерние view. Создаётся в create-проходе.TView— «шаблон» view (shared между всеми экземплярами компонента): метаданные, какой инструкции какой слот, статические данные. Один на класс компонента.- Реактивные узлы (
signal/computed/effect) — отдельный граф; view выступает «потребителем» (consumer), который помечается грязным при изменении источника. См.packages/core/src/render3/reactivity/иprimitives/signals.
Привязка к исходникам
application_ref.ts—tick / _tick / tickImpl / synchronize / synchronizeOncepackages/core/src/render3/instructions/change_detection.ts—detectChangesInternalpackages/core/src/change_detection/scheduling/— zoneless schedulerpackages/core/src/render3/reactivity/— связка signals ↔ viewpackages/core/src/render3/util/global_utils.ts— публикацияng.ɵsetProfiler
Итог фазы Runtime
- В zoneless CD запускает изменение сигнала (или явный markForCheck/tick), а не «любой async».
- Изменение → view помечается грязным → планируется
tick. tick → synchronize → (loop) synchronizeOnce: root effects →detectChangesв Targeted-режиме → afterRender-хуки; в dev — повторная checkNoChanges.detectChangesгоняет update-проход template-функции;@if/@forточечно создают/удаляют DOM черезconditional/repeater.- Подтверждено живым трейсом профайлера на реальных кликах.