Angular内容投影与插槽

内容投影(Content Projection)是Angular中一个强大的特性,允许你将内容从父组件传递到子组件的模板中,实现高度可复用的组件。

核心概念:使用<ng-content>元素作为插槽,在组件模板中指定位置来投影父组件传递的内容。

1. 基础内容投影

父组件模板
定义要投影的内容
投影传递
通过组件标签
子组件插槽
<ng-content>接收

1.1 基本投影示例

// card.component.ts - 子组件
import { Component } from '@angular/core';

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div class="card-header">
        <h3>卡片标题</h3>
      </div>
      <div class="card-body">
        <!-- 内容投影插槽 -->
        <ng-content></ng-content>
      </div>
      <div class="card-footer">
        <p>卡片底部</p>
      </div>
    </div>
  `,
  styles: [`
    .card {
      border: 1px solid #dee2e6;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      overflow: hidden;
      margin: 10px;
    }
    .card-header {
      background-color: #3498db;
      color: white;
      padding: 15px;
    }
    .card-body {
      padding: 20px;
      min-height: 100px;
    }
    .card-footer {
      background-color: #f8f9fa;
      padding: 10px 20px;
      border-top: 1px solid #dee2e6;
      color: #6c757d;
    }
  `]
})
export class CardComponent { }
// app.component.ts - 父组件
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <div class="container">
      <h2>内容投影演示</h2>

      <!-- 基本投影 -->
      <app-card>
        <p>这是投影到卡片内部的内容。</p>
        <ul>
          <li>列表项1</li>
          <li>列表项2</li>
          <li>列表项3</li>
        </ul>
        <button (click)="showAlert()">点击我</button>
      </app-card>

      <!-- 另一个卡片,内容不同 -->
      <app-card>
        <h4>自定义标题</h4>
        <p>不同的卡片内容。</p>
        <img src="assets/image.jpg" alt="示例图片" style="width: 100%;">
      </app-card>
    </div>
  `,
  styles: [`
    .container {
      padding: 20px;
      max-width: 800px;
      margin: 0 auto;
    }
  `]
})
export class AppComponent {
  showAlert(): void {
    alert('按钮被点击了!');
  }
}
卡片标题

这是投影到卡片内部的内容。

  • 列表项1
  • 列表项2
  • 列表项3
卡片标题

自定义标题

不同的卡片内容。

图片占位符

2. 多插槽投影

通过select属性创建多个具名插槽。

// tab-panel.component.ts - 多插槽组件
import { Component } from '@angular/core';

@Component({
  selector: 'app-tab-panel',
  template: `
    <div class="tab-panel">
      <!-- 头部插槽 -->
      <div class="tab-header">
        <ng-content select="[tab-header]"></ng-content>
      </div>

      <!-- 导航插槽 -->
      <div class="tab-nav">
        <ng-content select="[tab-nav]"></ng-content>
      </div>

      <!-- 内容插槽 -->
      <div class="tab-content">
        <ng-content select="[tab-content]"></ng-content>
      </div>

      <!-- 底部插槽 -->
      <div class="tab-footer">
        <ng-content select="[tab-footer]"></ng-content>
      </div>

      <!-- 默认插槽(未匹配的内容) -->
      <div class="tab-default">
        <ng-content></ng-content>
      </div>
    </div>
  `,
  styles: [`
    .tab-panel {
      border: 1px solid #dee2e6;
      border-radius: 8px;
      overflow: hidden;
      margin: 20px 0;
    }
    .tab-header {
      background-color: #2c3e50;
      color: white;
      padding: 15px;
      font-size: 1.2em;
    }
    .tab-nav {
      background-color: #f8f9fa;
      padding: 10px;
      border-bottom: 1px solid #dee2e6;
    }
    .tab-content {
      padding: 20px;
      min-height: 150px;
    }
    .tab-footer {
      background-color: #f8f9fa;
      padding: 10px 20px;
      border-top: 1px solid #dee2e6;
    }
    .tab-default {
      padding: 10px;
      background-color: #fff3cd;
      border-top: 1px solid #ffc107;
    }
  `]
})
export class TabPanelComponent { }
<!-- 父组件使用多插槽 -->
<app-tab-panel>
  <!-- 匹配tab-header插槽 -->
  <div tab-header>
    <h3>用户管理面板</h3>
  </div>

  <!-- 匹配tab-nav插槽 -->
  <div tab-nav>
    <button class="tab-btn active">用户列表</button>
    <button class="tab-btn">添加用户</button>
    <button class="tab-btn">权限设置</button>
  </div>

  <!-- 匹配tab-content插槽 -->
  <div tab-content>
    <h4>用户列表</h4>
    <table>
      <tr><td>张三</td><td>管理员</td></tr>
      <tr><td>李四</td><td>编辑</td></tr>
    </table>
  </div>

  <!-- 匹配tab-footer插槽 -->
  <div tab-footer>
    <span>共2个用户</span>
    <button>导出数据</button>
  </div>

  <!-- 未匹配,进入默认插槽 -->
  <p>这是额外的信息,会显示在默认插槽中。</p>
