CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-179 · Angular 高级教程

Angular 22+ 教程 14:组件查询——@ViewChild / @ContentChild 与信号查询

组件查询(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() 等信号查询
值形态普通属性 + QueryListSignal<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 各管一边,不要混用。

系列导航

← 算法 014:桶排序(Bucket Sort) 目录 设计模式 014:享元(Flyweight) →
← 返回文章列表