条件渲染与列表渲染是模板的骨架。Angular v17 引入的块状控制流 @if / @for / @switch 在 v22 已是绝对主角:更少的 import、更严的类型检查、更贴近现代语言的语法。本文对照旧的结构型指令(*ngIf / *ngFor / *ngSwitch)讲清新语法的设计动机与全部细节,并补上 @defer 这一延迟加载利器。
为什么要有新的控制流
旧写法的三个痛点:
<!-- 需要 import:NgIf / NgFor 要进组件的 imports(早期是 CommonModule) -->
<li *ngFor="let todo of todos; trackBy: trackById; index as i; odd as isOdd">
{{ i + 1 }}. {{ todo.title }}
</li>
<li *ngIf="todos.length === 0">清单是空的</li>
- 结构型指令本质是指令,必须导入才能用,IDE 自动补全经常漏。
- 微语法(
let x of xs; trackBy: f; index as i)是字符串 DSL,语义靠文档约定。 - 类型收窄能力弱:
*ngIf="user"后模板里的user未必被收窄为非空。
新控制流是编译器原生语法——不需要 import、没有微语法、类型收窄一流:
| 旧 | 新 |
|---|---|
*ngIf="cond" | @if (cond) { } |
*ngIf="cond; else tpl" | @if (cond) { } @else { } |
*ngFor="let x of xs; trackBy: f" | @for (x of xs; track x.id) { } |
*ngSwitch 三件套 | @switch / @case / @default |
| 无对应 | @defer、@empty、@else if |
@if / @else if / @else
@if (order().status === 'paid') {
<p class="ok">已支付,感谢惠顾</p>
} @else if (order().status === 'pending') {
<p>等待支付……</p>
} @else {
<p>订单已取消</p>
}
两个进阶能力:as 别名与类型收窄。
type User = { name: string; email?: string };
user = signal<User | null>(null);
@if (user(); as u) {
<!-- u 已收窄为 User,且在同一块内稳定复用 -->
<p>{{ u.name }}:{{ u.email ?? '未留邮箱' }}</p>
}
as u 把求值结果绑定到局部别名,同时完成非空收窄——过去要靠 *ngIf="user as u; else empty" 加层层嵌套模板才能实现的效果,现在一行搞定。块内还支持写 HTML 注释,模板的可读性维护手段不再受限。
@for 与必须的 track
<ul>
@for (todo of todos(); track todo.id) {
<li [class.done]="todo.done">{{ todo.title }}</li>
} @empty {
<li class="empty">清单是空的,先加一条吧</li>
}
</ul>
三件事必须记住:
**1)track 是强制的。**不写 track 编译直接报错。track 是一个表达式,求值结果作为列表项的身份标识:
@for (todo of todos(); track todo.id) { } <!-- 推荐:稳定业务 id -->
@for (item of items(); track $index) { } <!-- 兜底:下标 -->
2)track 与旧 trackBy 的区别:trackBy 是”函数返回 key”,track 直接是”算 key 的表达式”,省掉样板函数:
// 旧写法需要它
trackById(_: number, todo: Todo) { return todo.id; }
**3)用稳定 id,谨慎用 $index。**列表会重排、增删中间项时,track $index 会导致框架认为”每个位置的内容变了”,DOM 复用退化。只在”只追加、不重排”的列表(日志流)上使用 $index 才安全。
@for 自带一批隐含变量,都在块内可用:
| 变量 | 含义 |
|---|---|
$index | 当前下标 |
$count | 列表长度 |
$first / $last | 是否首/末项 |
$even / $odd | 下标奇偶 |
@for (row of rows(); track row.id; let odd = $odd, i = $index) {
<tr [class.odd]="odd">...</tr>
}
@empty 块在列表为空时渲染,替代了旧的三元判断,空状态文案从此有固定落点。
@switch / @case / @default
type Status = 'idle' | 'loading' | 'success' | 'error';
status = signal<Status>('idle');
@switch (status()) {
@case ('idle') { <p>点击按钮开始</p> }
@case ('loading') { <app-spinner /> }
@case ('success') { <p>加载完成</p> }
@case ('error') { <p>出错了</p> }
}
与 JS 的 switch 不同,@case 不会贯穿,也无需 break。当 @case 分支已穷尽联合类型的所有成员时,若仍书写 @default,其中再引用被匹配的表达式,其类型已是 never——TypeScript 会在编译期把误用标红,相当于免费的穷尽性检查。新写分支型业务(状态机、枚举)时,建议故意删掉一个 @case 看编译器报错,体会这层保护。
@defer:按需渲染的利器
@defer 块及其触发器(trigger)让”昂贵内容延迟出现”成为原生能力:
@defer (on viewport; prefetch on idle) {
<app-heavy-chart [data]="series()" />
} @placeholder {
<div class="chart-skeleton">图表位置</div>
} @loading {
<app-spinner />
} @error {
<p>图表加载失败</p>
}
常用触发器:
| 触发器 | 时机 |
|---|---|
on idle | 浏览器空闲时(可带时长,如 on idle(500ms),要求空闲保持该时长后触发) |
on viewport | 占位元素进入视口 |
on immediate | 立即 |
on hover / on interact | 悬停 / 用户交互(点击、输入) |
on timer(5s) | 定时 |
when expr | 表达式为真 |
prefetch ... | 配合主触发器,提前预取不渲染 |
配套块各司其职:@placeholder(必写的初始占位,支持 minimum 参数避免闪烁)、@loading(触发后加载中)、@error(懒加载失败)。@defer 内的依赖是独立懒加载 chunk 的天然切分边界,“首屏之外的重组件”是它最典型的应用场景;SSR 下服务端会渲染 @placeholder,到客户端再按触发器接管。
嵌套与性能
块可以任意嵌套,语义清晰:
@for (group of groups(); track group.id) {
<section>
<h3>{{ group.name }}</h3>
@if (group.items().length > 0) {
@for (item of group.items(); track item.id) {
<article>{{ item.title }}</article>
}
} @else {
<p>组内暂无条目</p>
}
</section>
}
性能层面的三个事实:
- Zoneless 模式下,
@for依赖的信号变化只触发该列表的重新 diff,与其他视图区域无关。 - track 的质量直接决定 DOM 复用率,列表卡顿时第一件事就是检查 track 表达式。
@if求值开销极低,“用 @if 隐藏复杂子树”依然是性价比最高的优化手段之一。
从旧语法迁移
ng update 升级到 v17+ 时,codemods 会自动把 *ngIf / *ngFor / *ngSwitch 转换为块语法,且转换质量很高(含 as、trackBy 到 track 的映射)。旧的结构型指令本身仍可用(自定义结构型指令的场景第 12 篇展开),但新代码请统一用块语法——它不只是”换种写法”,而是类型检查、tree-shaking、可读性的全面升级。
小结
- 块状控制流是编译器原生语法:无需 import、无微语法、类型收窄完整。
@if支持@else if/@else/as别名;@for必须写track,稳定 id 优先,$index 只用于只追加列表。@empty是空状态的固定落点;@switch无贯穿,穷尽时@default分支为 never,误用编译期暴露。@defer+ 触发器(on idle(500ms)/on viewport/when等)+@placeholder/@loading/@error构成完整的延迟渲染方案。- 迁移交给 codemods,新代码从第一行就用新语法。