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.ngsetProfiler((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+):

  1. сбрасывает «грязные» root effects (rootEffectScheduler.flush());
  2. для каждого «потенциально грязного» view вызывает detectChangesInternal(lView, mode);
    • zoneless → ChangeDetectionMode.Targeted: проверяются только view, у которых выставлен RefreshView или изменился прочитанный сигнал (а не всё дерево «CheckAlways», как при zone.js global-режиме);
  3. выполняет 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/itemscomputed, зависящие от 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.tstick / _tick / tickImpl / synchronize / synchronizeOnce
  • packages/core/src/render3/instructions/change_detection.tsdetectChangesInternal
  • packages/core/src/change_detection/scheduling/ — zoneless scheduler
  • packages/core/src/render3/reactivity/ — связка signals ↔ view
  • packages/core/src/render3/util/global_utils.ts — публикация ng.ɵsetProfiler

Итог фазы Runtime

  1. В zoneless CD запускает изменение сигнала (или явный markForCheck/tick), а не «любой async».
  2. Изменение → view помечается грязным → планируется tick.
  3. tick → synchronize → (loop) synchronizeOnce: root effects → detectChanges в Targeted-режиме → afterRender-хуки; в dev — повторная checkNoChanges.
  4. detectChanges гоняет update-проход template-функции; @if/@for точечно создают/удаляют DOM через conditional/repeater.
  5. Подтверждено живым трейсом профайлера на реальных кликах.