12 — Queries: как найти свой элемент на пальцах

Цель: понять @ViewChild / @ContentChild и новые сигнальные viewChild() / contentChild() — как Angular находит элемент/компонент в шаблоне и когда этот результат готов. Точные file:line — в notes/v21/.

Зачем это вообще

Иногда из кода компонента надо дотянуться до конкретного куска шаблона: получить ссылку на <canvas>, вызвать метод дочернего компонента, измерить <div>. Руками через document.querySelector — плохо (хрупко, ломает SSR, не знает про Angular). Query — это «встроенный, правильный querySelector», который ищет внутри твоего компонента и отдаёт нужный объект.

Что можно искать (предикат)

Query матчит узлы по одному из трёх признаков:

1. по template reference (#ref):   viewChild('chart')   ← ищем <canvas #chart>
2. по типу компонента/директивы:   viewChild(ChildCmp)  ← ищем <app-child>
3. + что вернуть (read):           viewChild('chart', { read: ElementRef })

read уточняет, что именно отдать с найденного узла: сам элемент (ElementRef), инстанс директивы, TemplateRef, ViewContainerRef и т.д.

View query vs Content query — главное различие

Это самая важная развилка. Разница — где искать:

viewChild / @ViewChild     → в МОЁМ СОБСТВЕННОМ шаблоне
                             (то, что я написал в template: `...`)

contentChild / @ContentChild → в СПРОЕЦИРОВАННОМ контенте (<ng-content>)
                               (то, что мне передали между моими тегами снаружи)

Пример: компонент <app-card> со слотом <ng-content>.

<!-- внутри card.html (это VIEW компонента card): -->
<div class="card-header">...</div>      ← сюда дотянется viewChild
<ng-content></ng-content>               ← а сюда упадёт CONTENT

<!-- снаружи, родитель использует card: -->
<app-card>
  <p #note>Заметка</p>                  ← это CONTENT для card → contentChild('note')
</app-card>

Связь с 13-content-projection: спроецированный контент принадлежит родителю, но живёт в дочернем — поэтому для него отдельный вид query.

Когда результат готов (ключевая ловушка)

Результат query появляется не сразу. Привязано к хукам из 11-lifecycle:

contentChild  → готов в ngAfterContentInit  (контент пришёл раньше)
viewChild     → готов в ngAfterViewInit      (свой шаблон собрался позже)

Поэтому классика ошибки: обратиться к @ViewChild в ngOnInit → там ещё undefined, view ещё не построен.

Исключение — static: true: если искомый элемент статичен (не внутри @if/@for, всегда есть), результат доступен раньше — уже в ngOnInit:

@ViewChild('header', { static: true }) header!: ElementRef;  // есть в ngOnInit
@ViewChild('row') row!: ElementRef;                          // только в ngAfterViewInit

Под капотом: два прохода (TQuery + LQuery)

Как и везде в Ivy (см. 06-view-lview), работа делится на «бланк» и «копию»:

firstCreatePass (один раз на класс):
  создаётся TQuery — СТАТИКА: что искать (предикат), какие узлы подходят.
  Angular проходит шаблон и запоминает: «узел #5 совпал, читать ElementRef».

каждый change detection:
  обновляется LQuery — РЕЗУЛЬТАТЫ: берём запомненные совпадения и материализуем
  реальные объекты (ElementRef/инстанс) из текущего LView.

То есть «поиск» по сути происходит на компиляции + первом проходе, а в рантайме лишь собираются готовые объекты по заранее известным индексам. Никакого живого сканирования DOM (как и с селекторами в 07-selektory).

Множественные: children

viewChild ищет один (первый). Для списка — viewChildren / contentChildren:

items = viewChildren(ItemCmp);        // Signal<readonly ItemCmp[]>
@ViewChildren(ItemCmp) old!: QueryList<ItemCmp>;  // старый API

Старый API отдаёт QueryList — это обёртка-список с:

  • .changes — Observable, стреляет когда набор изменился (элементы добавились/убрали);
  • флаг dirty — «надо пересобрать»; Angular сам зовёт reset() при изменениях (@if/@for вставил/убрал view → query помечается грязным → пересобирается).

Сигнальные queries — новый способ

В Angular 21 рекомендованы функции-сигналы вместо декораторов:

// новое:
chart = viewChild<ElementRef>('chart');       // Signal<ElementRef | undefined>
chart = viewChild.required<ElementRef>('chart'); // Signal<ElementRef> (бросит, если нет)
rows  = viewChildren(RowCmp);                  // Signal<readonly RowCmp[]>

// старое:
@ViewChild('chart') chart!: ElementRef;

Плюсы сигнальной версии:

  • результат — обычный signal, читаешь chart(), и он встроен в реактивность: computed/effect сами реагируют на появление/смену элемента;
  • .required убирает undefined из типа (и кидает понятную ошибку, если не нашёл);
  • не нужно гадать про static и хуки — читаешь сигнал там, где он уже есть (например, в afterNextRender или effect).

Под капотом сигнальный query — это computed, который пересчитывается, когда query помечается грязным (тот же механизм dirty, что у QueryList, только завёрнут в сигнал).

Карта

                  ЧТО ИСКАТЬ           ГДЕ                 КОГДА ГОТОВ
viewChild(...)    #ref / тип / read    свой шаблон         ngAfterViewInit (или сигнал)
contentChild(...) #ref / тип / read    <ng-content>        ngAfterContentInit
*Children(...)    то же                то же               то же, но список/QueryList

механизм: TQuery (статика, 1 раз) → LQuery (результаты, каждый CD)
          поиск решён на компиляции; в рантайме — сбор готовых объектов

Итог

  • viewChild — в своём шаблоне; contentChild — в спроецированном контенте.
  • Результат готов в ngAfterViewInit / ngAfterContentInit (или сразу при static: true); в ngOnInit обычного ViewChild ещё нет.
  • Под капотом — TQuery (что искать, один раз) + LQuery (результаты, каждый CD), без живого сканирования DOM.
  • Предпочитай сигнальные viewChild()/contentChild() — результат-сигнал, .required, дружит с computed/effect.

Дальше → 13-content-projection: что такое спроецированный контент, который ищет contentChild.