10_组件通信

组件通信:数据交互与状态共享

在Angular应用中,组件并非孤立存在,它们需要相互协作、共享数据才能构建完整的功能。组件通信是Angular开发的核心技能,不同组件关系(父子、跨级、兄弟)需要采用不同的通信策略。本章将系统讲解Angular中组件通信的多种方式,包括输入输出绑定、服务共享、依赖注入等,并结合Angular v20的Signals特性,展示现代化的状态管理方案。

1. 父子组件通信:@Input与@Output

父子组件是最常见的组件关系(如页面与子组件、容器与内容组件),这种关系下的通信通过Angular的输入输出机制实现,简单直接且类型安全。

1.1 父传子:@Input装饰器传递数据

@Input()装饰器用于定义组件的输入属性,允许父组件向子组件传递数据。

子组件定义输入属性

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

// 定义接收的数据类型
interface Product {
  id: number;
  name: string;
  price: number;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ product?.name }}</h3>
      <p>价格:{{ product?.price | currency }}</p>
      @if (isOnSale) {
        <span class="sale">促销中</span>
      }
    </div>
  `,
  styles: [`
    .card { border: 1px solid #ccc; padding: 1rem; margin: 1rem; }
    .sale { color: red; font-weight: bold; }
  `]
})
export class ProductCardComponent {
  // 基础输入属性
  @Input() product?: Product;
  
  // 带默认值的输入属性
  @Input() isOnSale = false;
  
  // 重命名输入属性(模板中使用differentName,类中使用inputName)
  @Input('differentName') inputName = '默认值';
}

父组件传递数据

父组件通过模板绑定将数据传递给子组件的输入属性:

// parent.component.ts
import { Component } from '@angular/core';
import { ProductCardComponent } from './child.component';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductCardComponent],
  template: `
    <h2>商品列表</h2>
    <!-- 传递对象 -->
    <app-product-card 
      [product]="currentProduct" 
      [isOnSale]="true"
    ></app-product-card>
    
    <!-- 传递基本类型 -->
    <app-product-card 
      [product]="{ id: 2, name: '耳机', price: 299 }" 
      [differentName]="'自定义名称'"
    ></app-product-card>
  `
})
export class ProductListComponent {
  currentProduct = { id: 1, name: '笔记本电脑', price: 5999 };
}

监听输入属性变化

使用ngOnChanges生命周期钩子监听输入属性的变化:

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({ /* ... */ })
export class ProductCardComponent implements OnChanges {
  @Input() product?: Product;
  
  // 监听输入属性变化
  ngOnChanges(changes: SimpleChanges) {
    // 检查product属性的变化
    if (changes['product']) {
      const oldValue = changes['product'].previousValue;
      const newValue = changes['product'].currentValue;
      console.log('商品变化:', oldValue, '→', newValue);
      
      // 首次接收值时初始化
      if (changes['product'].firstChange && newValue) {
        this.initializeProduct(newValue);
      }
    }
  }
  
  private initializeProduct(product: Product) {
    // 初始化逻辑
  }
}

1.2 子传父:@Output与EventEmitter发送事件

@Output()装饰器结合EventEmitter允许子组件向父组件发送事件,传递数据或通知状态变化。

子组件定义输出事件

// child.component.ts
import { Component, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-quantity-selector',
  standalone: true,
  template: `
    <div class="quantity-controls">
      <button (click)="decrement()" [disabled]="quantity <= 1">-</button>
      <span>{{ quantity }}</span>
      <button (click)="increment()">+</button>
      <button (click)="confirm()">确认</button>
    </div>
  `
})
export class QuantitySelectorComponent {
  quantity = 1;
  
  // 定义输出事件(传递数量变化)
  @Output() quantityChange = new EventEmitter<number>();
  
  // 定义自定义事件(传递确认信息)
  @Output() confirmSelection = new EventEmitter<{ quantity: number, timestamp: number }>();
  
  increment() {
    this.quantity++;
    this.quantityChange.emit(this.quantity); // 发送当前数量
  }
  
  decrement() {
    if (this.quantity > 1) {
      this.quantity--;
      this.quantityChange.emit(this.quantity); // 发送当前数量
    }
  }
  
  confirm() {
    // 发送复杂数据
    this.confirmSelection.emit({
      quantity: this.quantity,
      timestamp: Date.now()
    });
  }
}

父组件监听事件

父组件通过模板绑定监听子组件的输出事件:

// parent.component.ts
import { Component } from '@angular/core';
import { QuantitySelectorComponent } from './child.component';

@Component({
  selector: 'app-order-form',
  standalone: true,
  imports: [QuantitySelectorComponent],
  template: `
    <h2>订单表单</h2>
    <p>当前数量:{{ selectedQuantity }}</p>
    <app-quantity-selector
      (quantityChange)="onQuantityChange($event)"
      (confirmSelection)="onConfirm($event)"
    ></app-quantity-selector>
    @if (lastConfirm) {
      <p>上次确认:{{ lastConfirm.quantity }}个({{ lastConfirm.timestamp | date:'medium' }})</p>
    }
  `
})
export class OrderFormComponent {
  selectedQuantity = 1;
  lastConfirm?: { quantity: number; timestamp: number };
  
  // 处理数量变化事件
  onQuantityChange(newQuantity: number) {
    this.selectedQuantity = newQuantity;
    console.log('数量变为:', newQuantity);
  }
  
  // 处理确认事件
  onConfirm(data: { quantity: number; timestamp: number }) {
    this.lastConfirm = data;
    alert(`已确认购买${data.quantity}个`);
  }
}

双向绑定简化

对于xxx输入和xxxChange输出的组合,可以使用[(xxx)]语法实现双向绑定:

// 子组件保持输出事件命名为quantityChange
@Output() quantityChange = new EventEmitter<number>();

// 父组件使用双向绑定
<app-quantity-selector [(quantity)]="selectedQuantity"></app-quantity-selector>

// 等价于
<app-quantity-selector 
  [quantity]="selectedQuantity"
  (quantityChange)="selectedQuantity = $event"
></app-quantity-selector>

2. 父组件调用子组件方法:@ViewChild

当父组件需要直接调用子组件的方法或访问其属性时,可以使用@ViewChild()装饰器获取子组件实例。

2.1 基础用法

// child.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-message-box',
  standalone: true,
  template: `
    <div class="message">{{ message }}</div>
  `
})
export class MessageBoxComponent {
  message = '初始消息';
  
  // 子组件方法
  showMessage(text: string, duration: number = 3000) {
    this.message = text;
    setTimeout(() => this.message = '', duration);
  }
  
  clear() {
    this.message = '';
  }
}

父组件通过@ViewChild()获取子组件实例:

// parent.component.ts
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { MessageBoxComponent } from './child.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [MessageBoxComponent],
  template: `
    <button (click)="showSuccess()">显示成功消息</button>
    <button (click)="clearMessage()">清除消息</button>
    <app-message-box></app-message-box>
  `
})
export class ParentComponent implements AfterViewInit {
  // 获取子组件实例(在视图初始化后可用)
  @ViewChild(MessageBoxComponent) messageBox?: MessageBoxComponent;
  
  // 视图初始化完成后才能访问子组件
  ngAfterViewInit() {
    // 初始调用
    this.messageBox?.showMessage('组件加载完成', 2000);
  }
  
  showSuccess() {
    // 调用子组件方法
    this.messageBox?.showMessage('操作成功!');
  }
  
  clearMessage() {
    // 调用子组件方法
    this.messageBox?.clear();
  }
}

2.2 多个子组件与选择器

当存在多个相同类型的子组件时,可通过模板引用变量区分:

// 父组件模板
<app-message-box #msg1></app-message-box>
<app-message-box #msg2></app-message-box>

// 父组件类
@ViewChild('msg1') messageBox1?: MessageBoxComponent;
@ViewChild('msg2') messageBox2?: MessageBoxComponent;

// 使用
showDifferentMessages() {
  this.messageBox1?.showMessage('消息1');
  this.messageBox2?.showMessage('消息2');
}

对于动态生成的多个子组件,使用@ViewChildren()获取所有实例:

import { Component, ViewChildren, QueryList, AfterViewInit } from '@angular/core';
import { MessageBoxComponent } from './child.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [MessageBoxComponent],
  template: `
    @for (i of [1,2,3]; track i) {
      <app-message-box></app-message-box>
    }
    <button (click)="updateAll()">更新所有消息</button>
  `
})
export class ParentComponent implements AfterViewInit {
  // 获取所有子组件实例
  @ViewChildren(MessageBoxComponent) messageBoxes?: QueryList<MessageBoxComponent>;
  
  updateAll() {
    this.messageBoxes?.forEach((box, index) => {
      box.showMessage(`消息 ${index + 1} 已更新`);
    });
  }
}

3. 跨级组件通信:服务与依赖注入

对于非父子关系的组件(如兄弟组件、祖孙组件、任意层级组件),使用服务(Service)结合依赖注入是最优雅的通信方式。通过单例服务共享状态,实现组件解耦。

3.1 基于Service的状态共享

创建共享服务存储状态,并提供方法修改状态和订阅状态变化:

// theme.service.ts
import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' }) // 根注入器提供单例服务
export class ThemeService {
  // 使用Signal存储主题状态(v20推荐)
  private darkMode = signal(false);
  
  // 计算属性:主题名称
  themeName = computed(() => this.darkMode() ? 'dark' : 'light');
  
  // 切换主题的方法
  toggleTheme() {
    this.darkMode.update(mode => !mode);
  }
  
  // 设置主题的方法
  setDarkMode(enable: boolean) {
    this.darkMode.set(enable);
  }
}

任意组件都可以注入该服务,读取或修改主题状态:

// header.component.ts(修改状态)
import { Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';

@Component({
  selector: 'app-header',
  standalone: true,
  template: `
    <header>
      <h1>我的应用</h1>
      <button (click)="toggleTheme()">
        切换到{{ themeName() === 'light' ? '深色' : '浅色' }}模式
      </button>
    </header>
  `
})
export class HeaderComponent {
  private themeService = inject(ThemeService);
  themeName = this.themeService.themeName; // 共享计算属性
  
  toggleTheme() {
    this.themeService.toggleTheme(); // 调用服务方法修改状态
  }
}
// content.component.ts(读取状态)
import { Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';

@Component({
  selector: 'app-content',
  standalone: true,
  template: `
    <div class="content" [class.dark]="isDarkMode()">
      <p>当前主题:{{ themeName() }}</p>
      <p>这是页面内容...</p>
    </div>
  `,
  styles: [`
    .content { padding: 1rem; }
    .dark { background: #333; color: white; }
  `]
})
export class ContentComponent {
  private themeService = inject(ThemeService);
  themeName = this.themeService.themeName;
  
  // 派生状态
  isDarkMode = computed(() => this.themeService.themeName() === 'dark');
}

3.2 基于Observable的事件总线

对于复杂的跨组件通信场景,可使用RxJS的Subject创建事件总线,实现多组件间的事件发布与订阅:

// event-bus.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';

// 定义事件结构
interface EventBusEvent<T = any> {
  type: string;
  data: T;
}

@Injectable({ providedIn: 'root' })
export class EventBusService {
  private eventBus = new Subject<EventBusEvent>();
  
  // 发布事件
  publish<T>(type: string, data: T) {
    this.eventBus.next({ type, data });
  }
  
  // 订阅特定类型的事件
  subscribe<T>(type: string): Observable<T> {
    return this.eventBus.pipe(
      filter(event => event.type === type),
      map(event => event.data as T)
    );
  }
}

组件A发布事件:

// component-a.ts
import { Component, inject } from '@angular/core';
import { EventBusService } from './event-bus.service';

@Component({
  selector: 'app-component-a',
  standalone: true,
  template: `
    <button (click)="sendMessage()">发送消息到组件B</button>
  `
})
export class ComponentA {
  private eventBus = inject(EventBusService);
  
  sendMessage() {
    // 发布事件
    this.eventBus.publish('user-message', {
      text: '来自组件A的问候',
      timestamp: Date.now()
    });
  }
}

组件B订阅事件:

// component-b.ts
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { EventBusService } from './event-bus.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-component-b',
  standalone: true,
  template: `
    <div class="messages">
      <h3>收到的消息</h3>
      @for (msg of messages; track msg.timestamp) {
        <p>{{ msg.text }} ({{ msg.timestamp | date:'medium' }})</p>
      }
    </div>
  `
})
export class ComponentB implements OnInit, OnDestroy {
  private eventBus = inject(EventBusService);
  private subscription?: Subscription;
  messages: Array<{ text: string; timestamp: number }> = [];
  
  ngOnInit() {
    // 订阅事件
    this.subscription = this.eventBus.subscribe<{ text: string; timestamp: number }>('user-message')
      .subscribe(message => {
        this.messages.push(message);
      });
  }
  
  // 组件销毁时取消订阅,防止内存泄漏
  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

4. 内容投影中的通信:@ContentChild

内容投影(Content Projection)允许父组件向子组件插入内容(通过ng-content),这种场景下的通信可通过@ContentChild()获取投影内容中的组件或元素。

4.1 基础用法

子组件(容器组件)通过@ContentChild()获取投影内容中的组件:

// card.component.ts(容器组件)
import { Component, ContentChild, AfterContentInit } from '@angular/core';
import { CardHeaderComponent } from './card-header.component';

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <!-- 内容投影插槽 -->
      <ng-content select="app-card-header"></ng-content>
      <div class="card-body">
        <ng-content></ng-content>
      </div>
      <div class="card-footer">
        <button (click)="onReset()">重置标题</button>
      </div>
    </div>
  `
})
export class CardComponent implements AfterContentInit {
  // 获取投影内容中的CardHeaderComponent
  @ContentChild(CardHeaderComponent) header?: CardHeaderComponent;
  