</app-tab-panel>

多插槽投影可视化

tab-header

用户管理面板

tab-nav
tab-content
张三管理员
李四编辑
tab-footer
共2个用户
默认插槽

这是额外的信息,会显示在默认插槽中。

3. 条件投影

使用ngProjectAs和结构指令控制投影内容。

// conditional-panel.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-conditional-panel',
  template: `
    <div class="conditional-panel">
      <!-- 条件1插槽 -->
      <div *ngIf="showCondition1" class="condition-1">
        <ng-content select="[condition-1]"></ng-content>
      </div>

      <!-- 条件2插槽 -->
      <div *ngIf="showCondition2" class="condition-2">
        <ng-content select="[condition-2]"></ng-content>
      </div>

      <!-- 动态选择插槽 -->
      <div class="dynamic-slot">
        <ng-content select="[dynamic-content]"></ng-content>
      </div>

      <!-- 使用ngProjectAs -->
      <div class="projected-content">
        <ng-content select="app-special-content"></ng-content>
      </div>
    </div>
  `,
  styles: [`
    .conditional-panel {
      border: 1px solid #dee2e6;
      border-radius: 8px;
      padding: 20px;
      margin: 20px 0;
    }
    .condition-1 {
      background-color: #d4edda;
      padding: 15px;
      margin-bottom: 10px;
      border-radius: 4px;
    }
    .condition-2 {
      background-color: #f8d7da;
      padding: 15px;
      margin-bottom: 10px;
      border-radius: 4px;
    }
    .dynamic-slot {
      background-color: #d1ecf1;
      padding: 15px;
      margin-bottom: 10px;
      border-radius: 4px;
    }
    .projected-content {
      background-color: #fff3cd;
      padding: 15px;
      border-radius: 4px;
    }
  `]
})
export class ConditionalPanelComponent {
  @Input() showCondition1 = true;
  @Input() showCondition2 = false;
}
<!-- 使用条件投影 -->
<app-conditional-panel
  [showCondition1]="true"
  [showCondition2]="false">

  <!-- 条件1内容 -->
  <div condition-1>
    <h4>条件1内容</h4>
    <p>当showCondition1为true时显示</p>
  </div>

  <!-- 条件2内容 -->
  <div condition-2>
    <h4>条件2内容</h4>
    <p>当showCondition2为true时显示</p>
  </div>

  <!-- 动态内容 -->
  <div dynamic-content>
    <h4>动态内容</h4>
    <p>总是显示</p>
  </div>

  <!-- 使用ngProjectAs -->
  <div ngProjectAs="app-special-content">
    <h4>通过ngProjectAs投影的内容</h4>
    <p>使用ngProjectAs指定插槽选择器</p>
  </div>

  <!-- 这不会被投影,因为没有匹配的插槽 -->
  <div>
    <p>这个div不会显示,因为没有匹配的插槽选择器</p>
  </div>
</app-conditional-panel>

4. 高级投影技术

4.1 投影内容查询

// 使用ContentChild和ContentChildren查询投影内容
import { Component, ContentChild, ContentChildren, QueryList, AfterContentInit } from '@angular/core';

@Component({
  selector: 'app-accordion',
  template: `
    <div class="accordion">
      <ng-content></ng-content>
    </div>
  `
})
export class AccordionComponent implements AfterContentInit {
  // 查询单个投影子元素
  @ContentChild('accordionHeader') header: any;

  // 查询多个投影子元素
  @ContentChildren('accordionItem') items!: QueryList<any>;

  ngAfterContentInit(): void {
    console.log('投影内容初始化完成');

    if (this.header) {
      console.log('找到accordionHeader:', this.header);
    }

    console.log(`找到${this.items.length}个accordionItem`);

    // 监听投影内容变化
    this.items.changes.subscribe((items: QueryList<any>) => {
      console.log('投影内容变化:', items.length);
    });
  }
}

