ViewChild та ContentChild в Angular
@ViewChild та @ContentChild - це декоратори Angular, що дають прямий доступ до дочірніх елементів шаблону. Де саме живе елемент визначає, який з них використовувати.
Теорія
TL;DR
@ViewChildзвертається до власного шаблону компонента;@ContentChildдо контенту, який батько передав через<ng-content>- Обидва повертають один збіг; для кількох елементів використовуй
@ViewChildren/@ContentChildren @ViewChildдоступний післяngAfterViewInit;@ContentChildпісляngAfterContentInit- Правило вибору: якщо елемент у твоєму шаблоні - ViewChild. Якщо батько його передав - ContentChild
- Angular 17+ має сигнальні варіанти:
viewChild()таcontentChild()
Швидкий приклад
// Батько передає контент у картку:
// <app-card>
// <h2 #title>Мій заголовок</h2> <-- проектований контент
// </app-card>
@Component({
selector: 'app-card',
template: `<ng-content></ng-content>`
})
export class CardComponent implements AfterContentInit {
@ContentChild('title') titleRef!: ElementRef;
ngAfterContentInit() {
console.log(this.titleRef.nativeElement.textContent); // "Мій заголовок"
}
}h2 живе в шаблоні батьківського компонента, а не всередині CardComponent. Тому тут правильний @ContentChild, а @ViewChild повернув би undefined.
Головна різниця
@ViewChild шукає всередині власного шаблону компонента. @ContentChild шукає серед того, що батько передав у слот <ng-content>. Тайминг доступності відповідає цьому поділу: ViewChild готовий у ngAfterViewInit, ContentChild у ngAfterContentInit. Неправильний декоратор повертає undefined без жодного корисного повідомлення про помилку.
Коли що використовувати
@ViewChild: зфокусувати поле форми, викликати метод дочірнього компонента, прочитати DOM-елемент зі свого шаблону@ContentChild: будуєш обгортку на зразок картки, модального вікна або акордеону, якій потрібно взаємодіяти з тим, що всередині передали@ViewChildren: кілька екземплярів через*ngFor, групові операції над дочірніми компонентами@ContentChildren: вкладки, панелі акордеону, будь-який повторюваний проектований контент- Якщо потрібно лише передати дані вниз,
@Input()простіший і жодного запиту не треба
Таблиця порівняння
| Аспект | @ViewChild | @ContentChild |
|---|---|---|
| Що запитує | Власний шаблон компонента | Контент через <ng-content> |
| Доступний після | ngAfterViewInit() | ngAfterContentInit() |
| Кілька елементів | @ViewChildren | @ContentChildren |
| Хто визначає елемент | Сам компонент | Батьківський компонент |
| Типовий сценарій | Поля форм, методи дочірнього компонента | Заголовки карток, панелі вкладок |
Як Angular розв'язує запити
Під час ініціалізації Angular сканує шаблон (або проектований контент) у пошуку елементів за вказаним селектором. Селектор буває двох видів: рядок, що відповідає змінній шаблону (#ref), або тип, що знаходить перший екземпляр компонента чи директиви цього класу. Для @ViewChild сканування відбувається після того, як DOM компонента відрендерився. Для @ContentChild - після того, як батько вставив контент у слот.
Опція static змінює тайминг. { static: true } розв'язує запит до запуску визначення змін, тому результат доступний вже в ngOnInit. Використовуй це лише для елементів, які завжди присутні в шаблоні і ніколи не обгорнені в *ngIf чи *ngFor. На практиці static: true - одна з найбільш занедбаних деталей Angular на співбесідах, а стандартний { static: false } підходить для переважної більшості ситуацій.
Сигнальні запити (Angular 17+)
Angular 17 додав функціональні сигнальні запити як сучасну альтернативу декораторам.
@Component({ /* ... */ })
export class MyComponent {
// Не потрібен lifecycle hook для доступу
nameInput = viewChild.required<ElementRef>('nameInput');
chart = viewChild(ChartComponent);
tabs = viewChildren(TabComponent);
// Запити до проектованого контенту
title = contentChild<ElementRef>('title');
buttons = contentChildren(ButtonComponent);
ngOnInit() {
// Сигнали реактивні, ngAfterViewInit не потрібен
console.log(this.nameInput().nativeElement);
}
}Сигнальні запити реактивні за замовчуванням. Значення оновлюється автоматично при зміні шаблону, без підписки на QueryList.changes.
Типові помилки
1. Звернення до ViewChild в ngOnInit
Представлення компонента ще не відрендерилось на цьому етапі. Результат завжди undefined.
// Неправильно
ngOnInit() {
console.log(this.input.nativeElement); // TypeError
}
// Правильно
ngAfterViewInit() {
console.log(this.input.nativeElement); // працює
}2. Використання @ViewChild для проектованого контенту
// Неправильно - повертає undefined для елементів, переданих батьком
@ViewChild('title') title!: ElementRef;
// Правильно
@ContentChild('title') title!: ElementRef;3. Сприйняття QueryList як статичного знімка
QueryList живий. Закеш довжину раз, і вона застаріє щойно елементи додадуться або прибережуться умовно.
// Неправильно - не реагує на динамічні зміни
const count = this.items.length;
// Правильно
this.items.changes.subscribe(() => {
console.log('Поточна кількість:', this.items.length);
});4. Відсутність опції read для директив
Коли директива та компонент знаходяться на одному елементі, Angular за замовчуванням повертає екземпляр компонента. Використовуй read, щоб отримати директиву.
// Неправильно - повертає компонент, а не директиву
@ViewChild(MyDirective) dir!: MyComponent;
// Правильно
@ViewChild(MyDirective, { read: MyDirective }) dir!: MyDirective;5. Оновлення даних шаблону всередині ngAfterViewInit
Зміна властивості, пов'язаної з шаблоном, у ngAfterViewInit викликає ExpressionChangedAfterItHasBeenCheckedError, бо визначення змін вже завершилось.
// Неправильно
ngAfterViewInit() {
this.title = 'нове значення'; // помилка, якщо title прив'язаний у шаблоні
}
// Правильно
ngAfterViewInit() {
this.title = 'нове значення';
this.cdr.detectChanges(); // запустити ще один прохід
}Де зустрічається в реальних проектах
- Angular Material:
MatTabGroupвикористовує@ContentChildren(MatTab)для збору панелей вкладок від батьківського компонента - Angular Forms:
FormGroupDirectiveвикористовує@ViewChildren(FormControlName)для відстеження всіх зареєстрованих контролів - PrimeNG:
p-dropdownвикористовує@ContentChild(PrimeTemplate)для підтримки кастомних шаблонів елементів - Кастомні модальні вікна:
@ViewChild('closeBtn')для переміщення фокусу на кнопку закриття при відкритті - Таблиці даних:
@ViewChildren(TableRowComponent)для групового оновлення рядків після зміни фільтру
Питання для практики
Q: Яка різниця між @ViewChild('ref') та @ViewChild(MyComponent)?
A: Рядкові референси збігаються зі змінними шаблону (#ref). Типові референси знаходять перший екземпляр цього класу. Типові запити типобезпечні і простіші для рефакторингу при перейменуванні компонента.
Q: Навіщо @ContentChild, якщо є @Input()?
A: @Input() передає дані. @ContentChild дає доступ до фактичного DOM-елемента або екземпляра компонента, щоб можна було викликати методи, читати властивості або застосовувати стилі напряму. Це два різні інструменти для різних завдань.
Q: Чи працює @ViewChild з *ngIf?
A: Так, але результат undefined коли умова хибна. Завжди перевіряй на наявність перед зверненням. Використовуй { static: false } (стандарт) щоб Angular чекав появи елемента перед розв'язанням запиту.
Q: Що відбувається, якщо кілька елементів мають однакову змінну шаблону?
A: @ViewChild повертає лише перший збіг. Це поширена причина багів у циклах. Для отримання всіх елементів використовуй @ViewChildren.
Q (senior): Як побудувати багаторазову обгортку для валідації форми, яка не залежить від того, які поля всередині?
A: Створи FormGroupComponent, що використовує @ContentChildren(FormControlName) для знаходження контролів у <ng-content>. Обгортка обробляє логіку валідації та відображення помилок сама. Батьківський компонент просто огортає поля і отримує валідацію без будь-якої залежності від внутрішньої реалізації обгортки.
Приклади
Базовий: керування нативним полем введення через @ViewChild
@Component({
selector: 'app-search',
template: `
<input #searchInput type="text" placeholder="Пошук...">
<button (click)="focusSearch()">Фокус</button>
`
})
export class SearchComponent implements AfterViewInit {
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
// DOM готовий саме тут
this.searchInput.nativeElement.focus();
}
focusSearch() {
this.searchInput.nativeElement.focus();
}
}@ViewChild розв'язує #searchInput після рендерингу компонента. Виклик .focus() у ngOnInit кинув би помилку, бо елемент ще не існує на тому етапі.
Середній: @ContentChild у обгортці форми
// Обгортка не знає, які поля всередині неї
@Component({
selector: 'app-form-group',
template: `<div class="form-group"><ng-content></ng-content></div>`
})
export class FormGroupComponent implements AfterContentInit {
@ContentChild('password') passwordField!: ElementRef;
ngAfterContentInit() {
// Поле визначено в батьку і проектоване сюди
console.log('Поле готове:', this.passwordField.nativeElement);
}
}
// Батько проектує поле в обгортку
@Component({
selector: 'app-login',
template: `
<app-form-group>
<input #password type="password" placeholder="Пароль">
</app-form-group>
`
})
export class LoginComponent {}Поле #password живе в шаблоні LoginComponent. FormGroupComponent отримує до нього доступ через @ContentChild, бо елемент був проектований всередину, а не визначений у самій обгортці.
Просунутий: жива система вкладок через @ContentChildren
@Component({
selector: 'app-tabs',
template: `
<div class="tab-bar">
<button
*ngFor="let tab of tabs; let i = index"
(click)="selectTab(i)">
{{ tab.label }}
</button>
</div>
<ng-content></ng-content>
`
})
export class TabsComponent implements AfterContentInit {
@ContentChildren('tabPanel') tabPanels!: QueryList<ElementRef>;
tabs: { label: string }[] = [];
ngAfterContentInit() {
this.buildTabs();
// QueryList живий - оновлюється при додаванні або видаленні панелей
this.tabPanels.changes.subscribe(() => this.buildTabs());
}
buildTabs() {
this.tabs = this.tabPanels.map((_, i) => ({ label: `Вкладка ${i + 1}` }));
}
selectTab(index: number) {
this.tabPanels.forEach((panel, i) => {
panel.nativeElement.hidden = i !== index;
});
}
}Використання:
<app-tabs>
<div #tabPanel>Контент 1</div>
<div #tabPanel>Контент 2</div>
<div #tabPanel>Контент 3</div>
</app-tabs>QueryList.changes тримає панель вкладок синхронізованою при динамічних змінах. Без цієї підписки кількість вкладок залишалась би замороженою на початковому значенні.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.