内容投影(Content Projection)是Angular中一个强大的特性,允许你将内容从父组件传递到子组件的模板中,实现高度可复用的组件。
<ng-content>元素作为插槽,在组件模板中指定位置来投影父组件传递的内容。
// 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('按钮被点击了!');
}
}
这是投影到卡片内部的内容。
不同的卡片内容。
通过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>
| 张三 | 管理员 |
| 李四 | 编辑 |
这是额外的信息,会显示在默认插槽中。
使用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>
// 使用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 { }
// 使用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>
// 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>
OnPush变更检测策略ContentChild和ContentChildrenng-template替代复杂的内容投影trackBy优化投影列表::ng-deep或CSS变量| 特性 | Angular | Vue | React |
|---|---|---|---|
| 基本插槽 | <ng-content> |
<slot> |
props.children |
| 具名插槽 | select="[name]" |
<slot name=""> |
通过props传递 |
| 作用域插槽 | ng-template |
v-slot |
Render Props |
| 默认内容 | 在<ng-content>标签内 |
在<slot>标签内 |
props.children默认值 |
// 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();
});
});
ngProjectAs处理动态组件投影