@Component({
  selector: 'app-accordion-item',
  template: `
    <div class="accordion-item" #accordionItem>
      <div class="accordion-header" #accordionHeader>
        <ng-content select="[accordion-title]"></ng-content>
      </div>
      <div class="accordion-content">
        <ng-content select="[accordion-content]"></ng-content>
      </div>
    </div>
  `
})
export class AccordionItemComponent { }

4.2 动态投影模板

// 使用ng-template和TemplateRef进行动态投影
import { Component, TemplateRef, ContentChild, ViewChild, ViewContainerRef } from '@angular/core';

@Component({
  selector: 'app-template-container',
  template: `
    <div class="template-container">
      <h4>模板容器</h4>

      <!-- 默认插槽 -->
      <div class="default-slot">
        <ng-content></ng-content>
      </div>

      <!-- 动态模板插槽 -->
      <div class="dynamic-template">
        <ng-container #templateContainer></ng-container>
      </div>

      <!-- 条件模板 -->
      <div class="conditional-template" *ngIf="showTemplate">
        <ng-container *ngTemplateOutlet="customTemplate"></ng-container>
      </div>
    </div>
  `
})
export class TemplateContainerComponent {
  @ContentChild('customTemplate') customTemplate!: TemplateRef<any>;
  @ViewChild('templateContainer', { read: ViewContainerRef }) templateContainer!: ViewContainerRef;

  showTemplate = true;

  ngAfterContentInit(): void {
    // 动态创建模板内容
    if (this.customTemplate) {
      this.templateContainer.createEmbeddedView(this.customTemplate);
    }
  }
}
<!-- 使用动态模板 -->
<app-template-container>
  <!-- 普通投影内容 -->
  <p>这是普通投影内容</p>

  <!-- 模板定义 -->
  <ng-template #customTemplate>
    <div class="custom-template-content">
      <h5>动态模板内容</h5>
      <p>这是通过ng-template定义的内容</p>
      <button (click)="onTemplateButtonClick()">模板按钮</button>
    </div>
  </ng-template>
</app-template-container>

5. 实战案例:可复用布局组件

// layout.component.ts - 复杂布局组件
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-page-layout',
  template: `
    <div class="page-layout" [class.sidebar-collapsed]="sidebarCollapsed">
      <!-- 头部区域 -->
      <header class="layout-header">
        <ng-content select="[layout-header]"></ng-content>
      </header>

      <div class="layout-body">
        <!-- 侧边栏 -->
        <aside class="layout-sidebar" *ngIf="showSidebar">
          <ng-content select="[layout-sidebar]"></ng-content>
        </aside>

        <!-- 主内容区 -->
        <main class="layout-main">
          <div class="main-header">
            <ng-content select="[main-header]"></ng-content>
          </div>

          <div class="main-content">
            <ng-content select="[main-content]"></ng-content>
          </div>

          <div class="main-footer">
            <ng-content select="[main-footer]"></ng-content>
          </div>
        </main>
      </div>

      <!-- 全局底部 -->
      <footer class="layout-footer">
        <ng-content select="[layout-footer]"></ng-content>
      </footer>
    </div>
  `,
  styles: [`
    .page-layout {
      display: flex;
      flex-direction: column;
      min-height: 100vh;
    }

    .layout-header {
      background-color: #2c3e50;
      color: white;
      padding: 15px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }

    .layout-body {
      display: flex;
      flex: 1;
    }

    .layout-sidebar {
      width: 250px;
      background-color: #f8f9fa;
      border-right: 1px solid #dee2e6;
      transition: width 0.3s ease;
    }

    .sidebar-collapsed .layout-sidebar {
      width: 60px;
    }

    .layout-main {
      flex: 1;
      padding: 20px;
      display: flex;
      flex-direction: column;
    }

    .main-header {
      margin-bottom: 20px;
    }

    .main-content {
      flex: 1;
      background-color: white;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.05);
    }

    .main-footer {
      margin-top: 20px;
      padding: 10px;
      background-color: #f8f9fa;
      border-radius: 4px;
    }

    .layout-footer {
      background-color: #2c3e50;
      color: white;
      padding: 15px;
      text-align: center;
    }
  `]
})
export class PageLayoutComponent {
  @Input() showSidebar = true;
  @Input() sidebarCollapsed = false;
}
<!-- 使用布局组件 -->
<app-page-layout [showSidebar]="true" [sidebarCollapsed]="false">
  <!-- 头部 -->
  <div layout-header>
    <h2>我的应用</h2>
    <nav>
      <a href="#">首页</a>
      <a href="#">关于</a>
      <a href="#">联系</a>
    </nav>
  </div>

  <!-- 侧边栏 -->
  <div layout-sidebar>
    <ul>
      <li><a href="#">仪表板</a></li>
      <li><a href="#">用户管理</a></li>
      <li><a href="#">设置</a></li>
    </ul>
  </div>

  <!-- 主内容头部 -->
  <div main-header>
    <h3>欢迎回来,张三</h3>
    <button (click)="refresh()">刷新</button>
  </div>

  <!-- 主内容 -->
  <div main-content>
    <h4>今日数据</h4>
    <p>这里显示主要的内容...</p>
    <app-data-table></app-data-table>
  </div>

  <!-- 主内容底部 -->
  <div main-footer>
    <span>最后更新: {{lastUpdated | date}}</span>
  </div>

  <!-- 全局底部 -->
  <div layout-footer>
    <p>© 2024 我的公司. 保留所有权利.</p>
  </div>
