11 — Жизненный цикл и afterRender на пальцах

Цель: понять, в каком порядке Angular зовёт хуки компонента (ngOnInit, ngAfterViewInit и т.д.), почему именно в таком, и что такое afterEveryRender / afterNextRender с их «фазами». Точные file:line — в соседнем vault'е notes/v21/.

Главная идея

Компонент — не просто «создался и живёт». У него есть точки в жизни, в которые Angular даёт тебе вклиниться: «вот сейчас я тебя создал», «вот сейчас обновил входы», «вот сейчас отрисовал детей», «вот сейчас удаляю». Эти точки и есть хуки.

Аналогия: стройка дома.

ngOnChanges       — привезли стройматериалы (входы), сверяем что изменилось
ngOnInit          — фундамент залит, дом «официально начат» (один раз)
ngDoCheck         — прораб обходит стройку на каждой проверке
ngAfterContentInit— заехали жильцы со своей мебелью (спроецированный контент готов)
ngAfterViewInit   — дом достроен целиком, включая все комнаты-дети (один раз)
ngOnDestroy       — дом сносят, выносим вещи (отписки, очистка)

Два прохода — два набора хуков

Помни из 04-runtime-cd: есть create-проход (один раз, строим) и update-проход (много раз, обновляем). Хуки делятся ровно по этому признаку:

Init-хуки (один раз, на create):   Check-хуки (каждый update):
  ngOnInit                            ngDoCheck
  ngAfterContentInit                  ngAfterContentChecked
  ngAfterViewInit                     ngAfterViewChecked

ngOnChanges — особый: зовётся и на create (первые входы), и на update (если входы поменялись).

Точный порядок за один проход

Запомни мнемонику: сверху вниз приходят входы, снизу вверх готовятся дети.

CREATE (первый раз):
  1. ngOnChanges        ← если есть @Input / input()
  2. ngOnInit           ← один раз, «я родился»
  3. ngDoCheck
     ↓ выполняется шаблон (создаются дети)
  4. ngAfterContentInit ← спроецированный <ng-content> на месте
  5. ngAfterViewInit    ← все дочерние компоненты уже прошли свой create
                          (поэтому ViewChild доступен именно здесь!)

UPDATE (каждая проверка):
  1. ngOnChanges        ← если входы изменились
  2. ngDoCheck
     ↓ обновляется шаблон
  3. ngAfterContentChecked
  4. ngAfterViewChecked
  5. afterEveryRender / afterNextRender  ← после ВСЕГО, когда DOM уже отрисован

Почему ngOnInit до детей, а ngAfterViewInit после? Потому что входы текут сверху вниз (родитель → ребёнок), а готовность view собирается снизу вверх: родитель «готов целиком» только когда готовы все его дети. Поэтому @ViewChild гарантированно есть в ngAfterViewInit, а не в ngOnInitngOnInit детей ещё нет).

ngOnChanges и SimpleChanges

ngOnChanges срабатывает только для входов (@Input / input()), и только когда значение реально пришло/изменилось. Под капотом компилятор оборачивает установку входов так, чтобы собрать объект SimpleChanges:

ngOnChanges(changes: SimpleChanges) {
  // changes.title.previousValue / currentValue / firstChange
  if (changes['title']) { ... }
}

Важно: меняешь поле внутри компонента (this.x = 5) — ngOnChanges НЕ позовётся, он только про входы снаружи.

ngOnDestroy — уборка за собой

Когда view уничтожается (@if стало false, элемент @for убрали, ушли со страницы), Angular зовёт ngOnDestroy. Это твоё место, чтобы:

  • отписаться от ручных subscribe() (если не используешь takeUntilDestroyed / async);
  • очистить таймеры, слушатели, веб-сокеты.

Сигнальные effect() и подписки через DestroyRef чистятся сами — для них ngOnDestroy не нужен.

afterEveryRender / afterNextRender — «когда DOM уже на экране»

Хуки выше работают до того, как браузер реально отрисовал пиксели. Если тебе нужно потрогать настоящий DOM (измерить размер, проинициализировать стороннюю библиотеку графиков, поставить фокус) — нужны эти две функции:

constructor() {
  afterNextRender(() => {        // один раз после ближайшего рендера
    this.chart = new Chart(this.canvas.nativeElement);
  });
  afterEveryRender(() => {        // каждый раз после рендера
    console.log('перерисовались');
  });
}
  • afterNextRender — сработает один раз, после первого же завершённого рендера. Идеально для инициализации (как ngAfterViewInit, но гарантирует, что DOM реально в документе).
  • afterEveryRender — срабатывает после каждого рендера. Осторожно: легко словить лишние срабатывания, не делай тут тяжёлого.

Важно: они НЕ запускаются на сервере (SSR) — только в браузере. Это специально: измерять DOM на сервере нечего.

Фазы after-render-хуков: earlyRead → write → mixedReadWrite → read

Вот то самое уточнение. У afterEveryRender можно указать фазу. Зачем вообще фазы?

