CHARLIE SAYS

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

Angular 22+ 教程 10:ng-template 与 TemplateRef

日常写模板,@if / @for 已经够用;但在它们之下还有一层更基础的机制——ng-template 与嵌入式视图(embedded view)。理解这层机制,才能看懂结构型指令的微语法、CDK Portal、动态表格这类”模板作为数据传递”的高级玩法。本文从”什么都不渲染的元素”讲起,直到用它搭出一个动态渲染器。

先看一个”什么都不渲染”的元素

<ng-template>
  <p>你好</p>
</ng-template>

页面空空如也。ng-template 的内容不会自己渲染——它只是一份”模板定义”,被 Angular 编译成 TemplateRef 对象挂在一旁,等某处显式要求渲染时,才会实例化成真实的视图。这正是它与普通元素最本质的区别:

普通元素ng-template
内容何时渲染立即从不(除非被显式渲染)
编译产物立即的视图指令TemplateRef 定义
典型用途页面本体延迟渲染、复用渲染、模板传参

模板引用:#tpl 与 ngTemplateOutlet

最直接的使用方式:模板引用变量拿到 TemplateRef,交给 ngTemplateOutlet 渲染。

<ng-template #greeting>
  <p>欢迎回来!</p>
</ng-template>

<!-- 结构型写法 -->
<ng-container *ngTemplateOutlet="greeting" />

<!-- 等价的属性写法(可与 @if 等控制流共存于同一元素) -->
<ng-container [ngTemplateOutlet]="greeting" />

两种写法的选择:结构型 * 写法会”吃掉”元素上的其他结构型指令,需要与其他控制流并列时用 [ngTemplateOutlet] 属性写法更清晰。ngTemplateOutlet@angular/common 导入。

上下文:let- 与 context

模板可以接收参数化的数据。let- 声明上下文变量,context 传值:

<ng-template #itemTpl let-item let-i="index">
  <li>{{ i + 1 }}. {{ item.title }}</li>
</ng-template>

@for (row of rows(); track row.id; let i = $index) {
  <ng-container
    [ngTemplateOutlet]="itemTpl"
    [ngTemplateOutletContext]="{ $implicit: row, index: i }"
  />
}

规则只有两条:

  • let-item 不带名字,取上下文的 $implicit(“默认值”槽位)。
  • let-i="index" 带名字,取上下文对象的同名属性。

这就是同一份模板渲染不同数据的全部机制——传不同的 context,得到不同的视图。

在组件类中拿到模板:TemplateRef

模板引用变量也能被类代码查询到。v22 的信号式查询写法:

import { Component, TemplateRef, viewChild } from '@angular/core';

@Component({
  selector: 'app-renderer',
  template: `
    <ng-template #rowTpl let-row>
      <span>{{ row.name }}</span>
    </ng-template>
  `,
})
export class RendererComponent {
  rowTpl = viewChild.required<TemplateRef<RowContext>>('rowTpl');
}

另一种常见入口是”挂在 ng-template 上的指令”,通过注入拿 TemplateRef:

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

@Directive({ selector: 'ng-template[appCell]' })
export class CellDirective {
  readonly tpl = inject<TemplateRef<unknown>>(TemplateRef);
}

选择器 ng-template[appCell] 意为”ng-template 且带 appCell 属性”,指令实例化时 DI 容器自动把宿主模板注入进来。viewChildren(CellDirective) 配合这种写法,可以把页面上的多个模板收集成一个字典——后面实战会用到。

渲染它:ViewContainerRef.createEmbeddedView

ViewContainerRef 是”视图容器”,负责在其所在位置插入/移除视图:

import { Component, TemplateRef, ViewContainerRef, inject, viewChild } from '@angular/core';

@Component({
  selector: 'app-host',
  template: `
    <ng-template #toastTpl let-msg>
      <div class="toast">{{ msg }}</div>
    </ng-template>
  `,
})
export class HostComponent {
  private toastTpl = viewChild.required<TemplateRef<{ $implicit: string }>>('toastTpl');
  private vcr = inject(ViewContainerRef);

  showToast(msg: string) {
    this.vcr.createEmbeddedView(this.toastTpl(), { $implicit: msg });
  }

