Angular常用组件开发指南
Angular组件是构建Angular应用的基础构建块。通过组件化开发,我们可以创建可复用、可测试和可维护的界面元素。本文将介绍Angular常用组件的开发实践。
核心价值
Angular组件化开发优势
- 🧩 代码复用:封装UI和逻辑为可重用组件
- 🛠️ 关注点分离:每个组件负责特定功能
- 📦 声明式模板:直观的HTML模板语法
- 🔄 单向数据流:更易于理解的数据流动
- 🔍 变更检测:高效的组件更新机制
1. 组件基础设计模式
1.1 展示型组件 vs 容器型组件
Angular应用中常用的组件设计模式是将组件分为展示型组件和容器型组件:
-
展示型组件(Presentational Components):
- 专注于UI渲染
- 通过Input接收数据,通过Output发送事件
- 不依赖外部服务和状态管理
- 容易测试和重用
-
容器型组件(Container Components):
- 处理数据获取和状态管理
- 包含业务逻辑
- 注入服务和与后端交互
- 将数据传递给展示型组件
展示型组件示例
typescript
1import { Component, Input, Output, EventEmitter } from '@angular/core';23@Component({4 selector: 'app-user-profile-card',5 template: `6 <div class="user-card">7 <img [src]="user.avatar" alt="{{ user.name }}">8 <h3>{{ user.name }}</h3>9 <p>{{ user.email }}</p>10 <button (click)="onContact()">联系用户</button>11 </div>12 `,13 styleUrls: ['./user-profile-card.component.scss']14})15export class UserProfileCardComponent {16 @Input() user: any;17 @Output() contact = new EventEmitter<any>();18 19 onContact() {20 this.contact.emit(this.user);21 }22}容器型组件示例
typescript
1import { Component, OnInit } from '@angular/core';2import { UserService } from '../../services/user.service';34@Component({5 selector: 'app-user-dashboard',6 template: `7 <div *ngIf="loading">加载中...</div>8 <div *ngIf="error">{{ error }}</div>9 10 <div class="user-grid">11 <app-user-profile-card 12 *ngFor="let user of users" 13 [user]="user"14 (contact)="contactUser($event)">15 </app-user-profile-card>16 </div>17 `,18 styleUrls: ['./user-dashboard.component.scss']19})20export class UserDashboardComponent implements OnInit {21 users = [];22 loading = false;23 error = '';24 25 constructor(private userService: UserService) {}26 27 ngOnInit() {28 this.loading = true;29 this.userService.getUsers()30 .subscribe({31 next: (data) => {32 this.users = data;33 this.loading = false;34 },35 error: (err) => {36 this.error = '无法加载用户数据';37 this.loading = false;38 console.error(err);39 }40 });41 }42 43 contactUser(user) {44 this.userService.initiateContact(user);45 }46}1.2 组件通信策略
组件之间的通信是构建Angular应用的关键部分:
- 父组件 -> 子组件: 使用
@Input()装饰器 - 子组件 -> 父组件: 使用
@Output()和EventEmitter - 无直接关系组件: 使用服务和RxJS Subject/BehaviorSubject
- 跨多层级组件: 依赖注入和提供者层级
使用服务进行跨组件通信
typescript
1// 消息服务2import { Injectable } from '@angular/core';3import { BehaviorSubject, Observable } from 'rxjs';45@Injectable({6 providedIn: 'root'7})8export class MessageService {9 private messageSource = new BehaviorSubject<string>('默认消息');10 currentMessage = this.messageSource.asObservable();11 12 constructor() { }13 14 changeMessage(message: string) {15 this.messageSource.next(message);16 }17}1819// 发送组件20@Component({21 selector: 'app-sender',22 template: `23 <input [(ngModel)]="message" placeholder="输入消息">24 <button (click)="sendMessage()">发送</button>25 `26})27export class SenderComponent {28 message: string;29 30 constructor(private messageService: MessageService) {}31 32 sendMessage() {33 this.messageService.changeMessage(this.message);34 }35}3637// 接收组件38@Component({39 selector: 'app-receiver',40 template: `<p>当前消息: {{ message }}</p>`41})42export class ReceiverComponent implements OnInit {43 message: string;44 45 constructor(private messageService: MessageService) {}46 47 ngOnInit() {48 this.messageService.currentMessage.subscribe(message => {49 this.message = message;50 });51 }52 53 ngOnDestroy() {54 // 重要:取消订阅避免内存泄漏55 this.subscription.unsubscribe();56 }57}2. 表单组件
表单组件是任何Web应用的核心部分。Angular提供了强大的表单控制功能,下面介绍几个常用的表单组件实现。
2.1 高级输入框组件
一个带有验证、标签和错误提示的增强输入框组件:
advanced-input.component.ts
typescript
1import { Component, forwardRef, Input } from '@angular/core';2import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormControl, Validator } from '@angular/forms';34@Component({5 selector: 'app-advanced-input',6 template: `7 <div class="form-field" [class.has-error]="showError">8 <label *ngIf="label" [for]="id" class="form-label">9 {{ label }}10 <span class="required" *ngIf="required">*</span>11 </label>12 13 <div class="input-container">14 <input15 [type]="type"16 [id]="id"17 class="form-input"18 [placeholder]="placeholder"19 [value]="value"20 [disabled]="disabled"21 (input)="onInputChange($event)"22 (blur)="onTouched()"23 />24 25 <button26 *ngIf="value && clearable"27 type="button"28 class="clear-button"29 (click)="clearValue()"30 aria-label="Clear input">31 ✕32 </button>33 </div>34 35 <div *ngIf="showError" class="error-message">36 {{ errorMessage }}37 </div>38 </div>39 `,40 styles: [`41 .form-field {42 margin-bottom: 1rem;43 }44 45 .form-label {46 display: block;47 margin-bottom: 0.5rem;48 font-weight: 500;49 }50 51 .input-container {52 position: relative;53 display: flex;54 }55 56 .form-input {57 width: 100%;58 padding: 0.75rem 1rem;59 border: 1px solid #ccc;60 border-radius: 4px;61 font-size: 1rem;62 transition: border-color 0.2s, box-shadow 0.2s;63 }64 65 .form-input:focus {66 outline: none;67 border-color: #4299e1;68 box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2);69 }70 71 .has-error .form-input {72 border-color: #e53e3e;73 }74 75 .has-error .form-input:focus {76 box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.2);77 }78 79 .clear-button {80 position: absolute;81 right: 0.75rem;82 top: 50%;83 transform: translateY(-50%);84 background: none;85 border: none;86 color: #718096;87 cursor: pointer;88 padding: 0.25rem;89 line-height: 1;90 }91 92 .required {93 color: #e53e3e;94 margin-left: 0.25rem;95 }96 97 .error-message {98 color: #e53e3e;99 font-size: 0.875rem;100 margin-top: 0.5rem;101 }102 `],103 providers: [104 {105 provide: NG_VALUE_ACCESSOR,106 useExisting: forwardRef(() => AdvancedInputComponent),107 multi: true108 },109 {110 provide: NG_VALIDATORS,111 useExisting: forwardRef(() => AdvancedInputComponent),112 multi: true113 }114 ]115})116export class AdvancedInputComponent implements ControlValueAccessor, Validator {117 @Input() label: string;118 @Input() placeholder: string = '';119 @Input() type: string = 'text';120 @Input() id: string = `input-${Math.random().toString(36).substring(2, 11)}`;121 @Input() required: boolean = false;122 @Input() clearable: boolean = true;123 @Input() minLength: number = 0;124 @Input() maxLength: number;125 @Input() pattern: string;126 @Input() customValidator: (value: any) => { valid: boolean, message: string };127 128 value: any = '';129 disabled: boolean = false;130 touched: boolean = false;131 errorMessage: string = '';132 133 onChange: any = () => {};134 onTouched: any = () => {};135 136 constructor() {}137 138 // ControlValueAccessor 接口139 writeValue(value: any): void {140 this.value = value;141 }142 143 registerOnChange(fn: any): void {144 this.onChange = fn;145 }146 147 registerOnTouched(fn: any): void {148 this.onTouched = fn;149 }150 151 setDisabledState(isDisabled: boolean): void {152 this.disabled = isDisabled;153 }154 155 // 当输入改变时156 onInputChange(event: Event): void {157 const input = event.target as HTMLInputElement;158 this.value = input.value;159 this.onChange(this.value);160 this.validate(null);161 }162 163 // 清除输入值164 clearValue(): void {165 this.value = '';166 this.onChange(this.value);167 this.onTouched();168 }169 170 // Validator 接口171 validate(control: FormControl): {[key: string]: any} | null {172 // 如果组件控制自己的值,使用this.value代替control.value173 const value = control ? control.value : this.value;174 175 // 必填验证176 if (this.required && !value) {177 this.errorMessage = '此字段为必填项';178 return { 'required': true };179 }180 181 // 最小长度验证182 if (this.minLength && value && value.length < this.minLength) {183 this.errorMessage = `最少需要 ${this.minLength} 个字符`;184 return { 'minlength': { requiredLength: this.minLength, actualLength: value.length } };185 }186 187 // 最大长度验证188 if (this.maxLength && value && value.length > this.maxLength) {189 this.errorMessage = `最多允许 ${this.maxLength} 个字符`;190 return { 'maxlength': { requiredLength: this.maxLength, actualLength: value.length } };191 }192 193 // 模式验证194 if (this.pattern && value) {195 const regex = new RegExp(this.pattern);196 if (!regex.test(value)) {197 this.errorMessage = '输入格式不正确';198 return { 'pattern': { requiredPattern: this.pattern, actualValue: value } };199 }200 }201 202 // 自定义验证203 if (this.customValidator && value) {204 const result = this.customValidator(value);205 if (!result.valid) {206 this.errorMessage = result.message;207 return { 'custom': true };208 }209 }210 211 // 验证通过212 this.errorMessage = '';213 return null;214 }215 216 // 显示错误的条件217 get showError(): boolean {218 return this.errorMessage !== '' && this.touched;219 }220}使用方法示例:
html
1<form [formGroup]="userForm">2 <app-advanced-input3 label="用户名"4 formControlName="username"5 required6 [minLength]="3"7 [maxLength]="20"8 ></app-advanced-input>9 10 <app-advanced-input11 label="电子邮箱"12 type="email"13 formControlName="email"14 required15 [pattern]="emailPattern"16 ></app-advanced-input>17 18 <button type="submit" [disabled]="userForm.invalid">提交</button>19</form>2.2 下拉选择组件
带有搜索功能的下拉选择组件:
searchable-select.component.ts
typescript
1import { Component, forwardRef, Input, ElementRef, ViewChild, HostListener } from '@angular/core';2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';34interface Option {5 value: any;6 label: string;7}89@Component({10 selector: 'app-searchable-select',11 template: `12 <div class="select-container">13 <label *ngIf="label" [for]="id" class="select-label">14 {{ label }}15 <span class="required" *ngIf="required">*</span>16 </label>17 18 <div19 class="select-input"20 [class.open]="isOpen"21 [class.disabled]="disabled"22 (click)="toggleDropdown()">23 <span *ngIf="selectedOption; else placeholderTpl">{{ selectedOption.label }}</span>24 <ng-template #placeholderTpl>25 <span class="placeholder">{{ placeholder }}</span>26 </ng-template>27 28 <div class="select-arrow">▼</div>29 </div>30 31 <div class="dropdown" *ngIf="isOpen">32 <div class="search-container">33 <input34 #searchInput35 type="text"36 class="search-input"37 placeholder="搜索..."38 [value]="searchText"39 (input)="onSearch($event)"40 (click)="$event.stopPropagation()">41 </div>42 43 <div class="options-list" *ngIf="filteredOptions.length > 0">44 <div45 *ngFor="let option of filteredOptions"46 class="option-item"47 [class.selected]="value === option.value"48 (click)="selectOption(option); $event.stopPropagation()">49 {{ option.label }}50 </div>51 </div>52 53 <div class="no-results" *ngIf="filteredOptions.length === 0">54 无匹配结果55 </div>56 </div>57 </div>58 `,59 styles: [`60 .select-container {61 position: relative;62 margin-bottom: 1rem;63 }64 65 .select-label {66 display: block;67 margin-bottom: 0.5rem;68 font-weight: 500;69 }70 71 .select-input {72 display: flex;73 justify-content: space-between;74 align-items: center;75 width: 100%;76 padding: 0.75rem 1rem;77 border: 1px solid #ccc;78 border-radius: 4px;79 background-color: #fff;80 cursor: pointer;81 user-select: none;82 transition: border-color 0.2s, box-shadow 0.2s;83 }84 85 .select-input:hover {86 border-color: #a0aec0;87 }88 89 .select-input.open {90 border-color: #4299e1;91 box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2);92 }93 94 .select-input.disabled {95 background-color: #f7fafc;96 cursor: not-allowed;97 color: #a0aec0;98 }99 100 .placeholder {101 color: #a0aec0;102 }103 104 .select-arrow {105 font-size: 0.75rem;106 color: #718096;107 transition: transform 0.2s;108 }109 110 .open .select-arrow {111 transform: rotate(180deg);112 }113 114 .dropdown {115 position: absolute;116 top: 100%;117 left: 0;118 width: 100%;119 background: #fff;120 border: 1px solid #e2e8f0;121 border-radius: 4px;122 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);123 z-index: 10;124 margin-top: 4px;125 max-height: 300px;126 display: flex;127 flex-direction: column;128 }129 130 .search-container {131 padding: 0.5rem;132 border-bottom: 1px solid #e2e8f0;133 }134 135 .search-input {136 width: 100%;137 padding: 0.5rem;138 border: 1px solid #e2e8f0;139 border-radius: 4px;140 font-size: 0.875rem;141 }142 143 .options-list {144 overflow-y: auto;145 max-height: 240px;146 }147 148 .option-item {149 padding: 0.75rem 1rem;150 cursor: pointer;151 transition: background-color 0.15s;152 }153 154 .option-item:hover {155 background-color: #f7fafc;156 }157 158 .option-item.selected {159 background-color: #ebf8ff;160 color: #3182ce;161 font-weight: 500;162 }163 164 .no-results {165 padding: 0.75rem 1rem;166 text-align: center;167 color: #a0aec0;168 font-style: italic;169 }170 171 .required {172 color: #e53e3e;173 margin-left: 0.25rem;174 }175 `],176 providers: [177 {178 provide: NG_VALUE_ACCESSOR,179 useExisting: forwardRef(() => SearchableSelectComponent),180 multi: true181 }182 ]183})184export class SearchableSelectComponent implements ControlValueAccessor {185 @Input() options: Option[] = [];186 @Input() label: string;187 @Input() placeholder: string = '请选择';188 @Input() id: string = `select-${Math.random().toString(36).substring(2, 11)}`;189 @Input() required: boolean = false;190 191 @ViewChild('searchInput') searchInput: ElementRef;192 193 value: any = null;194 disabled: boolean = false;195 isOpen: boolean = false;196 searchText: string = '';197 198 onChange: any = () => {};199 onTouched: any = () => {};200 201 constructor(private elementRef: ElementRef) {}202 203 // 关闭下拉框的点击外部元素事件204 @HostListener('document:click', ['$event'])205 onClickOutside(event: Event) {206 if (!this.elementRef.nativeElement.contains(event.target)) {207 this.isOpen = false;208 }209 }210 211 // ControlValueAccessor 接口212 writeValue(value: any): void {213 this.value = value;214 }215 216 registerOnChange(fn: any): void {217 this.onChange = fn;218 }219 220 registerOnTouched(fn: any): void {221 this.onTouched = fn;222 }223 224 setDisabledState(isDisabled: boolean): void {225 this.disabled = isDisabled;226 }227 228 // 切换下拉框状态229 toggleDropdown(): void {230 if (this.disabled) return;231 232 this.isOpen = !this.isOpen;233 this.searchText = '';234 235 if (this.isOpen) {236 setTimeout(() => {237 this.searchInput?.nativeElement.focus();238 });239 }240 }241 242 // 选择选项243 selectOption(option: Option): void {244 this.value = option.value;245 this.onChange(this.value);246 this.onTouched();247 this.isOpen = false;248 }249 250 // 搜索251 onSearch(event: Event): void {252 this.searchText = (event.target as HTMLInputElement).value;253 }254 255 // 过滤选项256 get filteredOptions(): Option[] {257 if (!this.searchText) return this.options;258 259 return this.options.filter(option => 260 option.label.toLowerCase().includes(this.searchText.toLowerCase())261 );262 }263 264 // 获取选中项265 get selectedOption(): Option | undefined {266 return this.options.find(option => option.value === this.value);267 }268}使用方法示例:
html
1<form [formGroup]="productForm">2 <app-searchable-select3 label="分类"4 [options]="categoryOptions"5 formControlName="category"6 required7 ></app-searchable-select>8</form>910<script>11 // 组件代码12 categoryOptions = [13 { value: 'electronics', label: '电子产品' },14 { value: 'clothing', label: '服装' },15 { value: 'books', label: '图书' },16 { value: 'food', label: '食品' },17 // ...更多选项18 ];19</script>3. 布局组件
布局组件帮助构建页面的整体结构和组织内容。下面介绍几个实用的布局组件。
3.1 网格系统组件
一个灵活的响应式网格系统组件:
grid-row.component.ts
typescript
1import { Component, Input, HostBinding } from '@angular/core';23@Component({4 selector: 'app-grid-row',5 template: `<ng-content></ng-content>`,6 styles: [`7 :host {8 display: flex;9 flex-wrap: wrap;10 box-sizing: border-box;11 }12 `]13})14export class GridRowComponent {15 @Input() gutter: number = 0;16 @Input() justify: 'start' | 'end' | 'center' | 'space-between' | 'space-around' = 'start';17 @Input() align: 'top' | 'middle' | 'bottom' = 'top';18 19 // 映射到flex属性20 private justifyMap = {21 'start': 'flex-start',22 'end': 'flex-end',23 'center': 'center',24 'space-between': 'space-between',25 'space-around': 'space-around'26 };27 28 private alignMap = {29 'top': 'flex-start',30 'middle': 'center',31 'bottom': 'flex-end'32 };33 34 @HostBinding('style.margin-left.px')35 @HostBinding('style.margin-right.px')36 get marginOffset() {37 return this.gutter ? -(this.gutter / 2) : 0;38 }39 40 @HostBinding('style.justify-content')41 get justifyContent() {42 return this.justifyMap[this.justify];43 }44 45 @HostBinding('style.align-items')46 get alignItems() {47 return this.alignMap[this.align];48 }49}grid-column.component.ts
typescript
1import { Component, Input, HostBinding, OnInit } from '@angular/core';23@Component({4 selector: 'app-grid-col',5 template: `<ng-content></ng-content>`,6 styles: [`7 :host {8 box-sizing: border-box;9 }10 11 /* 响应式断点 - 可以根据需求调整 */12 @media (max-width: 575.98px) {13 :host.responsive {14 flex: 0 0 100%;15 max-width: 100%;16 }17 }18 `]19})20export class GridColumnComponent implements OnInit {21 @Input() span: number = 24;22 @Input() offset: number = 0;23 @Input() xs: number | object;24 @Input() sm: number | object;25 @Input() md: number | object;26 @Input() lg: number | object;27 @Input() xl: number | object;28 @Input() gutter: number = 0;29 30 @HostBinding('style.padding-left.px')31 @HostBinding('style.padding-right.px')32 get padding() {33 return this.gutter ? this.gutter / 2 : 0;34 }35 36 @HostBinding('style.flex')37 @HostBinding('style.max-width')38 get flexStyle() {39 return `0 0 ${(this.span / 24 * 100).toFixed(8)}%`;40 }41 42 @HostBinding('style.margin-left')43 get offsetStyle() {44 return this.offset ? `${(this.offset / 24 * 100).toFixed(8)}%` : null;45 }46 47 @HostBinding('class.responsive')48 get isResponsive() {49 return !!(this.xs || this.sm || this.md || this.lg || this.xl);50 }51 52 constructor() {}53 54 ngOnInit() {55 this.setResponsiveStyles();56 }57 58 private setResponsiveStyles() {59 // 这里可以添加更复杂的响应式样式处理逻辑60 // 例如创建媒体查询样式并添加到document.head61 }62}使用方法示例:
html
1<app-grid-row [gutter]="16" justify="space-between" align="middle">2 <app-grid-col [span]="8">3 <div class="column-content">列 1</div>4 </app-grid-col>5 <app-grid-col [span]="8">6 <div class="column-content">列 2</div>7 </app-grid-col>8 <app-grid-col [span]="8">9 <div class="column-content">列 3</div>10 </app-grid-col>11</app-grid-row>1213<app-grid-row [gutter]="24">14 <app-grid-col [span]="6" [xs]="24" [sm]="12" [md]="8" [lg]="6">15 <div class="column-content">响应式列</div>16 </app-grid-col>17 <app-grid-col [span]="6" [xs]="24" [sm]="12" [md]="8" [lg]="6">18 <div class="column-content">响应式列</div>19 </app-grid-col>20 <app-grid-col [span]="6" [xs]="24" [sm]="12" [md]="8" [lg]="6">21 <div class="column-content">响应式列</div>22 </app-grid-col>23 <app-grid-col [span]="6" [xs]="24" [sm]="12" [md]="8" [lg]="6">24 <div class="column-content">响应式列</div>25 </app-grid-col>26</app-grid-row>3.2 卡片组件
一个多功能的卡片组件,用于展示内容:
card.component.ts
typescript
1import { Component, Input } from '@angular/core';23@Component({4 selector: 'app-card',5 template: `6 <div class="card" [ngClass]="cardClasses">7 <!-- 卡片头部 -->8 <div class="card-header" *ngIf="title || subtitle || headerTemplate">9 <ng-container *ngIf="!headerTemplate">10 <h3 class="card-title" *ngIf="title">{{ title }}</h3>11 <div class="card-subtitle" *ngIf="subtitle">{{ subtitle }}</div>12 </ng-container>13 <ng-container *ngTemplateOutlet="headerTemplate"></ng-container>14 </div>15 16 <!-- 卡片封面 -->17 <div class="card-cover" *ngIf="coverImage">18 <img [src]="coverImage" [alt]="title || 'Card image'">19 </div>20 21 <!-- 卡片内容 -->22 <div class="card-body">23 <ng-content></ng-content>24 </div>25 26 <!-- 卡片操作区 -->27 <div class="card-actions" *ngIf="actionsTemplate">28 <ng-container *ngTemplateOutlet="actionsTemplate"></ng-container>29 </div>30 </div>31 `,32 styles: [`33 .card {34 background-color: #fff;35 border-radius: 8px;36 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);37 overflow: hidden;38 transition: box-shadow 0.3s, transform 0.3s;39 margin-bottom: 1.5rem;40 display: flex;41 flex-direction: column;42 }43 44 .card.hoverable:hover {45 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);46 transform: translateY(-4px);47 }48 49 .card.bordered {50 box-shadow: none;51 border: 1px solid #eee;52 }53 54 .card-header {55 padding: 1rem 1.5rem;56 border-bottom: 1px solid #f0f0f0;57 display: flex;58 flex-direction: column;59 }60 61 .card-title {62 margin: 0;63 font-size: 1.25rem;64 font-weight: 500;65 color: rgba(0, 0, 0, 0.85);66 line-height: 1.4;67 }68 69 .card-subtitle {70 margin-top: 0.25rem;71 color: rgba(0, 0, 0, 0.45);72 font-size: 0.875rem;73 }74 75 .card-cover img {76 width: 100%;77 display: block;78 }79 80 .card-body {81 padding: 1.5rem;82 flex: 1;83 }84 85 .card-actions {86 padding: 0.75rem 1.5rem;87 border-top: 1px solid #f0f0f0;88 background: #fafafa;89 }90 91 /* 尺寸变体 */92 .card.small {93 font-size: 0.875rem;94 }95 96 .card.small .card-header {97 padding: 0.75rem 1rem;98 }99 100 .card.small .card-body {101 padding: 1rem;102 }103 104 .card.small .card-title {105 font-size: 1rem;106 }107 108 .card.large .card-header {109 padding: 1.5rem 2rem;110 }111 112 .card.large .card-body {113 padding: 2rem;114 }115 116 .card.large .card-title {117 font-size: 1.5rem;118 }119 `]120})121export class CardComponent {122 @Input() title: string;123 @Input() subtitle: string;124 @Input() coverImage: string;125 @Input() bordered: boolean = false;126 @Input() hoverable: boolean = false;127 @Input() size: 'small' | 'default' | 'large' = 'default';128 @Input() headerTemplate: any; // ng-template引用129 @Input() actionsTemplate: any; // ng-template引用130 131 get cardClasses(): {[key: string]: boolean} {132 return {133 'bordered': this.bordered,134 'hoverable': this.hoverable,135 'small': this.size === 'small',136 'large': this.size === 'large'137 };138 }139}使用方法示例:
html
1<!-- 基本用法 -->2<app-card 3 title="卡片标题" 4 subtitle="卡片副标题" 5 [hoverable]="true"6 coverImage="assets/images/card-image.jpg">7 <p>这是卡片内容区域,可以放置任何内容。</p>8</app-card>910<!-- 使用模板自定义头部和操作区 -->11<app-card [headerTemplate]="customHeader" [actionsTemplate]="customActions">12 <p>卡片内容区域</p>13</app-card>1415<ng-template #customHeader>16 <div class="custom-header">17 <img src="assets/icons/user.svg" alt="User icon">18 <div>19 <h3>自定义标题</h3>20 <span>创建于:{{ createdDate | date }}</span>21 </div>22 </div>23</ng-template>2425<ng-template #customActions>26 <div class="action-buttons">27 <button (click)="onEdit()">编辑</button>28 <button (click)="onDelete()">删除</button>29 </div>30</ng-template>3.3 分隔面板组件
一个可折叠的面板组件,适合展示分组内容:
collapse.component.ts
typescript
1import { Component, Input } from '@angular/core';23@Component({4 selector: 'app-collapse',5 template: `6 <div class="collapse-container">7 <ng-content></ng-content>8 </div>9 `,10 styles: [`11 .collapse-container {12 border: 1px solid #eee;13 border-radius: 4px;14 }15 `]16})17export class CollapseComponent {18 @Input() accordion: boolean = false;19 20 activeKeys: string[] = [];21 22 togglePanel(key: string): void {23 if (this.accordion) {24 this.activeKeys = this.activeKeys.includes(key) ? [] : [key];25 } else {26 const index = this.activeKeys.indexOf(key);27 if (index > -1) {28 this.activeKeys.splice(index, 1);29 } else {30 this.activeKeys.push(key);31 }32 }33 }34}collapse-panel.component.ts
typescript
1import { Component, Input, Host, Optional } from '@angular/core';2import { CollapseComponent } from './collapse.component';34@Component({5 selector: 'app-collapse-panel',6 template: `7 <div class="collapse-panel" [class.active]="isActive">8 <div class="panel-header" (click)="toggle()">9 <div class="header-content">{{ header }}</div>10 <div class="expand-icon">11 <i class="arrow" [class.down]="!isActive" [class.up]="isActive"></i>12 </div>13 </div>14 15 <div class="panel-content" [style.height]="isActive ? contentHeight : '0'">16 <div class="content-inner" #contentWrapper>17 <ng-content></ng-content>18 </div>19 </div>20 </div>21 `,22 styles: [`23 .collapse-panel {24 border-bottom: 1px solid #eee;25 }26 27 .collapse-panel:last-child {28 border-bottom: none;29 }30 31 .panel-header {32 padding: 1rem;33 display: flex;34 justify-content: space-between;35 align-items: center;36 cursor: pointer;37 user-select: none;38 color: rgba(0, 0, 0, 0.85);39 font-weight: 500;40 }41 42 .panel-header:hover {43 background-color: #fafafa;44 }45 46 .collapse-panel.active .panel-header {47 border-bottom: 1px solid #eee;48 }49 50 .arrow {51 border: solid #999;52 border-width: 0 2px 2px 0;53 display: inline-block;54 padding: 3px;55 transition: transform 0.3s;56 }57 58 .arrow.down {59 transform: rotate(45deg);60 }61 62 .arrow.up {63 transform: rotate(-135deg);64 }65 66 .panel-content {67 overflow: hidden;68 transition: height 0.3s ease-out;69 }70 71 .content-inner {72 padding: 1rem;73 color: rgba(0, 0, 0, 0.65);74 }75 `]76})77export class CollapsePanelComponent {78 @Input() header: string;79 @Input() key: string = Math.random().toString(36).substring(2, 9);80 @Input() disabled: boolean = false;81 82 private _contentHeight = 'auto';83 84 get isActive(): boolean {85 return this.collapseComponent?.activeKeys.includes(this.key) ?? false;86 }87 88 get contentHeight(): string {89 return this.isActive ? this._contentHeight : '0';90 }91 92 constructor(@Optional() @Host() private collapseComponent: CollapseComponent) {}93 94 toggle(): void {95 if (this.disabled) return;96 97 this.collapseComponent?.togglePanel(this.key);98 }99}使用方法示例:
html
1<!-- 基本折叠面板 -->2<app-collapse>3 <app-collapse-panel header="折叠面板 1" key="panel1">4 <p>这里是面板1的内容区域。</p>5 </app-collapse-panel>6 7 <app-collapse-panel header="折叠面板 2" key="panel2">8 <p>这里是面板2的内容区域。</p>9 <p>可以包含任意内容,比如文本、表单或其他组件。</p>10 </app-collapse-panel>11 12 <app-collapse-panel header="折叠面板 3" key="panel3" [disabled]="true">13 <p>这个面板被禁用了,无法点击展开。</p>14 </app-collapse-panel>15</app-collapse>1617<!-- 手风琴模式 -->18<app-collapse [accordion]="true">19 <app-collapse-panel header="部分 1" key="section1">20 <p>手风琴模式下一次只能展开一个面板。</p>21 </app-collapse-panel>22 23 <app-collapse-panel header="部分 2" key="section2">24 <p>点击这个面板会自动关闭其他面板。</p>25 </app-collapse-panel>26</app-collapse>4. 功能组件
功能组件提供特定的交互功能,如模态对话框、通知提醒等。
4.1 模态对话框组件
一个可复用的模态对话框组件:
modal.component.ts
typescript
1import { Component, Input, Output, EventEmitter, TemplateRef } from '@angular/core';2import { trigger, state, style, transition, animate } from '@angular/animations';34@Component({5 selector: 'app-modal',6 template: `7 <div class="modal-backdrop" *ngIf="visible" (click)="onBackdropClick()" [@backdropAnimation]="'show'">8 <div class="modal-dialog" [class]="modalSizeClass" (click)="$event.stopPropagation()" [@dialogAnimation]="'show'">9 <!-- 模态框头部 -->10 <div class="modal-header">11 <ng-container *ngIf="!customHeader">12 <h4 class="modal-title">{{ title }}</h4>13 </ng-container>14 <ng-container *ngTemplateOutlet="customHeader"></ng-container>15 16 <button *ngIf="showClose" type="button" class="close-button" aria-label="Close" (click)="close()">17 ×18 </button>19 </div>20 21 <!-- 模态框内容 -->22 <div class="modal-body">23 <ng-content></ng-content>24 <ng-container *ngTemplateOutlet="contentTemplate"></ng-container>25 </div>26 27 <!-- 模态框底部 -->28 <div class="modal-footer" *ngIf="showFooter">29 <ng-container *ngIf="!customFooter">30 <button type="button" class="btn btn-secondary" (click)="close()">{{ cancelText }}</button>31 <button type="button" class="btn btn-primary" (click)="confirm()">{{ confirmText }}</button>32 </ng-container>33 <ng-container *ngTemplateOutlet="customFooter"></ng-container>34 </div>35 </div>36 </div>37 `,38 styles: [`39 .modal-backdrop {40 position: fixed;41 top: 0;42 left: 0;43 width: 100%;44 height: 100%;45 background-color: rgba(0, 0, 0, 0.5);46 display: flex;47 align-items: center;48 justify-content: center;49 z-index: 1050;50 }51 52 .modal-dialog {53 background-color: #fff;54 border-radius: 4px;55 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);56 max-width: 90%;57 max-height: 90vh;58 display: flex;59 flex-direction: column;60 overflow: hidden;61 }62 63 .modal-size-small {64 width: 400px;65 }66 67 .modal-size-medium {68 width: 600px;69 }70 71 .modal-size-large {72 width: 800px;73 }74 75 .modal-header {76 display: flex;77 justify-content: space-between;78 align-items: center;79 padding: 1rem 1.5rem;80 border-bottom: 1px solid #eee;81 }82 83 .modal-title {84 margin: 0;85 font-weight: 500;86 color: rgba(0, 0, 0, 0.85);87 }88 89 .close-button {90 background: transparent;91 border: none;92 font-size: 1.5rem;93 font-weight: 700;94 line-height: 1;95 color: #999;96 cursor: pointer;97 padding: 0;98 }99 100 .close-button:hover {101 color: #333;102 }103 104 .modal-body {105 padding: 1.5rem;106 overflow-y: auto;107 flex: 1;108 }109 110 .modal-footer {111 padding: 1rem 1.5rem;112 border-top: 1px solid #eee;113 display: flex;114 justify-content: flex-end;115 gap: 0.5rem;116 }117 118 .btn {119 padding: 0.5rem 1rem;120 border-radius: 4px;121 font-weight: 400;122 border: 1px solid transparent;123 cursor: pointer;124 transition: all 0.2s;125 }126 127 .btn-primary {128 background-color: #1890ff;129 border-color: #1890ff;130 color: white;131 }132 133 .btn-primary:hover {134 background-color: #40a9ff;135 border-color: #40a9ff;136 }137 138 .btn-secondary {139 background-color: white;140 border-color: #d9d9d9;141 color: rgba(0, 0, 0, 0.65);142 }143 144 .btn-secondary:hover {145 background-color: #f5f5f5;146 border-color: #d9d9d9;147 }148 `],149 animations: [150 trigger('backdropAnimation', [151 state('void', style({152 opacity: 0153 })),154 state('show', style({155 opacity: 1156 })),157 transition('void => show', animate('200ms')),158 transition('show => void', animate('200ms'))159 ]),160 trigger('dialogAnimation', [161 state('void', style({162 transform: 'scale(0.7)',163 opacity: 0164 })),165 state('show', style({166 transform: 'scale(1)',167 opacity: 1168 })),169 transition('void => show', animate('200ms cubic-bezier(0.35, 0, 0.25, 1)')),170 transition('show => void', animate('200ms cubic-bezier(0.35, 0, 0.25, 1)'))171 ])172 ]173})174export class ModalComponent {175 @Input() visible: boolean = false;176 @Input() title: string = '对话框';177 @Input() size: 'small' | 'medium' | 'large' = 'medium';178 @Input() showClose: boolean = true;179 @Input() showFooter: boolean = true;180 @Input() closeOnBackdrop: boolean = true;181 @Input() customHeader: TemplateRef<any>;182 @Input() customFooter: TemplateRef<any>;183 @Input() contentTemplate: TemplateRef<any>;184 @Input() confirmText: string = '确定';185 @Input() cancelText: string = '取消';186 187 @Output() visibleChange = new EventEmitter<boolean>();188 @Output() confirmed = new EventEmitter<void>();189 @Output() cancelled = new EventEmitter<void>();190 191 get modalSizeClass(): string {192 return `modal-size-${this.size}`;193 }194 195 close(): void {196 this.visibleChange.emit(false);197 this.cancelled.emit();198 }199 200 confirm(): void {201 this.confirmed.emit();202 this.visibleChange.emit(false);203 }204 205 onBackdropClick(): void {206 if (this.closeOnBackdrop) {207 this.close();208 }209 }210}使用方法示例:
html
1<!-- 基本用法 -->2<button (click)="openModal()">打开模态框</button>34<app-modal 5 [(visible)]="isModalVisible"6 title="用户信息"7 (confirmed)="handleConfirm()"8 (cancelled)="handleCancel()">9 <p>这是模态框的内容区域。</p>10 <form>11 <div class="form-group">12 <label for="name">姓名</label>13 <input id="name" type="text">14 </div>15 <div class="form-group">16 <label for="email">电子邮箱</label>17 <input id="email" type="email">18 </div>19 </form>20</app-modal>2122<!-- 使用自定义模板 -->23<app-modal 24 [(visible)]="isCustomModalVisible"25 [customHeader]="customModalHeader"26 [customFooter]="customModalFooter">27 <p>带有自定义头部和底部的模态框。</p>28</app-modal>2930<ng-template #customModalHeader>31 <div class="custom-header">32 <i class="icon-warning"></i>33 <span>警告提示</span>34 </div>35</ng-template>3637<ng-template #customModalFooter>38 <button (click)="cancelAction()">不,我再想想</button>39 <button class="danger-button" (click)="confirmAction()">是的,我确定</button>40</ng-template>4.2 通知提示组件
一个用于显示全局通知的组件,包含服务和组件:
notification.service.ts
typescript
1import { Injectable } from '@angular/core';2import { Subject } from 'rxjs';34export type NotificationType = 'success' | 'info' | 'warning' | 'error';56export interface NotificationConfig {7 id?: string;8 title?: string;9 message: string;10 type?: NotificationType;11 duration?: number; // 显示时长,单位毫秒,0表示不自动关闭12}1314export interface NotificationInstance extends NotificationConfig {15 id: string;16 timestamp: number;17}1819@Injectable({20 providedIn: 'root'21})22export class NotificationService {23 private notifications$ = new Subject<NotificationInstance | { id: string, remove: true }>();24 25 // 可观察的通知流26 notifications = this.notifications$.asObservable();27 28 constructor() {}29 30 // 显示通知31 show(config: NotificationConfig): string {32 const id = config.id || `notification-${Date.now()}-${Math.floor(Math.random() * 1000)}`;33 34 const notification: NotificationInstance = {35 ...config,36 id,37 type: config.type || 'info',38 duration: config.duration === undefined ? 4500 : config.duration,39 timestamp: Date.now()40 };41 42 this.notifications$.next(notification);43 44 return id;45 }46 47 // 关闭通知48 close(id: string): void {49 this.notifications$.next({ id, remove: true });50 }51 52 // 快捷方法53 success(message: string, title?: string, duration?: number): string {54 return this.show({ message, title, type: 'success', duration });55 }56 57 info(message: string, title?: string, duration?: number): string {58 return this.show({ message, title, type: 'info', duration });59 }60 61 warning(message: string, title?: string, duration?: number): string {62 return this.show({ message, title, type: 'warning', duration });63 }64 65 error(message: string, title?: string, duration?: number): string {66 return this.show({ message, title, type: 'error', duration });67 }68}notification.component.ts
typescript
1import { Component, OnInit, OnDestroy } from '@angular/core';2import { trigger, state, style, transition, animate } from '@angular/animations';3import { NotificationService, NotificationInstance } from './notification.service';4import { Subscription } from 'rxjs';56@Component({7 selector: 'app-notification-container',8 template: `9 <div class="notification-container">10 <div 11 *ngFor="let notification of notifications" 12 class="notification"13 [ngClass]="'notification-' + notification.type"14 [@notificationAnimation]="'visible'"15 (@notificationAnimation.done)="onAnimationDone($event, notification)"16 >17 <div class="notification-icon">18 <i class="icon" [ngClass]="getIconClass(notification.type)"></i>19 </div>20 <div class="notification-content">21 <div class="notification-title" *ngIf="notification.title">{{ notification.title }}</div>22 <div class="notification-message">{{ notification.message }}</div>23 </div>24 <button class="notification-close" (click)="close(notification.id)">×</button>25 </div>26 </div>27 `,28 styles: [`29 .notification-container {30 position: fixed;31 top: 20px;32 right: 20px;33 z-index: 1060;34 max-width: 350px;35 }36 37 .notification {38 background: white;39 border-radius: 4px;40 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);41 margin-bottom: 16px;42 padding: 16px;43 position: relative;44 display: flex;45 align-items: flex-start;46 overflow: hidden;47 }48 49 .notification-icon {50 margin-right: 12px;51 font-size: 20px;52 line-height: 24px;53 }54 55 .notification-content {56 flex: 1;57 }58 59 .notification-title {60 font-weight: 500;61 font-size: 16px;62 margin-bottom: 4px;63 color: rgba(0, 0, 0, 0.85);64 }65 66 .notification-message {67 font-size: 14px;68 color: rgba(0, 0, 0, 0.65);69 }70 71 .notification-close {72 position: absolute;73 right: 16px;74 top: 12px;75 background: transparent;76 border: none;77 cursor: pointer;78 font-size: 16px;79 color: rgba(0, 0, 0, 0.45);80 }81 82 .notification-success {83 border-left: 4px solid #52c41a;84 }85 86 .notification-success .icon {87 color: #52c41a;88 }89 90 .notification-info {91 border-left: 4px solid #1890ff;92 }93 94 .notification-info .icon {95 color: #1890ff;96 }97 98 .notification-warning {99 border-left: 4px solid #faad14;100 }101 102 .notification-warning .icon {103 color: #faad14;104 }105 106 .notification-error {107 border-left: 4px solid #f5222d;108 }109 110 .notification-error .icon {111 color: #f5222d;112 }113 `],114 animations: [115 trigger('notificationAnimation', [116 state('void', style({117 opacity: 0,118 transform: 'translateX(100%)'119 })),120 state('visible', style({121 opacity: 1,122 transform: 'translateX(0)'123 })),124 transition('void => visible', animate('200ms cubic-bezier(0, 0, 0.2, 1)')),125 transition('visible => void', animate('200ms cubic-bezier(0.4, 0, 1, 1)'))126 ])127 ]128})129export class NotificationContainerComponent implements OnInit, OnDestroy {130 notifications: NotificationInstance[] = [];131 private subscription: Subscription;132 private timers = new Map<string, any>();133 134 constructor(private notificationService: NotificationService) {}135 136 ngOnInit(): void {137 this.subscription = this.notificationService.notifications.subscribe(notification => {138 if ('remove' in notification) {139 this.removeNotification(notification.id);140 } else {141 this.addNotification(notification);142 }143 });144 }145 146 ngOnDestroy(): void {147 this.subscription.unsubscribe();148 // 清除所有计时器149 this.timers.forEach(timerId => clearTimeout(timerId));150 this.timers.clear();151 }152 153 private addNotification(notification: NotificationInstance): void {154 this.notifications = [...this.notifications, notification];155 156 // 设置自动关闭计时器157 if (notification.duration > 0) {158 const timerId = setTimeout(() => {159 this.close(notification.id);160 }, notification.duration);161 162 this.timers.set(notification.id, timerId);163 }164 }165 166 private removeNotification(id: string): void {167 const index = this.notifications.findIndex(n => n.id === id);168 if (index > -1) {169 const notification = this.notifications[index];170 notification['removing'] = true;171 this.notifications = [...this.notifications];172 173 // 清除该通知的计时器174 if (this.timers.has(id)) {175 clearTimeout(this.timers.get(id));176 this.timers.delete(id);177 }178 }179 }180 181 close(id: string): void {182 this.notificationService.close(id);183 }184 185 onAnimationDone(event: any, notification: NotificationInstance): void {186 if (event.toState === 'void') {187 this.notifications = this.notifications.filter(n => n.id !== notification.id);188 }189 }190 191 getIconClass(type: string): string {192 switch (type) {193 case 'success': return 'icon-check-circle';194 case 'info': return 'icon-info-circle';195 case 'warning': return 'icon-exclamation-circle';196 case 'error': return 'icon-close-circle';197 default: return 'icon-info-circle';198 }199 }200}使用方法示例:
typescript
1import { Component } from '@angular/core';2import { NotificationService } from './notification.service';34@Component({5 selector: 'app-demo',6 template: `7 <button (click)="showSuccessNotification()">显示成功通知</button>8 <button (click)="showInfoNotification()">显示信息通知</button>9 <button (click)="showWarningNotification()">显示警告通知</button>10 <button (click)="showErrorNotification()">显示错误通知</button>11 `12})13export class DemoComponent {14 constructor(private notificationService: NotificationService) {}15 16 showSuccessNotification(): void {17 this.notificationService.success('操作成功完成', '成功', 3000);18 }19 20 showInfoNotification(): void {21 this.notificationService.info('这是一条信息通知', '提示');22 }23 24 showWarningNotification(): void {25 this.notificationService.warning('请注意可能的问题', '警告');26 }27 28 showErrorNotification(): void {29 this.notificationService.error('操作失败,请重试', '错误', 0); // 0表示不自动关闭30 }31}5. Angular组件最佳实践
5.1 组件设计原则
开发Angular组件时应遵循以下设计原则:
- 单一职责原则:每个组件应专注于做一件事,并做好它。
- 可复用性:组件应设计为可重用,避免特定业务逻辑硬编码。
- 可测试性:组件应便于单元测试,依赖注入而非直接依赖实现。
- 可组合性:组件应设计为可组合,支持内容投影和模板定制。
- 封装性:组件应封装其内部实现细节,只暴露必要的API。
5.2 组件设计模式
-
展示型组件与容器型组件分离:
- 展示型组件:处理UI展示,通过输入接收数据,通过输出发送事件
- 容器型组件:处理数据和状态管理,注入服务,协调子组件
-
组件组合模式:
- 使用内容投影(
ng-content)组合组件 - 使用模板引用定制组件外观
- 使用组件API进行配置
- 使用内容投影(
-
智能默认值与渐进式接口:
- 提供合理默认值,减少必要配置
- 允许通过可选输入进行定制
- 保持简单接口,同时支持高级功能
5.3 组件性能优化
- 使用OnPush变更检测策略:
typescript
1@Component({2 selector: 'app-performance',3 template: `...`,4 changeDetection: ChangeDetectionStrategy.OnPush5})6export class PerformanceComponent {}-
避免过度使用
ngDoCheck:- 该钩子运行频率高,实现应保持轻量
- 考虑使用防抖或节流减少执行次数
-
跟踪ngFor:
html
1<div *ngFor="let item of items; trackBy: trackByFn">{{ item.name }}</div>typescript
1trackByFn(index: number, item: any): number {2 return item.id;3}-
延迟加载:
- 使用
*ngIf条件渲染复杂组件 - 考虑使用虚拟滚动处理大列表
- 使用
defer块(Angular 17+)延迟加载组件
- 使用
-
纯管道替代方法:
- 使用纯管道处理模板中的数据转换,而非组件方法
5.4 组件库开发建议
如果要开发自己的组件库,请遵循以下建议:
-
一致的API设计:
- 保持输入/输出命名一致性
- 使用类似的交互模式
- 保持相似的错误处理策略
-
可访问性:
- 支持键盘导航
- 添加适当的ARIA属性
- 确保足够的颜色对比度
- 支持屏幕阅读器
-
主题化支持:
- 使用CSS变量实现主题切换
- 避免硬编码样式值
- 提供暗色模式支持
-
文档与示例:
- 为每个组件提供清晰文档
- 包含基本和高级使用示例
- 记录公共API和事件
-
版本控制与迁移:
- 遵循语义版本控制
- 提供清晰的变更日志
- 包含版本间迁移指南
评论