  // 内容投影初始化完成后访问
  ngAfterContentInit() {
    console.log('投影的标题组件:', this.header);
  }
  
  onReset() {
    // 调用投影组件的方法
    this.header?.resetTitle();
  }
}

被投影的子组件:

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

@Component({
  selector: 'app-card-header',
  standalone: true,
  template: `
    <h2>{{ title }}</h2>
    <button (click)="changeTitle()">修改标题</button>
  `
})
export class CardHeaderComponent {
  title = '默认标题';
  
  changeTitle() {
    this.title = '修改后的标题';
  }
  
  resetTitle() {
    this.title = '默认标题';
  }
}

父组件使用:

// parent.component.ts
import { Component } from '@angular/core';
import { CardComponent } from './card.component';
import { CardHeaderComponent } from './card-header.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [CardComponent, CardHeaderComponent],
  template: `
    <app-card>
      <!-- 投影内容 -->
      <app-card-header></app-card-header>
      <p>这是卡片内容</p>
    </app-card>
  `
})
export class ParentComponent {}

4.2 多个投影内容:@ContentChildren

当投影多个组件时,使用@ContentChildren()获取所有实例:

import { Component, ContentChildren, QueryList, AfterContentInit } from '@angular/core';
import { TabComponent } from './tab.component';