</app-page-layout>

6. 性能优化与最佳实践

投影性能优化建议:
  • 使用具名插槽提高可读性和性能
  • 避免在投影内容中使用复杂的选择器
  • 使用OnPush变更检测策略
  • 合理使用ContentChildContentChildren
  • 考虑使用ng-template替代复杂的内容投影
  • 注意投影内容的变化检测范围
  • 使用trackBy优化投影列表
常见问题与解决方案:
  • 投影内容样式问题:使用::ng-deep或CSS变量
  • 变更检测不触发:检查投影组件的变更检测策略
  • 内容无法投影:确保选择器匹配正确
  • 性能问题:避免在投影内容中使用大量DOM操作
  • 生命周期问题:投影内容在父组件中初始化

7. 与Vue/Slot和React/Children的对比

特性 Angular Vue React
基本插槽 <ng-content> <slot> props.children
具名插槽 select="[name]" <slot name=""> 通过props传递
作用域插槽 ng-template v-slot Render Props
默认内容 <ng-content>标签内 <slot>标签内 props.children默认值

8. 测试内容投影

// card.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CardComponent } from './card.component';

describe('CardComponent', () => {
  let component: CardComponent;
  let fixture: ComponentFixture<CardComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [CardComponent]
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(CardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('应该创建组件', () => {
    expect(component).toBeTruthy();
  });

  it('应该正确渲染投影内容', () => {
    // 设置投影内容
    const testContent = '测试投影内容';
    fixture = TestBed.createComponent(CardComponent);
    const compiled = fixture.nativeElement;

    // 模拟父组件传递内容
    const cardElement = compiled.querySelector('app-card');
    cardElement.innerHTML = `

${testContent}

`; fixture.detectChanges(); // 检查内容是否被正确投影 const projectedContent = compiled.querySelector('.card-body p'); expect(projectedContent.textContent).toContain(testContent); }); it('应该包含默认结构元素', () => { const compiled = fixture.nativeElement; expect(compiled.querySelector('.card-header')).toBeTruthy(); expect(compiled.querySelector('.card-body')).toBeTruthy(); expect(compiled.querySelector('.card-footer')).toBeTruthy(); }); it('应该正确响应投影内容变化', () => { const compiled = fixture.nativeElement; // 模拟内容变化 const cardElement = compiled.querySelector('app-card'); cardElement.innerHTML = '
新内容
'; fixture.detectChanges(); const ngContent = compiled.querySelector('.card-body ng-content'); expect(ngContent).toBeTruthy(); }); });

9. 实际应用场景

内容投影适用场景:
  • 布局组件:页头、侧边栏、内容区、页脚
  • UI组件:卡片、模态框、标签页、手风琴
  • 表单组件:带标签的输入框、表单组
  • 导航组件:面包屑、菜单、步骤条
  • 数据展示:表格、列表、详情面板
  • 工具组件:工具提示、弹出框、下拉菜单
  • 业务组件:用户卡片、产品展示、评论区域
注意事项:
  • 投影内容在父组件作用域中编译,而不是子组件
  • 投影内容继承父组件的样式作用域
  • 使用ngProjectAs处理动态组件投影
  • 注意投影组件的生命周期时机
  • 避免在投影内容中使用子组件的私有API
  • 考虑使用服务进行组件间通信
设计模式建议:
  • 复合模式:将多个简单组件组合成复杂组件
  • 策略模式:通过投影内容改变组件行为
  • 模板方法:定义算法骨架,由子类实现具体步骤
  • 装饰器模式:通过包装增加组件功能
  • 工厂模式:动态创建不同类型的投影内容
  • 观察者模式:监听投影内容变化