结构型指令(Structural Directives)负责增删 DOM 子树。在 v17 新控制流(@if / @for / @switch / @defer)普及之前,*ngIf / *ngFor / *ngSwitch 是 Angular 模板的绝对主角;如今多数场景已被取代,但微语法(Microsyntax)、TemplateRef / ViewContainerRef 这些底层机制依然是理解 Angular 模板编译的钥匙,自定义结构型指令在特定场景下仍不可替代。
从 *ngIf 与 *ngFor 说起
历史上三者来自 @angular/common 的 CommonModule:
<!-- 历史写法: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 提供的上下文变量:
| 变量 | 含义 |
|---|---|
$implicit(let item 接收的值) | 当前迭代项 |
index | 索引 |
count | 总数 |
first / last | 是否首项 / 末项 |
even / odd | 偶数 / 奇数行 |
trackBy 的作用与 @for 的 track 相同:告诉框架如何识别列表项的 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不带赋值时接收$implicit;let-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()驱动增删,替代@Inputsetter。 - 日常条件/列表渲染直接用
@if/@for;自定义结构型指令留给”运行时才知道渲染哪段模板”的场景。