@Component({
  selector: 'app-tabs',
  standalone: true,
  template: `
    <div class="tabs">
      <div class="tab-buttons">
        @for (tab of tabs; track tab) {
          <button (click)="selectTab(tab)" [class.active]="tab.isActive">
            {{ tab.title }}
          </button>
        }
      </div>
      <div class="tab-content">
        <ng-content></ng-content>
      </div>
    </div>
  `
})
export class TabsComponent implements AfterContentInit {
  // 获取所有投影的TabComponent
  @ContentChildren(TabComponent) tabs?: QueryList<TabComponent>;
  
  ngAfterContentInit() {
    // 默认激活第一个标签
    this.tabs?.first?.activate();
  }
  
  selectTab(selectedTab: TabComponent) {
    // 先禁用所有标签
    this.tabs?.forEach(tab => tab.deactivate());
    // 激活选中的标签
    selectedTab.activate();
  }
}

5. 路由参数与查询参数:跨页面通信

在单页应用中,不同路由页面间的通信可通过路由参数、查询参数或路由状态实现,这是一种无耦合的通信方式。

5.1 路由参数传递

定义带参数的路由:

// app.routes.ts
export const routes = [
  { path: 'user/:id', component: UserProfileComponent }
];

导航时传递参数:

// 组件中通过路由导航传递参数
this.router.navigate(['/user', 123]); // 导航到/user/123

