CHARLIE SAYS

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

Angular 22+ 教程 12:结构型指令与微语法

结构型指令(Structural Directives)负责增删 DOM 子树。在 v17 新控制流(@if / @for / @switch / @defer)普及之前,*ngIf / *ngFor / *ngSwitch 是 Angular 模板的绝对主角;如今多数场景已被取代,但微语法(Microsyntax)、TemplateRef / ViewContainerRef 这些底层机制依然是理解 Angular 模板编译的钥匙,自定义结构型指令在特定场景下仍不可替代。

从 *ngIf 与 *ngFor 说起

历史上三者来自 @angular/commonCommonModule

<!-- 历史写法:v22 仍可运行,但新代码应使用 @if / @for -->
<div *ngIf="user().name; else empty">{{ user().name }}</div>

<li *ngFor="let item of items(); index as i; trackBy: trackById">
  {{ i }} - {{ item.name }}
</li>

<ng-template #empty>暂无数据</ng-template>

*ngFor 提供的上下文变量:

变量含义
$implicitlet item 接收的值)当前迭代项
index索引
count总数
first / last是否首项 / 末项
even / odd偶数 / 奇数行

trackBy 的作用与 @fortrack 相同:告诉框架如何识别列表项的 identity,避免不必要的销毁重建。

星号语法与脱糖

* 是结构型指令的语法糖。编译器把:

<div *ngIf="user().name; else empty">...</div>

脱糖(desugar)为:

<ng-template [ngIf]="user().name" [ngIfElse]="empty">
  <div>...</div>
</ng-template>

即:宿主元素被包进 <ng-template>,星号属性变成模板上的方括号绑定。NgIf 本质是一个带 input 的属性型指令,示意如下:

// 简化示意:真实的 NgIf 是类式实现
@Directive({ selector: '[ngIf]' })
export class NgIf {
  ngIf = input(false);
  ngIfElse = input<TemplateRef<unknown> | null>(null);
  // 根据这些 input 创建或销毁内嵌视图
}

*ngFor 的完整脱糖更能体现微语法规则:

<li *ngFor="let item of items(); index as i; trackBy: trackById"></li>
<ng-template ngFor let-item [ngForOf]="items()" let-i="index"
             [ngForTrackBy]="trackById">
  <li>...</li>
</ng-template>

微语法规则可以概括为三条:

  • let item of X:第一个绑定展开为同名 input(ngForOf),let item 接收上下文的 $implicit
  • key: value:后续键值对绑定到指令的 ng{Key} input。
  • let local = key:从指令的渲染上下文取值并声明为局部变量。

TemplateRef 与 ViewContainerRef

理解结构型指令离不开两个核心对象:

  • TemplateRef:模板的”图纸”,本身不产生 DOM,需要被实例化才渲染。
  • ViewContainerRef:视图容器,负责 createEmbeddedView / createComponent / clear
@Component({
  selector: 'app-demo',
  template: `
    <ng-template #tpl let-name>
      <p>Hello, {{ name }}</p>
    </ng-template>
  `,
})
export class DemoComponent {
  private tpl = viewChild.required<TemplateRef<unknown>>('tpl');
  private vcr = inject(ViewContainerRef);

  show() {
    // 用户交互时视图早已渲染,读取 required 查询是安全的
    this.vcr.createEmbeddedView(this.tpl(), { $implicit: 'Angular' });
  }
}

传入的 { $implicit: 'Angular' } 就是渲染上下文,模板里的 let-name 会接收到它。

手写一个结构型指令:appUnless

需求:条件为真时隐藏内容(与 ngIf 相反)。v22 信号时代写法:

import {
  Directive,
  TemplateRef,
  ViewContainerRef,
  effect,
  inject,
  input,
} from '@angular/core';

@Directive({ selector: '[appUnless]' })
export class UnlessDirective {
  private tpl = inject(TemplateRef<unknown>);
  private vcr = inject(ViewContainerRef);

  appUnless = input.required<boolean>();

  constructor() {
    effect(() => (this.appUnless() ? this.clear() : this.render()));
  }

  private render() {
    if (this.vcr.length === 0) {
      this.vcr.createEmbeddedView(this.tpl);
    }
  }

