12_自定义组件实践
自定义组件深度开发
自定义组件是 Angular 应用的核心构建块,其设计质量直接决定了应用的可维护性、复用性和扩展性。本章将从组件通信、复用策略和样式隔离三个维度,深入探讨自定义组件的高级开发技巧,结合 Angular 最新信号式 API 与设计模式,打造健壮、灵活的组件体系。
1 组件通信模式:高效数据流转
组件通信是组件协作的基础,Angular 提供了多样化的通信方案,需根据组件关系(父子、跨层级)和数据特性(单向 / 双向、静态 / 动态)选择适配模式。
1.1 输入信号:input() 与组件入参管理
Angular v17 + 引入的信号式输入(input()函数)替代了传统@Input()装饰器,提供更简洁的语法和响应式能力,支持默认值、必填校验、值转换等高级特性。
基础用法与默认值
输入信号本质是只读信号(ReadonlySignal),组件接收的入参会自动转为响应式数据:
// user-card.component.ts
import { Component, input } from '@angular/core';
interface User {
id: number;
name: string;
}
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<h3>{{ username() }}</h3>
<p>等级:{{ userLevel() }}</p>
</div>
`
})
export class UserCardComponent {
// 基础输入:带默认值的字符串类型
username = input('匿名用户');
// 数字类型输入:默认值0
userLevel = input<number>(0);
// 复杂对象类型输入:默认值为undefined
user = input<User>();
}
父组件传递参数时,直接通过属性绑定赋值:
<!-- 父组件模板 -->
<app-user-card
[username]="'张三'"
[userLevel]="3"
[user]="{ id: 1, name: '张三' }"
></app-user-card>
高级特性:必填校验与值转换
输入信号支持必填校验和值转换,强化组件入参的规范性:
// product.component.ts
import { Component, input, InputSignalWithTransform } from '@angular/core';
@Component({
selector: 'app-product',
standalone: true,
template: `<p>{{ formattedPrice() }}</p>`
})
export class ProductComponent {
// 1. 必填输入:未传递时抛出错误
productId = input.required<number>();
// 2. 值转换:自动格式化价格(字符串→数字→保留两位小数)
price: InputSignalWithTransform<number, string | number> = input(0, {
transform: (value) => {
const num = Number(value);
return isNaN(num) ? 0 : Number(num.toFixed(2));
}
});
// 派生信号:基于输入计算衍生值
formattedPrice = computed(() => `¥${this.price()}`);
}
注意:
input.required()需 Angular v16 + 支持,缺失必填参数会在运行时抛出明确错误,便于调试。
1.2 输出信号:output() 与事件通知
信号式输出(output()函数)替代@Output()+EventEmitter,简化事件发布逻辑,支持与 RxJS 无缝集成。
基础事件发布
输出信号通过emit()方法发送事件,父组件通过事件绑定监听:
// rating.component.ts
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-rating',
standalone: true,
template: `
@for (star of [1,2,3,4,5]; track star) {
<span
class="star"
[class.active]="star <= value()"
(click)="onStarClick(star)"
>★</span>
}
`,
styles: [`
.star { cursor: pointer; font-size: 20px; }
.active { color: #ffd700; }
`]
})
export class RatingComponent {
// 输入:当前评分
value = input<number>(0);
// 输出:评分变化事件
rateChange = output<number>();
onStarClick(star: number) {
this.rateChange.emit(star); // 发送事件数据
}
}
父组件监听事件:
// 父组件类
selectedRate = 0;
onRateChange(newRate: number) {
this.selectedRate = newRate;
console.log('评分变为:', newRate);
}
<!-- 父组件模板 -->
<app-rating
[value]="selectedRate"
(rateChange)="onRateChange($event)"
></app-rating>
双向绑定简化:model() 语法糖
对于 “输入 + 输出” 的双向通信场景,可使用model()函数简化为双向绑定:
// counter.component.ts
import { Component, model } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="count.update(v => v-1)">-</button>
<span>{{ count() }}</span>
<button (click)="count.update(v => v+1)">+</button>
`
})
export class CounterComponent {
// model() = input() + output(),自动生成countChange输出信号
count = model<number>(0);
}
父组件直接使用双向绑定语法:
<!-- 父组件模板:双向绑定 -->
<app-counter [(count)]="parentCount"></app-counter>
1.3 依赖注入:跨层级组件数据共享
依赖注入(DI)是 Angular 跨层级组件通信的核心方案,通过共享服务实现组件解耦,避免 “props 钻取” 问题。其核心原理是通过注入器层级(元素注入器→模块注入器)实现依赖查找与共享。
1. 单例服务共享全局状态
通过providedIn: 'root'创建全局单例服务,存储跨组件共享状态:
// theme.service.ts(全局状态服务)
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' }) // 根注入器提供单例
export class ThemeService {
// 私有信号:存储原始状态
private _darkMode = signal(false);
// 公共计算信号:暴露只读状态
isDarkMode = computed(() => this._darkMode());
// 状态修改方法
toggleTheme() {
this._darkMode.update(mode => !mode);
}
}
任意组件注入服务即可共享状态:
// header.component.ts(修改状态)
import { Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-header',
standalone: true,
template: `
<button (click)="themeService.toggleTheme()">
切换{{ themeService.isDarkMode() ? '浅色' : '深色' }}模式
</button>
`
})
export class HeaderComponent {
themeService = inject(ThemeService); // 注入服务
}
// content.component.ts(读取状态)
import { Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-content',
standalone: true,
template: `
<div [class.dark]="themeService.isDarkMode()">
内容区域
</div>
`,
styles: [`
.dark { background: #333; color: white; }
`]
})
export class ContentComponent {
themeService = inject(ThemeService); // 注入服务
}
2. 组件级注入:隔离局部状态
对于局部共享场景(如标签页、弹窗组),可在组件元数据中配置providers,创建组件级服务实例:
// tab-group.component.ts(容器组件)
import { Component, provide } from '@angular/core';
import { TabService } from './tab.service';
@Component({
selector: 'app-tab-group',
standalone: true,
providers: [TabService], // 组件级注入:每个TabGroup有独立服务实例
template: `
<div class="tab-buttons">
<ng-content select="[tab-button]"></ng-content>
</div>
<div class="tab-content">
<ng-content select="[tab-content]"></ng-content>
</div>
`
})
export class TabGroupComponent {}
注入器优先级:组件级注入器 > 模块注入器 > 根注入器,确保局部状态不污染全局。
2 组件复用策略:提升开发效率
组件复用是降低冗余代码、提升开发效率的关键。Angular 提供内容投影、继承、指令组合等多种复用方案,需根据场景选择适配策略。
2.1 内容投影:灵活插槽与模板分发
内容投影(Content Projection)允许父组件向子组件注入动态内容,实现 “组件外壳 + 动态内容” 的复用模式,核心技术包括ng-content(静态投影)和ng-template(动态投影)。
1. 单插槽投影:基础灵活布局
单插槽投影适用于简单的内容注入场景,子组件通过ng-content定义插槽:
// card.component.ts(复用外壳)
import { Component } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<div class="card-header">
<h3>{{ title() }}</h3>
</div>
<div class="card-body">
<ng-content></ng-content> <!-- 内容插槽 -->
</div>
</div>
`,
styles: [`
.card { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin: 1rem; }
.card-header { border-bottom: 1px solid #eee; margin-bottom: 1rem; }
`]
})
export class CardComponent {
title = input<string>('默认标题');
}
父组件注入动态内容:
<!-- 父组件模板:注入不同内容 -->
<app-card title="用户信息">
<p>姓名:张三</p>
<p>年龄:25</p>
</app-card>
<app-card title="商品详情">
<img src="product.jpg" alt="商品图片">
<p>价格:¥99</p>
</app-card>
2. 多插槽投影:结构化内容分发
多插槽投影通过select属性指定插槽标识,实现内容的精准分发,典型案例如 “汉堡组件” 的分层结构:
// burger.component.ts(多插槽容器)
import { Component } from '@angular/core';
@Component({
selector: 'app-burger',
standalone: true,
template: `
<div class="burger">
<!-- 顶部插槽:匹配[top]属性 -->
<ng-content select="[top]"></ng-content>
<!-- 中间插槽:匹配[middle]属性 -->
<div class="burger-middle">
<ng-content select="[middle]"></ng-content>
</div>
<!-- 底部插槽:匹配[bottom]属性 -->
<ng-content select="[bottom]"></ng-content>
</div>
`,
styles: [`
.burger { width: 200px; margin: 1rem; }
.burger-middle { border-top: 1px dashed #ccc; border-bottom: 1px dashed #ccc; padding: 0.5rem 0; }
`]
})
export class BurgerComponent {}
父组件按插槽注入内容:
<app-burger>
<!-- 顶部内容 -->
<div top class="bun top">上层面包</div>
<!-- 中间内容 -->
<div middle class="cheese">芝士</div>
<div middle class="meat">肉饼</div>
<!-- 底部内容 -->
<div bottom class="bun bottom">下层面包</div>
</app-burger>
3. 动态投影:ng-template 与条件渲染
对于需要条件渲染或重复渲染的内容,需使用ng-template+ngTemplateOutlet实现动态投影,避免静态内容的冗余初始化:
// collapse.component.ts(动态折叠面板)
import { Component, input, ContentChild, TemplateRef } from '@angular/core';
@Component({
selector: 'app-collapse',
standalone: true,
template: `
<div class="collapse-header" (click)="isOpen = !isOpen">
{{ title() }} {{ isOpen ? '↑' : '↓' }}
</div>
<!-- 动态渲染模板 -->
<ng-container *ngIf="isOpen">
<ng-template [ngTemplateOutlet]="contentTemplate"></ng-template>
</ng-container>
`
})
export class CollapseComponent {
title = input<string>('');
isOpen = false;
// 获取投影的模板引用
@ContentChild('content') contentTemplate!: TemplateRef<any>;
}
父组件提供模板内容:
<app-collapse title="折叠面板">
<!-- 模板内容:仅在展开时初始化 -->
<ng-template #content>
<p>动态加载的内容...</p>
<app-user-card [username]="'张三'"></app-user-card>
</ng-template>
</app-collapse>
2.2 组件继承与指令组合
组件复用需平衡灵活性与耦合度,组件继承适用于强关联的组件,指令组合则适用于弱关联的功能增强。
1. 组件继承:复用基础逻辑
组件继承通过extends关键字实现,父组件提供基础逻辑,子组件扩展特有功能:
// base-form.component.ts(基础父组件)
import { Component, input } from '@angular/core';
@Component({ template: '' }) // 抽象基础组件,无具体模板
export abstract class BaseFormComponent {
// 共享输入:表单标题
formTitle = input<string>('');
// 共享方法:表单验证
protected validateForm(): boolean {
console.log('执行基础验证逻辑');
return true;
}
// 抽象方法:子组件必须实现
abstract submitForm(): void;
}
// login-form.component.ts(子组件)
import { Component } from '@angular/core';
import { BaseFormComponent } from './base-form.component';
@Component({
selector: 'app-login-form',
standalone: true,
template: `
<h3>{{ formTitle() }}</h3>
<input type="text" placeholder="用户名">
<input type="password" placeholder="密码">
<button (click)="submitForm()">登录</button>
`
})
export class LoginFormComponent extends BaseFormComponent {
// 实现抽象方法
submitForm(): void {
if (this.validateForm()) {
console.log('执行登录逻辑');
}
}
}
注意:组件继承易导致耦合度升高,建议仅在 “is-a” 关系(如 LoginForm 是 Form)中使用。
2. 指令组合:解耦功能增强
指令组合通过属性指令为组件添加附加功能,实现 “组件 + 指令” 的灵活复用,典型案例如表单输入防抖:
// debounce.directive.ts(防抖指令)
import { Directive, HostListener, Input, Output, EventEmitter, OnDestroy, OnInit } from '@angular/core';
import { Subject, debounceTime, takeUntil } from 'rxjs';
@Directive({
selector: '[appDebounce]',
standalone: true
})
export class DebounceDirective implements OnInit, OnDestroy {
@Input() debounceTime = 300; // 防抖时间
private inputSubject = new Subject<string>();
private destroy$ = new Subject<void>();
@HostListener('input', ['$event.target.value'])
onInput(value: string) {
this.inputSubject.next(value);
}
ngOnInit() {
this.inputSubject
.pipe(debounceTime(this.debounceTime), takeUntil(this.destroy$))
.subscribe(value => {
// 触发防抖后的回调(通过输出信号或事件)
this.onDebounce.emit(value);
});
}
// 输出防抖后的结果
@Output() onDebounce = new EventEmitter<string>();
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
组件组合指令使用:
<!-- 任意输入框均可添加防抖功能 -->
<input
type="text"
appDebounce
[debounceTime]="500"
(onDebounce)="handleSearch($event)"
placeholder="搜索..."
>
3 组件样式隔离:避免冲突与主题定制
样式隔离是组件封装的重要维度,Angular 通过ViewEncapsulation控制样式作用域,结合 CSS 变量实现灵活的主题定制。
3.1 ViewEncapsulation:样式作用域控制
ViewEncapsulation定义组件样式的隔离方式,Angular 提供三种模式,适用于不同场景:
| 模式 | 原理 | 适用场景 |
|---|---|---|
| Emulated | 为组件元素添加唯一属性(如_ngcontent-c1),样式自动附加属性选择器,模拟 Shadow DOM |
绝大多数场景,平衡隔离与灵活性 |
| ShadowDom | 使用原生 Shadow DOM,样式完全隔离在 Shadow 树内 | 需严格隔离的组件(如组件库) |
| None | 样式无隔离,直接全局生效 | 全局样式重置或第三方库集成 |
模式对比与实践
import { Component, ViewEncapsulation } from '@angular/core';
// 1. 默认模式:Emulated
@Component({
selector: 'app-emulated',
standalone: true,
template: `<p>Emulated模式</p>`,
styles: [`p { color: red; }`],
encapsulation: ViewEncapsulation.Emulated // 默认,可省略
})
export class EmulatedComponent {}
// 2. 完全隔离:ShadowDom
@Component({
selector: 'app-shadow',
standalone: true,
template: `<p>ShadowDom模式</p>`,
styles: [`p { color: blue; }`],
encapsulation: ViewEncapsulation.ShadowDom
})
export class ShadowComponent {}
// 3. 无隔离:None
@Component({
selector: 'app-none',
standalone: true,
template: `<p>None模式</p>`,
styles: [`p { color: green; }`],
encapsulation: ViewEncapsulation.None
})
export class NoneComponent {}
注意:ShadowDom模式下,组件无法访问外部全局样式,需通过
@import引入必要样式。
3.2 CSS 变量:样式穿透与主题定制
CSS 变量(Custom Properties)是突破样式隔离的优雅方案,既能保持组件封装性,又能实现外部样式定制。
1. 组件内定义可定制变量
组件通过--变量名定义可定制样式,默认值确保基础样式生效:
// button.component.ts(可定制按钮)
import { Component, input } from '@angular/core';
@Component({
selector: 'app-custom-button',
standalone: true,
template: `<button class="btn">{{ label() }}</button>`,
styles: [`
.btn {
/* 可定制变量:默认值 */
background: var(--btn-bg, #007bff);
color: var(--btn-color, white);
padding: var(--btn-padding, 0.5rem 1rem);
border: none;
border-radius: 4px;
cursor: pointer;
}
`]
})
export class CustomButtonComponent {
label = input<string>('按钮');
}
2. 外部定制组件样式
父组件通过 CSS 变量覆盖默认值,实现样式定制:
<!-- 父组件模板:定制按钮样式 -->
<div class="button-container">
<app-custom-button label="primary按钮"></app-custom-button>
<app-custom-button label="危险按钮"></app-custom-button>
</div>
/* 父组件样式:定制变量 */
.button-container {
/* 基础按钮样式 */
--btn-bg: #007bff;
/* 危险按钮样式:通过子选择器覆盖 */
app-custom-button:nth-child(2) {
--btn-bg: #dc3545;
--btn-padding: 0.75rem 1.5rem;
}
}
3. 全局主题切换
结合依赖注入与 CSS 变量,实现全局主题切换:
// theme.service.ts(主题服务)
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ThemeService {
// 主题信号:light/dark
theme = signal<'light' | 'dark'>('light');
// 切换主题
toggleTheme() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
// 设置全局CSS变量
document.documentElement.style.setProperty('--theme-bg', this.theme() === 'dark' ? '#333' : '#fff');
document.documentElement.style.setProperty('--theme-color', this.theme() === 'dark' ? '#fff' : '#333');
}
}
组件使用全局主题变量:
/* 组件样式:使用全局主题变量 */
.card {
background: var(--theme-bg);
color: var(--theme-color);
}
4 总结与最佳实践
自定义组件开发需兼顾封装性、复用性和灵活性,以下是核心最佳实践:
通信模式选型
- 父子组件:优先使用
input()/output()或model() - 跨层级组件:使用 “Signal + 共享服务”,避免
@ViewChild强耦合 - 路由页面:使用路由参数传递简单数据
复用策略取舍
- 布局复用:使用多插槽内容投影(
ng-content+select) - 逻辑复用:优先使用 “指令组合”,避免过度继承
- 动态内容:使用
ng-template+ngTemplateOutlet实现懒加载
样式隔离原则
- 默认使用
ViewEncapsulation.Emulated,保持样式隔离 - 组件定制:通过 CSS 变量暴露可定制样式,避免
::ng-deep - 主题管理:结合全局 CSS 变量与信号式状态,实现动态主题
通过上述技术与实践,可构建出高内聚、低耦合的自定义组件体系,为 Angular 应用的可维护性和扩展性奠定基础。

浙公网安备 33010602011771号