目标组件接收参数:

// user-profile.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

@Component({ /* ... */ })
export class UserProfileComponent {
  private route = inject(ActivatedRoute);
  
  // 将路由参数转换为Signal
  userId = toSignal(
    this.route.paramMap.pipe(map(params => params.get('id'))),
    { initialValue: null }
  );
}

5.2 查询参数传递

导航时添加查询参数:

// 导航到/products?category=electronics&sort=price
this.router.navigate(['/products'], {
  queryParams: { category: 'electronics', sort: 'price' }
});

接收查询参数:

// product-list.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class ProductListComponent {
  private route = inject(ActivatedRoute);
  
  // 获取查询参数
  queryParams = toSignal(this.route.queryParamMap, { initialValue: new Map() });
  
  ngOnInit() {
    const category = this.queryParams().get('category');
    const sort = this.queryParams().get('sort');
    // 根据参数加载数据
  }
}

6. Angular v20通信新特性:Signals与独立组件

Angular v20引入的Signals为组件通信提供了更简洁、更高效的方式,尤其适合独立组件架构。

6.1 基于Signals的双向绑定

结合Signals实现更简洁的双向通信:

// child.component.ts
import { Component, input, output } from '@angular/core'; // v20输入输出新语法

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <button (click)="decrement()">-</button>
    <span>{{ count() }}</span>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  // v20新语法:input()替代@Input()
  count = input(0);
  
  // v20新语法:output()替代@Output()
  countChange = output<number>();
  
  increment() {
    this.countChange.emit(this.count() + 1);
  }
  
  decrement() {
    this.countChange.emit(this.count() - 1);
  }
}

