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 应用的可维护性和扩展性奠定基础。

posted @ 2025-09-21 18:19  S&L·chuck  阅读(10)  评论(0)    收藏  举报