  private clear() {
    this.vcr.clear();
  }
}
<section *appUnless="isReadOnly()">编辑表单…</section>

两个约定值得注意:

  • 在结构型指令中注入 TemplateRef,框架会注入宿主 <ng-template> 的引用——这是脱糖后指令挂在模板上的自然结果。
  • 属性名与 selector 同名(appUnless),脱糖后 [appUnless]="expr" 直接绑定到同名 input;信号 input 与结构型指令完全兼容。

对照历史写法(@Input setter,仍可用):

export class UnlessDirective {
  private tpl = inject(TemplateRef<unknown>);
  private vcr = inject(ViewContainerRef);

  @Input()
  set appUnless(condition: boolean) {
    condition ? this.vcr.clear() : this.create();
  }

  private create() {
    if (this.vcr.length === 0) {
      this.vcr.createEmbeddedView(this.tpl);
    }
  }
}

setter 版本把”声明”与”副作用”耦合在一个属性里;input() + effect 则把数据声明与渲染决策分开,测试时也更容易分别断言。

带类型的渲染上下文

给指令的上下文定义类型,模板里的 let- 变量就有类型提示:

export interface PaginatedContext<T> {
  $implicit: T[];
  pageIndex: number;
  pageCount: number;
}

@Directive({ selector: '[paginated]' })
export class PaginatedDirective<T> {
  private tpl = inject<TemplateRef<PaginatedContext<T>>>(TemplateRef);
  // ... 根据分页状态创建视图并传入上下文
}
<div *paginated="result; let rows; let p = pageIndex; let total = pageCount"></div>

ngTemplateOutlet:零成本复用模板

不写指令也能复用模板,NgTemplateOutlet 同样来自 @angular/common

<ng-template #card let-title let-desc="desc">
  <article>
    <h3>{{ title }}</h3>
    <p>{{ desc }}</p>
  </article>
</ng-template>

<ng-container
  *ngTemplateOutlet="card; context: { $implicit: 'Angular', desc: '一套框架,多种平台' }"
/>

要点:

  • let-title 不带赋值时接收 $implicitlet-desc="desc" 按名称接收。
  • v16.2 起 NgTemplateOutlet 支持 ngTemplateOutletInjector,可为内嵌视图指定注入器。
  • 常见搭配:把模板作为 input 传给列表/表格组件做自定义单元格,比内容投影更轻量。

新控制流之后:还需要结构型指令吗

需求v22 首选
条件渲染@if / @else if / @else
列表渲染@for + track
懒加载组件与依赖@defer
复用同组件内的模板片段ngTemplateOutlet(配合 @let 做中间计算)
运行时决定渲染哪段模板(权限、插槽策略、递归模板)自定义结构型指令
构建库级渲染原语(tooltip、virtual scroll)自定义结构型指令 + CDK

一个仍有代表性的场景——权限渲染:

@Directive({ selector: '[appHasRole]' })
export class HasRoleDirective {
  private auth = inject(AuthService);
  private tpl = inject(TemplateRef<unknown>);
  private vcr = inject(ViewContainerRef);

  appHasRole = input.required<string>();

  constructor() {
    effect(() => {
      this.vcr.clear();
      if (this.auth.hasRole(this.appHasRole())) {
        this.vcr.createEmbeddedView(this.tpl);
      }
    });
  }
}
<button type="button" *appHasRole="'admin'" (click)="remove()">删除</button>

另外记住一条历史规则:同一元素只能挂一个结构型指令(* 只有一个包裹位)。新控制流没有这个限制,@for 内可以自由嵌套 @if——这也是官方推荐迁移的理由之一。

小结

  • * 语法 = <ng-template> 包裹 + 属性绑定,微语法是脱糖规则的约定。
  • TemplateRef 是图纸,ViewContainerRef 是施工队;结构型指令的本质是”根据状态决定是否实例化图纸”。
  • v22 写自定义结构型指令:input() 声明条件 + effect() 驱动增删,替代 @Input setter。
  • 日常条件/列表渲染直接用 @if / @for;自定义结构型指令留给”运行时才知道渲染哪段模板”的场景。

系列导航

← 算法 012:快速排序(Quick Sort) 目录 设计模式 012:装饰(Decorator) →
← 返回文章列表