组件查询(Component Queries)解决”在类里拿到模板中某个元素/指令/组件实例”的问题。Angular 有两套写法:传统的 @ViewChild 系列装饰器与 v17.3 起稳定的 viewChild() / contentChild() 等信号查询。v22 中信号查询是推荐方式,装饰器写法仍完全可用,本文对照讲解。
两类查询:视图查询与内容查询
| 类别 | 查什么 | 典型场景 |
|---|---|---|
| 视图查询(View Query) | 组件自己模板中的元素/指令/组件 | 拿 canvas 画图、调用子组件方法 |
| 内容查询(Content Query) | 通过 <ng-content> 投影进来的内容 | Tabs 收集 Tab、表单容器收集控件 |
划分依据是”内容的归属”:写在组件模板里的归视图查询,写在父组件模板、投影进来的归内容查询。两者的 API 是平行的一组:viewChild / viewChildren / contentChild / contentChildren。
装饰器查询:历史写法(仍可用)
import {
AfterViewInit, Component, ContentChild, ContentChildren,
QueryList, ViewChild, ViewChildren,
} from '@angular/core';
@Component({ /* ... */ })
export class ProfileComponent implements AfterViewInit {
@ViewChild('avatar') avatar?: ElementRef<HTMLImageElement>;
@ViewChildren(BadgeComponent) badges!: QueryList<BadgeComponent>;
@ContentChild(HeaderDirective) header?: HeaderDirective;
@ContentChildren(PanelDirective) panels!: QueryList<PanelDirective>;
ngAfterViewInit() {
console.log(this.avatar?.nativeElement.src);
this.badges.changes.subscribe(list => {
// 列表变化时收到通知
});
}
}
要点:
- 查询结果在
ngAfterViewInit/ngAfterContentInit之后才可用;此前是undefined。 static: true可让查询在首次 CD 前解析(仅限不包在@if/@for等结构中的情况),历史上用于”首次渲染就要”的场景。- 多结果查询返回
QueryList<T>,通过.changes(Observable)订阅变化。
信号查询:v22 推荐写法
import {
Component, contentChild, contentChildren,
viewChild, viewChildren,
} from '@angular/core';
export class ProfileComponent {
avatar = viewChild<ElementRef<HTMLImageElement>>('avatar');
badges = viewChildren(BadgeComponent);
header = contentChild(HeaderDirective);
panels = contentChildren(PanelDirective);
}
使用方式与普通信号无异:
<img [src]="avatar()?.nativeElement.src" alt="avatar" />
<span>徽章数:{{ badges().length }}</span>
constructor() {
effect(() => {
console.log('当前面板数', this.panels().length);
});
}
required 查询
当模板结构保证匹配一定存在时,用 required 变体获得非空类型:
export class ChartComponent {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
}
注意:required 查询在视图渲染完成前读取会抛错。模板绑定与用户交互回调中读取是安全的;构造器同步代码里读取则不行。
装饰器查询 vs 信号查询
| 维度 | @ViewChild 等装饰器 | viewChild() 等信号查询 |
|---|---|---|
| 值形态 | 普通属性 + QueryList | Signal<T> / Signal<readonly T[]> |
| 可用时机 | ngAfterViewInit / ngAfterContentInit 之后 | 首次渲染后信号有值,按需读取 |
| required | 不支持,只能手动判空 | viewChild.required / contentChild.required |
| 变化订阅 | QueryList.changes(Observable) | 信号本身即响应式,computed / effect 直接消费 |
| 与 Zoneless 配合 | 需注意查询更新不直接触发刷新 | 天然适配:信号读取自动追踪 |
| 类型推断 | 一般,需要显式泛型 | 泛型 + 字段类型即查询类型 |
| 建议 | 存量代码维护 | v22 新代码首选 |
read 选项:查什么由你决定
模板引用变量 #xxx 的默认读取结果取决于它标注的对象(元素 → ElementRef;组件 → 组件实例)。read 选项可以改读其他形态:
export class HostComponent {
// 读元素上的指令实例
tooltip = viewChild('tip', { read: TooltipDirective });
// 读成视图容器(动态插入内容的位置)
host = viewChild.required('host', { read: ViewContainerRef });
// 读成模板引用
rowTpl = viewChild('row', { read: TemplateRef<unknown> });
}
装饰器写法的等价形式是 @ViewChild('tip', { read: TooltipDirective })。常用 read: ViewContainerRef 与动态组件 / 结构指令配合。
实战一:Tabs 组件(contentChildren + 投影)
内容查询的经典场景:Tabs 收集投影进来的所有 Tab。
// tab.component.ts
@Component({
selector: 'app-tab',
template: `
@if (active()) {
<div class="tab-panel"><ng-content /></div>
}
`,
})
export class TabComponent {
label = input.required<string>();
active = input(false);
}
// tabs.component.ts
@Component({
selector: 'app-tabs',
imports: [TabComponent],
template: `
<nav>
@for (tab of tabs(); track tab.label()) {
<button type="button"
[class.active]="tab.label() === selected()"
(click)="selected.set(tab.label())">
{{ tab.label() }}
</button>
}
</nav>
<ng-content />
`,
})
export class TabsComponent {
tabs = contentChildren(TabComponent);
selected = signal('');
constructor() {
effect(() => {
const first = this.tabs()[0];
if (!this.selected() && first) {
this.selected.set(first.label());
}
});
}
}
使用方——注意 active 由使用者根据 Tabs 暴露的 selected 计算,无需子组件反向依赖父组件:
<app-tabs #tabs>
@for (group of groups(); track group.id) {
<app-tab [label]="group.label" [active]="tabs.selected() === group.label">
{{ group.label }} 的内容
</app-tab>
}
</app-tabs>
实战二:viewChild + DOM 操作
export class ChartComponent {
canvas = viewChild<ElementRef<HTMLCanvasElement>>('canvas');
data = input.required<number[]>();
constructor() {
effect(() => {
const canvas = this.canvas(); // 渲染完成前为 undefined,需要判空
if (!canvas) return;
drawChart(canvas.nativeElement, this.data());
});
}
}
如果”存在性”由模板保证且只在交互后使用,可以升级为 viewChild.required 并在回调里读取。
边界与常见坑
- 查询跨不过组件边界:父组件查不到子组件模板内部的元素。需要访问时让子组件通过
model()/ 方法主动暴露,而不是更深的查询。 - 内容查询只搜索”声明在父组件模板中、投影进本组件”的内容;默认不含更深的嵌套,需要
contentChildren(PanelDirective, { descendants: true })显式深入。 @defer块内的内容在触发加载之前不存在,装饰器查询拿不到;信号查询则会在加载完成后自动更新——这也是信号查询对懒加载更友好的原因。- 同名模板引用变量出现多次时,单值查询(
viewChild)匹配第一个,顺序不保证,应保证命名唯一。 - 视图查询查不到投影内容,内容查询查不到自身模板内容,两套 API 各管一边,不要混用。