父组件使用:

import { Component, signal } from '@angular/core';
import { CounterComponent } from './counter.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [CounterComponent],
  template: `
    <p>父组件计数:{{ parentCount() }}</p>
    <app-counter 
      [count]="parentCount()"
      (countChange)="parentCount.set($event)"
    ></app-counter>
  `
})
export class ParentComponent {
  parentCount = signal(0);
}

6.2 独立组件的服务共享

独立组件无需模块即可直接注入服务,简化了跨组件通信:

// 独立组件直接注入全局服务
import { Component, inject } from '@angular/core';
import { DataService } from './data.service';

@Component({
  selector: 'app-standalone-component',
  standalone: true,
  template: `{{ data() | json }}`
})
export class StandaloneComponent {
  private dataService = inject(DataService);
  data = this.dataService.sharedData; // 直接使用服务中的Signal
}

7. 组件通信方式对比与选型

通信方式 适用场景 优点 缺点
@Input/@Output 父子组件 简单直接,类型安全 只适用于直接父子关系,多层级传递繁琐
@ViewChild/@ContentChild 父子组件/内容投影 可直接调用方法 强耦合,依赖组件实例
共享服务(Signal) 任意组件,状态共享 解耦,适合全局状态 需要设计服务接口,单例可能导致状态混乱
事件总线(Subject) 任意组件,事件通知 解耦,适合复杂交互 需要管理订阅,可能导致事件泛滥
路由参数 路由页面间 无耦合,支持刷新 只适合简单数据,暴露在URL中

选型建议

  • 直接父子关系:优先使用@Input/@Output
  • 复杂状态共享:使用Signal+共享服务
  • 松散耦合的跨组件通信:使用事件总线
  • 路由页面间通信:使用路由参数或查询参数
  • 避免过度使用@ViewChild(增加耦合度)

总结

组件通信是Angular应用开发的核心环节,本章介绍了多种通信方式,从简单的父子组件交互到复杂的跨级组件通信,每种方式都有其适用场景。

Angular v20的Signals特性为组件通信带来了新的可能,通过响应式信号实现状态共享,既保持了组件解耦,又简化了数据流管理。在实际开发中,应根据组件关系、数据复杂度和应用规模选择合适的通信方式,优先采用服务共享和Signals等现代化方案,构建可维护、可扩展的Angular应用。

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