  clear() {
    this.vcr.clear();
  }
}

createEmbeddedView(templateRef, context) 把模板实例化并追加到容器,返回的 EmbeddedViewRef 还支持 destroy() 单独销毁。手动管理视图的机会不多,但 toast、tooltip、动态行这类”调用式产生视图”的场景,它是最趁手的底层工具。

新控制流的底座

ng-template 不止是历史 API,它是整套结构体系的编译目标:

flowchart TD
  A["模板代码"] --> B["块控制流<br/>@if / @for / @switch"]
  A --> C["结构型指令微语法<br/>*ngIf / *ngTemplateOutlet"]
  B --> D["嵌入式视图<br/>TemplateRef + ViewContainerRef"]
  C --> D
  D --> E["DOM"]
  • 旧的 * 微语法会被脱糖(desugar)成 <ng-template> 包裹(第 12 篇详解)。
  • 新的 @if / @for 不再经过指令,但编译产物仍然是”创建/销毁嵌入式视图”这套底层操作,只是由编译器直接生成调用,省掉了指令运行时开销。

所以理解 ng-template 的收益是双重的:读得懂旧代码与第三方库(大量使用 * 语法与 TemplateRef),也更能理解新控制流的性能来源。

何时直接使用 ng-template

把判断标准列成清单:

  • 同一份数据需要多套渲染模板(表格的文本列/徽章列/进度列)——模板即策略。
  • 库与抽象层开发(CDK Portal、可复用容器组件),把”渲染权”交给调用方。
  • 调用式渲染(toast、确认框),需要 ViewContainerRef 动态插入。
  • 单纯的”延迟到某条件再渲染”:用 @if@defer,不要用 ng-template 绕路。

容易混淆的近亲也顺便划清:ng-content 是内容投影(把父组件写的子内容投进来,第 16 篇相关);动态组件(createComponent)渲染的是组件类而非模板(第 15 篇)。

实战:动态单元格渲染器

综合示例——表格列配置里放 TemplateRef,渲染时按列选择模板:

import { Component, Directive, TemplateRef, viewChildren } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

@Directive({ selector: 'ng-template[appCell]' })
export class CellDirective {
  constructor(readonly tpl: TemplateRef<unknown>) {}
}

@Component({
  selector: 'app-data-table',
  imports: [NgTemplateOutlet],
  template: `
    <!-- 列模板:由使用方在内容区声明 -->
    <ng-template appCell let-row>{{ row.name }}</ng-template>
    <ng-template appCell let-row>
      <span class="badge">{{ row.status }}</span>
    </ng-template>

    @for (row of rows(); track row.id) {
      <div class="row">
        @for (cell of cells(); track cell) {
          <ng-container [ngTemplateOutlet]="cell.tpl" [ngTemplateOutletContext]="{ $implicit: row }" />
        }
      </div>
    }
  `,
})
export class DataTableComponent {
  rows = signal([
    { id: 1, name: '订单 A', status: 'PAID' },
    { id: 2, name: '订单 B', status: 'PENDING' },
  ]);
  cells = viewChildren(CellDirective);
}

viewChildren(CellDirective) 把所有挂在 ng-template 上的指令实例收集为信号数组,每个指令持有自己的 TemplateRef——模板的顺序即列的顺序。换列、换样式只需增删 ng-template 声明,渲染逻辑零改动。这正是 Material 表格与各类企业组件库的通用手法。

小结

  • ng-template 的内容默认不渲染,编译为 TemplateRef 等待显式实例化。
  • #tpl + ngTemplateOutlet(context 配合 let-)覆盖大多数复用渲染场景。
  • 类代码用 viewChild / viewChildren 查询模板,或用指令注入 TemplateRef
  • ViewContainerRef.createEmbeddedView 是调用式产生视图的底层入口。
  • * 微语法脱糖为 ng-template,新控制流直接编译为嵌入式视图操作——它是新旧两代语法的共同底座。
  • 模板即数据:列配置、Portal、Toast 都是这一机制的直接应用。

系列导航

← 算法 010:Shell排序(Shell Sort) 目录 设计模式 010:桥接(Bridge) →
← 返回文章列表