1. 项目概述:为什么 NgTemplateOutlet 是 Angular 组件复用的“隐形杠杆”
在 Angular 项目做到中后期,你大概率会遇到这样一个场景:一个数据表格组件,既要支持默认的行渲染逻辑,又要让业务方能自定义某几列的显示内容;一个弹窗服务,底层封装了遮罩、动画、关闭逻辑,但标题、正文、按钮区域必须由调用方自由决定;甚至是一个简单的卡片容器,背景色、边框样式、内边距都可配,唯独中间的内容区域得完全交出去——这时候,
NgTemplateOutlet
就不是“可选项”,而是你绕不开的“必答题”。它不像
@Input()
那样显眼,也不像
@Output()
那样直白,但它恰恰是 Angular 模板驱动架构里最精巧的“内容插槽”实现机制。核心关键词 Angular、NgTemplateOutlet、Reusable Components、ng-template、TemplateRef,全部指向同一个底层能力:
将模板片段作为一等公民进行传递、存储与动态渲染
。这不是语法糖,而是 Angular 编译器对
TemplateRef
类型的深度支持——它把 HTML 片段编译成可执行的视图工厂函数,再通过
NgTemplateOutlet
这个指令,在运行时按需实例化、插入、销毁。它解决的不是“怎么传数据”的问题,而是“怎么传结构”的问题。适合谁?所有正在写可配置 UI 库、封装通用业务组件、或试图摆脱“复制粘贴式组件开发”的 Angular 开发者。如果你还在用
*ngIf
套一堆
div
来模拟插槽,或者靠
innerHTML
拼接字符串模板,那说明你还没真正打开 Angular 模板系统的“第二层抽屉”。
2. 核心设计思路拆解:从“静态复用”到“动态结构注入”的范式跃迁
2.1 传统复用方式的三大硬伤与 NgTemplateOutlet 的破局点
我们先看三个典型失败案例,它们共同暴露了 Angular 早期复用思维的局限性:
-
方案A:纯
@Input()数据驱动 + 内置模板硬编码
比如一个CardComponent,只接收title: string、content: string、footer: string。问题立刻浮现:当业务方想在标题里加一个带点击事件的图标按钮,或在内容区嵌入一个动态图表组件时,string类型根本承载不了结构信息。你被迫升级为@Input() contentTemplate: TemplateRef<any>,但此时组件内部仍需手动处理createEmbeddedView,代码臃肿且易出错。 -
方案B:
ng-content投影(Content Projection)
这是 Angular 最广为人知的插槽方案,但它有严格限制: 投影内容必须在父组件模板中静态声明,且只能被投影到子组件模板的固定<ng-content>位置 。一旦你需要“条件性地渲染不同模板”、“将同一份模板复用到多个位置”、“在服务层动态决定渲染哪个模板”,ng-content就彻底失效。它本质是编译期绑定,而真实业务需要的是运行时决策。 -
方案C:
ComponentFactoryResolver动态组件加载
虽然灵活,但代价巨大:每次创建都涉及模块解析、依赖注入树重建、变更检测上下文切换。一个简单列表项的模板替换,如果走这条路,性能损耗可能比渲染本身还高。它解决的是“组件级动态”,而非“模板级轻量复用”。
NgTemplateOutlet
的破局逻辑非常清晰:它不创造新组件,不改变依赖树,不触发额外变更检测周期。它只是把一个已编译好的
TemplateRef
(即一段“待执行的HTML蓝图”)当作参数,交给一个轻量级指令去执行渲染。这个过程发生在视图层,完全复用当前组件的
Injector
和
ViewContainerRef
,零额外开销。它的核心价值不是“能做什么”,而是“以什么代价做”——用最小的运行时成本,换取最大的结构灵活性。
2.2 NgTemplateOutlet 的底层工作流:从模板声明到视图实例化的四步闭环
理解
NgTemplateOutlet
,必须穿透指令表层,看到 Angular 编译器与视图引擎的协作链条。整个流程分四步,每一步都决定了它的不可替代性:
第一步:
<ng-template>
的编译 —— 生成
TemplateRef
实例
当你写下
<ng-template #myTpl let-item="item"><span>{{item.name}}</span></ng-template>
,Angular 编译器不会把它当成普通 DOM 元素丢弃。相反,它会:
-
识别
<ng-template>标签,将其标记为“惰性模板”; -
解析其内部结构(
<span>、插值表达式{{item.name}}),生成对应的ViewDefinition(视图定义); -
在组件实例化时,将此定义包装成
TemplateRef对象,并挂载到模板引用变量#myTpl上。
关键点:TemplateRef不是 DOM 片段,而是一个 可执行的视图工厂函数 。它本身不消耗内存,只有调用createEmbeddedView()时才真正分配视图实例。
第二步:
TemplateRef
的传递与持有 —— 跨组件边界的安全载体
TemplateRef
是一个轻量级对象(仅包含
ViewContainerRef
引用和
ViewDefinition
),可以安全地通过
@Input()
、服务注入、甚至 RxJS 流进行传递。例如:
// 父组件
@ViewChild('itemTpl', { read: TemplateRef }) itemTpl!: TemplateRef<{ $implicit: any }>;
constructor(private listService: ListService) {}
ngAfterViewInit() {
this.listService.setRowTemplate(this.itemTpl); // 安全传递
}
这里没有 DOM 序列化/反序列化,没有跨组件通信的复杂性,纯粹是对象引用传递。
第三步:
NgTemplateOutlet
的激活 —— 视图实例化的精确控制点
NgTemplateOutlet
指令的核心逻辑极其简洁:
// 简化版伪代码
ngOnChanges() {
if (this.ngTemplateOutlet) {
// 清除旧视图(如有)
this.viewContainer.clear();
// 创建新视图实例
const viewRef = this.ngTemplateOutlet.createEmbeddedView(
this.ngTemplateOutletContext || {} // 上下文对象
);
// 插入到指定位置
this.viewContainer.insert(viewRef);
}
}
它不关心模板长什么样,只负责“执行”和“插入”。
createEmbeddedView()
的参数
context
是关键:它将一个普通 JS 对象映射为模板内的局部变量(如
let-item
中的
item
)。这个映射关系在编译期就已固化,运行时只需传入对应属性名的对象。
第四步:上下文绑定与作用域隔离 —— 模板沙箱的实现原理
NgTemplateOutletContext
的设计是
NgTemplateOutlet
的灵魂。它确保了模板的“可移植性”:同一份
TemplateRef
,在不同上下文中能渲染出完全不同内容。例如:
<!-- 复用同一模板,但绑定不同数据源 -->
<ng-container *ngTemplateOutlet="rowTpl; context: { $implicit: user }"></ng-container>
<ng-container *ngTemplateOutlet="rowTpl; context: { $implicit: product, index: i }"></ng-container>
这里的
$implicit
是特殊关键字,代表“无名参数”,在模板中直接用
let-item
即可解构。而
index
则需显式声明
let-index="index"
。这种机制让模板彻底脱离具体数据模型,成为纯粹的“渲染逻辑单元”。
2.3 为什么不是
*ngFor
或
*ngIf
?NgTemplateOutlet 的不可替代性矩阵
很多人初学时会混淆
NgTemplateOutlet
与结构指令(如
*ngFor
)。下面这张对比表,直击本质差异:
| 维度 |
*ngFor
/
*ngIf
|
NgTemplateOutlet
| 关键结论 |
|---|---|---|---|
| 模板来源 |
必须在当前模板内声明
<ng-template>
| 可来自任意组件、服务、甚至动态生成 |
NgTemplateOutlet
支持跨组件、跨模块复用
|
| 上下文绑定 |
*ngFor
自动提供
index
,
first
等上下文;
*ngIf
无上下文
| 完全由开发者定义 ,可传入任意结构对象 |
NgTemplateOutlet
提供最大灵活性,无预设约束
|
| 渲染时机 | 与宿主组件生命周期强绑定,无法延迟或条件性触发 |
可在
ngAfterViewInit
、
setTimeout
、甚至
Observable
订阅回调中调用
|
NgTemplateOutlet
支持异步、条件、事件驱动的渲染
|
| 性能开销 | 每次变更检测都会重新评估条件/遍历数组 |
仅在
createEmbeddedView()
调用时创建视图,无额外开销
|
NgTemplateOutlet
是真正的“按需渲染”
|
| 调试体验 | 模板错误直接报在宿主组件 |
错误堆栈精准定位到
TemplateRef
声明处
|
NgTemplateOutlet
更易定位问题根源
|
这个矩阵说明:
NgTemplateOutlet
不是
*ngFor
的替代品,而是它的“增强外挂”。当你需要
*ngFor
的循环能力,同时又要求每一项的渲染结构可由外部定制,二者就是黄金搭档。
3. 核心细节与实操要点:从声明到落地的完整链路
3.1
ng-template
声明的三种姿势与最佳实践
<ng-template>
是
NgTemplateOutlet
的“原材料”,其声明方式直接影响复用效率和可维护性。我总结出三种高频场景下的最优写法:
姿势一:内联模板(Inline Template)—— 适合简单、局部复用
这是最直观的方式,直接在使用组件的模板中声明:
<!-- 父组件模板 -->
<app-data-table [data]="users">
<!-- 使用内联模板覆盖行渲染 -->
<ng-template #rowTemplate let-user="user">
<tr class="user-row">
<td>{{ user.id }}</td>
<td>
<strong>{{ user.name }}</strong>
<span class="badge" [class.active]="user.isActive">状态</span>
</td>
<td>
<button (click)="editUser(user)">编辑</button>
<button (click)="deleteUser(user)">删除</button>
</td>
</tr>
</ng-template>
</app-data-table>
注意:
#rowTemplate是模板引用变量,let-user="user"将user对象注入模板作用域。此处user名称必须与子组件NgTemplateOutlet的上下文键名严格一致。
姿势二:组件级模板(Component Template)—— 适合中等复杂度、跨多处复用
将模板提取到组件类中,通过
@ViewChild
获取,避免模板冗余:
// data-table.component.ts
@Component({
template: `
<table>
<tbody>
<ng-container *ngFor="let item of data; let i = index">
<ng-container
*ngTemplateOutlet="rowTemplate || defaultRowTemplate;
context: { $implicit: item, index: i }">
</ng-container>
</ng-container>
</tbody>
</table>
<!-- 默认模板,兜底使用 -->
<ng-template #defaultRowTemplate let-item="item" let-index="index">
<tr><td colspan="3">ID: {{item.id}} | Name: {{item.name}}</td></tr>
</ng-template>
`
})
export class DataTableComponent implements AfterViewInit {
@Input() data: any[] = [];
@Input() rowTemplate!: TemplateRef<{ $implicit: any; index: number }>;
@ViewChild('defaultRowTemplate', { read: TemplateRef })
defaultRowTemplate!: TemplateRef<{ $implicit: any; index: number }>;
ngAfterViewInit() {
// 确保模板引用在视图初始化后可用
}
}
提示:
@ViewChild查询必须在ngAfterViewInit后才能保证TemplateRef可用。若需在ngOnInit使用,可改用static: false并配合ChangeDetectorRef.detectChanges(),但通常不推荐。
姿势三:服务级模板(Service Template)—— 适合全局复用、主题化、A/B测试
将模板注册到服务中,实现真正的“模板即配置”:
// template-registry.service.ts
@Injectable({ providedIn: 'root' })
export class TemplateRegistryService {
private templates = new Map<string, TemplateRef<any>>();
register(name: string, template: TemplateRef<any>) {
this.templates.set(name, template);
}
get(name: string): TemplateRef<any> | undefined {
return this.templates.get(name);
}
}
// 父组件中注册
@Component({
template: `
<ng-template #userCardTpl let-user="user">
<div class="card user-card">
<h3>{{ user.name }}</h3>
<p>Email: {{ user.email }}</p>
</div>
</ng-template>
`
})
export class UserListComponent implements AfterViewInit {
@ViewChild('userCardTpl', { read: TemplateRef }) userCardTpl!: TemplateRef<any>;
constructor(private registry: TemplateRegistryService) {}
ngAfterViewInit() {
this.registry.register('user-card', this.userCardTpl);
}
}
// 子组件中消费
@Component({
template: `
<ng-container *ngTemplateOutlet="template; context: { $implicit: user }"></ng-container>
`
})
export class UserCardComponent {
@Input() user!: any;
template!: TemplateRef<{ $implicit: any }>;
constructor(private registry: TemplateRegistryService) {}
ngOnInit() {
this.template = this.registry.get('user-card') || this.fallbackTemplate;
}
private fallbackTemplate = createTemplateRef((ctx: { $implicit: any }) =>
`<div class="fallback">${ctx.$implicit.name}</div>`
);
}
注意:
createTemplateRef是一个辅助函数,用于在 TS 中动态创建TemplateRef(需配合Compiler,Angular 9+ 已废弃,推荐用ɵɵtemplate或直接在模板中声明)。实际项目中,更推荐将全局模板统一放在一个SharedTemplatesModule中导出。
3.2
NgTemplateOutletContext
的深度绑定技巧:超越
$implicit
的高级用法
context
对象是
NgTemplateOutlet
的“数据总线”,其设计远比表面复杂。掌握以下技巧,能让你的模板复用能力跃升一个层级:
技巧一:多变量解构与命名空间隔离
避免所有变量挤在
$implicit
下导致命名冲突:
<!-- 父组件 -->
<ng-container *ngTemplateOutlet="headerTpl;
context: {
title: '用户管理',
actions: toolbarActions,
config: { theme: 'dark', size: 'large' }
}">
</ng-container>
<!-- 模板中解构 -->
<ng-template #headerTpl let-title="title" let-actions="actions" let-config="config">
<header [class.theme-dark]="config.theme === 'dark'">
<h1>{{ title }}</h1>
<div class="toolbar">
<ng-container *ngFor="let action of actions">
<button (click)="action.handler()">{{ action.label }}</button>
</ng-container>
</div>
</header>
</ng-template>
提示:
let-X="Y"中的Y是context对象的键名,X是模板内使用的变量名。这种显式绑定让模板意图一目了然,且支持 IDE 智能提示。
技巧二:函数传递与事件代理
context
可以传递函数,实现模板内事件的“反向通信”:
<!-- 父组件 -->
<ng-container *ngTemplateOutlet="itemTpl;
context: {
item: currentItem,
onEdit: () => this.editItem(currentItem),
onDelete: (id: number) => this.deleteItem(id)
}">
</ng-container>
<!-- 模板中调用 -->
<ng-template #itemTpl let-item="item" let-onEdit="onEdit" let-onDelete="onDelete">
<div class="item">
<span>{{ item.name }}</span>
<button (click)="onEdit()">编辑</button>
<button (click)="onDelete(item.id)">删除</button>
</div>
</ng-template>
注意:箭头函数确保
this指向正确。这种方式避免了在模板中写(click)="parent.editItem(...)"的耦合写法,让模板真正“无状态”。
技巧三:响应式上下文(Reactive Context)—— 结合
AsyncPipe
的终极方案
当上下文数据来自
Observable
时,
NgTemplateOutlet
依然优雅:
<!-- 父组件 -->
<ng-container *ngTemplateOutlet="loadingTpl;
context: {
loading$: loadingState$,
error$: errorState$
}">
</ng-container>
<!-- 模板中结合 AsyncPipe -->
<ng-template #loadingTpl let-loading$="loading$" let-error$="error$">
<div *ngIf="(loading$ | async); else errorBlock">
<spinner></spinner>
</div>
<ng-template #errorBlock>
<div *ngIf="(error$ | async) as error" class="error">
{{ error.message }}
</div>
</ng-template>
</ng-template>
提示:
AsyncPipe会自动订阅/取消订阅,无需手动管理。这是构建“响应式 UI 组件”的基石。
3.3 性能优化与内存管理:避免模板泄漏的三大铁律
NgTemplateOutlet
虽轻量,但滥用仍会导致严重问题。我在多个大型项目中踩过坑,总结出必须遵守的三条铁律:
铁律一:永远手动清理
ViewContainerRef
NgTemplateOutlet
创建的视图不会自动销毁。如果
*ngTemplateOutlet
的绑定值频繁变化(如
*ngTemplateOutlet="currentTpl"
),旧视图会持续占用内存:
// ❌ 危险:未清理旧视图
@Input() currentTpl!: TemplateRef<any>;
// ✅ 正确:在 ngOnChanges 中主动清理
ngOnChanges(changes: SimpleChanges) {
if (changes['currentTpl']) {
// 清理旧视图
this.viewContainer.clear();
// 创建新视图
if (this.currentTpl) {
this.viewContainer.createEmbeddedView(this.currentTpl, this.context);
}
}
}
提示:
ViewContainerRef.clear()是关键。它会销毁所有已插入的视图实例及其关联的ChangeDetectorRef,防止内存泄漏。
铁律二:避免在
ngFor
循环内直接使用
*ngTemplateOutlet
看似合理,实则灾难:
<!-- ❌ 千万别这么写! -->
<div *ngFor="let item of items">
<ng-container *ngTemplateOutlet="itemTpl; context: { $implicit: item }"></ng-container>
</div>
问题在于:每次
items
数组变化,Angular 都会为每个
item
重新执行
createEmbeddedView()
,即使模板和上下文未变。正确做法是将
NgTemplateOutlet
封装进子组件:
<!-- ✅ 推荐:封装为独立组件 -->
<app-list-item
*ngFor="let item of items"
[item]="item"
[template]="itemTpl">
</app-list-item>
子组件内部再使用
NgTemplateOutlet
,并利用
OnPush
策略减少不必要的变更检测。
铁律三:谨慎使用
TemplateRef
的
createEmbeddedView
在
ngAfterViewInit
之外
TemplateRef.createEmbeddedView()
必须在 Angular 的变更检测上下文中调用。如果在
setTimeout
、
Promise.then
或第三方库回调中直接调用,可能导致视图不更新:
// ❌ 危险:脱离 Angular 上下文
setTimeout(() => {
this.viewContainer.createEmbeddedView(this.tpl);
}, 1000);
// ✅ 正确:用 `NgZone.run()` 回到 Angular 上下文
this.ngZone.run(() => {
this.viewContainer.createEmbeddedView(this.tpl);
});
提示:
NgZone是 Angular 的执行上下文管理器。所有 DOM 操作、视图创建都应确保在NgZone内执行,否则变更检测不会触发。
4. 实操过程详解:从零构建一个可复用的“智能表格组件”
4.1 需求分析与组件骨架搭建
我们以一个真实业务需求为蓝本:开发一个
SmartTableComponent
,它必须支持:
- 表头(Header)可自定义(支持排序图标、筛选下拉);
- 行(Row)可自定义(支持操作列、状态徽章、嵌套子表格);
- 空状态(Empty State)可自定义(支持图文提示、CTA 按钮);
- 加载状态(Loading)可自定义(支持骨架屏、进度条);
- 所有自定义模板均可独立配置,互不影响。
组件骨架如下:
// smart-table.component.ts
@Component({
selector: 'app-smart-table',
template: `
<div class="smart-table">
<!-- 表头区域 -->
<div class="table-header" *ngIf="!loading$ | async; else loadingState">
<ng-container *ngTemplateOutlet="headerTemplate || defaultHeaderTemplate;
context: { columns: columns }">
</ng-container>
</div>
<!-- 表格主体 -->
<div class="table-body">
<ng-container *ngIf="data?.length; else emptyState">
<ng-container *ngFor="let item of data; let i = index">
<ng-container *ngTemplateOutlet="rowTemplate || defaultRowTemplate;
context: { $implicit: item, index: i, isOdd: i % 2 === 1 }">
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<!-- 加载状态模板 -->
<ng-template #loadingState>
<ng-container *ngTemplateOutlet="loadingTemplate || defaultLoadingTemplate"></ng-container>
</ng-template>
<!-- 空状态模板 -->
<ng-template #emptyState>
<ng-container *ngTemplateOutlet="emptyTemplate || defaultEmptyTemplate"></ng-container>
</ng-template>
<!-- 默认模板声明 -->
<ng-template #defaultHeaderTemplate let-columns="columns">
<div class="header-row">
<div *ngFor="let col of columns" class="header-cell">{{ col.label }}</div>
</div>
</ng-template>
<ng-template #defaultRowTemplate let-item="$implicit" let-index="index" let-isOdd="isOdd">
<div [class.odd]="isOdd" class="row">
<div *ngFor="let col of columns" class="cell">
{{ item[col.field] }}
</div>
</div>
</ng-template>
<ng-template #defaultLoadingTemplate>
<div class="skeleton-row" *ngFor="let i of [1,2,3]">
<div class="skeleton-cell"></div>
<div class="skeleton-cell w-32"></div>
<div class="skeleton-cell w-24"></div>
</div>
</ng-template>
<ng-template #defaultEmptyTemplate>
<div class="empty-state">
<p>暂无数据</p>
<button (click)="onRefresh()">刷新</button>
</div>
</ng-template>
`,
styles: [`
.smart-table { width: 100%; }
.table-header, .table-body { border: 1px solid #eee; }
.header-row, .row { display: flex; }
.header-cell, .cell { flex: 1; padding: 8px; }
.odd { background-color: #f9f9f9; }
.skeleton-row { height: 40px; margin: 4px 0; }
.skeleton-cell { background: #e0e0e0; border-radius: 4px; }
.w-32 { width: 32px; }
.w-24 { width: 24px; }
`]
})
export class SmartTableComponent implements OnChanges, OnDestroy {
@Input() data: any[] = [];
@Input() columns: TableColumn[] = [];
@Input() headerTemplate!: TemplateRef<{ columns: TableColumn[] }>;
@Input() rowTemplate!: TemplateRef<{ $implicit: any; index: number; isOdd: boolean }>;
@Input() loadingTemplate!: TemplateRef<void>;
@Input() emptyTemplate!: TemplateRef<void>;
@Input() loading$!: Observable<boolean>;
@Output() refresh = new EventEmitter<void>();
// 模板引用,用于兜底
@ViewChild('defaultHeaderTemplate', { read: TemplateRef })
defaultHeaderTemplate!: TemplateRef<{ columns: TableColumn[] }>;
@ViewChild('defaultRowTemplate', { read: TemplateRef })
defaultRowTemplate!: TemplateRef<{ $implicit: any; index: number; isOdd: boolean }>;
@ViewChild('defaultLoadingTemplate', { read: TemplateRef })
defaultLoadingTemplate!: TemplateRef<void>;
@ViewChild('defaultEmptyTemplate', { read: TemplateRef })
defaultEmptyTemplate!: TemplateRef<void>;
// 视图容器引用
@ViewChild('tableBody', { read: ViewContainerRef })
tableBody!: ViewContainerRef;
private destroy$ = new Subject<void>();
constructor(private cdRef: ChangeDetectorRef) {}
ngOnChanges(changes: SimpleChanges) {
// 处理模板输入变化,主动清理并重建
if (changes['headerTemplate'] || changes['rowTemplate'] ||
changes['loadingTemplate'] || changes['emptyTemplate']) {
this.cdRef.markForCheck(); // 标记为需检查
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
onRefresh() {
this.refresh.emit();
}
}
export interface TableColumn {
field: string;
label: string;
sortable?: boolean;
filterable?: boolean;
}
4.2 核心模板注入逻辑实现:
NgTemplateOutlet
的精准调度
上面的骨架只是“画布”,真正的魔法在于如何让
NgTemplateOutlet
在正确时机、以正确上下文渲染正确模板。我们重点实现
headerTemplate
和
rowTemplate
的注入逻辑:
步骤一:
headerTemplate
的上下文注入与事件绑定
表头模板需要支持列排序,因此上下文必须包含排序状态和事件处理器:
// smart-table.component.ts(续)
export class SmartTableComponent implements OnChanges, OnDestroy {
// ... 其他代码
// 排序状态
private sortState: { field: string; direction: 'asc' | 'desc' } | null = null;
// 排序处理器
handleSort(field: string) {
if (this.sortState?.field === field) {
// 切换方向
this.sortState.direction = this.sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
// 新字段,重置为 asc
this.sortState = { field, direction: 'asc' };
}
// 发射排序事件
this.sortChange.emit(this.sortState);
}
// 排序事件输出
@Output() sortChange = new EventEmitter<{ field: string; direction: 'asc' | 'desc' }>();
// 为 headerTemplate 构建上下文
get headerContext() {
return {
columns: this.columns,
sortState: this.sortState,
onSort: (field: string) => this.handleSort(field)
};
}
}
对应模板使用:
<!-- 在 template 中 -->
<ng-template #defaultHeaderTemplate let-columns="columns" let-sortState="sortState" let-onSort="onSort">
<div class="header-row">
<div *ngFor="let col of columns" class="header-cell">
<span>{{ col.label }}</span>
<button *ngIf="col.sortable" (click)="onSort(col.field)" class="sort-btn">
<svg *ngIf="sortState?.field === col.field && sortState.direction === 'asc'" viewBox="0 0 10 10">
<path d="M5 0 L0 5 L10 5 Z"/>
</svg>
<svg *ngIf="sortState?.field === col.field && sortState.direction === 'desc'" viewBox="0 0 10 10">
<path d="M0 5 L5 0 L10 5 Z"/>
</svg>
</button>
</div>
</div>
</ng-template>
步骤二:
rowTemplate
的嵌套子表格支持
业务常需“展开行”查看详情,这需要
rowTemplate
能访问子模板:
// smart-table.component.ts(续)
export class SmartTableComponent implements OnChanges, OnDestroy {
// ... 其他代码
// 子表格模板输入
@Input() subRowTemplate!: TemplateRef<{ $implicit: any; parent: any }>;
// 为 rowTemplate 构建上下文,包含子模板
get rowContext() {
return {
$implicit: this.data[0], // 示例,实际在 *ngFor 中传入
index: 0,
isOdd: true,
subRowTemplate: this.subRowTemplate,
hasSubRows: (item: any) => !!item.children?.length
};
}
}
对应模板:
<!-- 在 template 中 -->
<ng-template #defaultRowTemplate
let-item="$implicit"
let-index="index"
let-isOdd="isOdd"
let-subRowTemplate="subRowTemplate"
let-hasSubRows="hasSubRows">
<div [class.odd]="isOdd" class="row">
<div *ngFor="let col of columns" class="cell">
{{ item[col.field] }}
</div>
<div class="action-cell">
<button *ngIf="hasSubRows(item)" (click)="toggleSubRows(item)">
{{ item.expanded ? '收起' : '展开' }}
</button>
</div>
</div>
<!-- 子表格区域 -->
<div *ngIf="item.expanded && subRowTemplate" class="sub-row">
<ng-container *ngTemplateOutlet="subRowTemplate; context: { $implicit: item.children, parent: item }">
</ng-container>
</div>
</ng-template>
4.3 完整使用示例:业务方如何“抄作业”
现在,业务方只需三步即可接入:
第一步:在业务模块中声明模板
<!-- user-management.component.html -->
<app-smart-table
[data]="users"
[columns]="userColumns"
[loading$]="loading$"
(refresh)="loadUsers()"
(sortChange)="onSort($event)">
<!-- 自定义表头 -->
<ng-template #headerTpl let-columns="columns" let-onSort="onSort">
<div class="custom-header">
<div *ngFor="let col of columns" class="col">
<span>{{ col.label }}</span>
<button (click)="onSort(col.field)" *ngIf="col.sortable">
<i class="icon-sort"></i>
</button>
</div>
<div class="actions">
<button (click)="addUser()">新增</button>
</div>
</div>
</ng-template>
<!-- 自定义行模板 -->
<ng-template #rowTpl let-item="$implicit" let-index="index">
<div class="user-row" [class.active]="item.status === 'active'">
<div>{{ item.id }}</div>
<div>
<strong>{{ item.name }}</strong>
<span class="status-badge" [class.online]="item.status === 'active'">
{{ item.status }}
</span>
</div>
<div>{{ item.email }}</div>
<div class="actions">
<button (click)="editUser(item)">编辑</button>
<button (click)="deleteUser(item)">删除</button>
</div>
</div>
</ng-template>
<!-- 自定义空状态 -->
<ng-template #emptyTpl>
<div class="empty-custom">
<img src="/assets/empty-users.svg" alt="无用户">
<h3>暂无用户</h3>
<p>点击下方按钮开始添加您的第一位用户</p>
<button (click)="addUser()">立即添加</button>
</div>
</ng-template>
</app-smart-table>
第二步:在业务组件中绑定模板引用
// user-management.component.ts
@Component({
template: `...`
})
export class UserManagementComponent {
users: User[] = [];
userColumns: TableColumn[] = [
{ field: 'id', label: 'ID' },
{ field: 'name', label: '姓名', sortable: true },
{ field: 'email', label: '邮箱' },
{ field: 'status', label: '状态', sortable: true }
];
loading$ = new BehaviorSubject<boolean>(false);
@ViewChild('headerTpl', { read: TemplateRef }) headerTpl!: TemplateRef<any>;
@ViewChild('rowTpl', { read: TemplateRef }) rowTpl!: TemplateRef<any>;
@ViewChild('emptyTpl', { read: TemplateRef }) emptyTpl!: TemplateRef<any>;
constructor(private table: SmartTableComponent) {}
ngAfterViewInit() {
// 将模板注入到 SmartTableComponent
this.table.headerTemplate = this.headerTpl;
this.table.rowTemplate = this.rowTpl;
this.table.emptyTemplate = this.emptyTpl;
}
}
第三步:享受复用红利
此时,
SmartTableComponent
已完全解耦。你可以:
-
在另一个
ProductListComponent中复用同一套SmartTableComponent,只需传入不同的columns和rowTpl; -
为移动端创建一套
mobileRowTpl,通过@HostBinding切换; -
将
headerTpl注册到TemplateRegistryService,实现主题化切换。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 “模板不渲染”问题的五层排查法
这是最高频问题,我整理了一套系统化排查路径,按优先级从高到低:
**第一层:

366

被折叠的 条评论
为什么被折叠?



