Angular NgTemplateOutlet:模板复用与动态结构注入核心指南

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 “模板不渲染”问题的五层排查法

这是最高频问题,我整理了一套系统化排查路径,按优先级从高到低:

**第一层:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值