Проблема называется layout thrashing («дёргание лейаута»). Если в коде чередовать «прочитал размер из DOM → записал в DOM → снова прочитал → снова записал», браузер вынужден пересчитывать лейаут на каждом чтении — это тормозит. Решение: сгруппировать все чтения вместе, все записи вместе. Фазы — это и есть способ сказать Angular, к какой группе относится твой колбэк, чтобы он выполнил их в правильном порядке.

Порядок выполнения фаз за один рендер:

1. earlyRead       — прочитать DOM ДО записей (если запись зависит от размера)
2. write           — только писать в DOM (ничего не читать!)
3. mixedReadWrite  — и читаю, и пишу вперемешку  ← ДЕФОЛТ, но самый медленный
4. read            — только читать DOM после всех записей

Та формулировка из доков — «эффект, который вызывается после рендера, в фазе mixedReadWrite» — описывает поведение по умолчанию: если ты просто передал функцию без указания фазы, она попадает в mixedReadWrite. Это безопасно (можно всё), но наименее оптимально, потому что Angular не может сгруппировать твои чтения и записи с чужими — фаза «и туда и сюда».

// по умолчанию = mixedReadWrite (работает, но не оптимально)
afterEveryRender(() => { ... });

// оптимально — явно разделить чтение и запись:
afterEveryRender({
  read:  () => { this.height = el.offsetHeight; },   // только читаю
  write: () => { el.style.transform = '...'; },      // только пишу
});

Правило на практике: если можешь — раздели на read и write, не сваливай всё в дефолтный mixedReadWrite. Тогда браузер сделает один пересчёт лейаута, а не десять. Официальная рекомендация: предпочитай read и write, а earlyRead и mixedReadWrite — только когда иначе никак.

afterRenderEffect — «фазы + реактивность»

afterNextRender / afterEveryRender — это простые колбэки: положил функцию, она вызвалась после рендера. afterRenderEffect (@angular/core) — это то же самое, но с суперсилой effect(): см. реактивность в 04-runtime-cd.

Два ключевых отличия:

1. Реактивность — перезапускается сам. Обычный afterEveryRender срабатывает на каждый рендер. afterRenderEffect — «грязный» только когда изменился сигнал, который он прочитал. То есть он перезапустится, лишь если поменялись его реальные зависимости (плюс минимум один раз). Это как effect(), но гарантированно после отрисовки DOM.

size = signal({ w: 0, h: 0 });

constructor() {
  afterRenderEffect({
    write: () => {
      const s = this.size();              // прочитали сигнал → это зависимость
      this.el.nativeElement.style.width = s.w + 'px';
    },
  });
  // перезапустится ТОЛЬКО когда size() изменится, а не на каждый рендер
}

2. Значения текут между фазами как Signal. Те же 4 фазы (earlyRead → write → mixedReadWrite → read), но теперь это отдельные эффекты в цепочке: первая фаза не получает аргументов, а каждая следующая получает результат предыдущей как Signal. Это и есть «правильный» способ сделать «измерил → записал → проверил» без дёргания лейаута:

afterRenderEffect({
  earlyRead: () => this.el.nativeElement.offsetHeight,   // вернули число
  write: (height) => {                                   // height: Signal<number>
    this.el.nativeElement.style.transform = `translateY(${height()}px)`;
    return height();                                      // передаём дальше
  },
  read: (shifted) => {                                    // shifted: Signal<number>
    console.log('после сдвига', shifted());
  },
});

Поток: earlyRead всё прочитал (но не писал) → его результат пришёл в write как сигнал → write всё записал (но не читал) → результат в read. Браузер делает один проход лейаута, а не чередует чтение/запись.

Когда что брать:

  • разово инициализировать (график, фокус) → afterNextRender({ write });
  • реакция на изменение сигнала с доступом к DOM, без layout thrashing → afterRenderEffect с разделением на фазы;
  • просто «что-то после каждого рендера» (логи, дёшево) → afterEveryRender.

Осторожно: на момент колбэка компонент может быть ещё не гидратирован (см. SSR) — не полагайся слепо на готовность DOM при гидратации.

Карта порядка

                 СОЗДАНИЕ                         ОБНОВЛЕНИЕ
входы↓  ngOnChanges → ngOnInit → ngDoCheck   ngOnChanges → ngDoCheck
              ↓ шаблон/дети                        ↓ шаблон/дети
контент ngAfterContentInit                    ngAfterContentChecked
дети↑   ngAfterViewInit                        ngAfterViewChecked
                                               ↓
                                   afterEveryRender (earlyRead→write→mixedReadWrite→read)

       ... и в конце жизни: ngOnDestroy → очистка слушателей/эффектов

Итог

  • Init-хуки — один раз (create), Check-хуки — каждый раз (update).
  • Входы текут сверху вниз (ngOnChanges/ngOnInit рано), готовность собирается снизу вверх (ngAfterViewInit поздно) — поэтому @ViewChild есть только после view.
  • this.x=5 внутри не зовёт ngOnChanges — он только про входы.
  • Трогать реальный DOM — через afterNextRender (один раз) / afterEveryRender (каждый раз), и только в браузере.
  • Фазы (read/write) нужны, чтобы не дёргать лейаут; дефолт mixedReadWrite — самый простой, но самый медленный, лучше разделять.

Дальше → 12-queries: как @ViewChild / viewChild() реально находят элемент.