一、先搞懂:为啥Angular组件通信总出问题?

你有没有过这种经历:写Angular项目时,父组件改了个数据,子组件死活不更新;或者子组件传个值给父组件,父组件收不到,找bug找俩小时?其实核心问题不是你写得差,是你没选对通信方式——就像你要送快递,明明同城寄普通件就行,你非要寄国际件,不仅慢还容易丢。

Angular的组件通信方式有好几种,各有各的适用场景,用错了就会出问题。比如简单的父子传值用服务,反而会让代码变复杂;复杂的跨组件传值用输入输出,根本传不过去。下面就按实际遇到的业务场景,一步步教你选对通信方式。

二、第一种场景:父子组件直接传值,用「输入输出」

2.1 啥场景用输入输出?

最常见的就是父子组件的简单传值,比如父组件要把一个商品ID传给子组件,子组件要把点击按钮的事件传给父组件。这种场景用输入输出最方便,不用额外搞复杂的东西。

2.2 完整示例(技术栈:Angular 16)

先写父组件,父组件里定义一个商品ID,要传给子组件;同时父组件要有个方法,接收子组件传过来的点击事件。

// 父组件 parent.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-parent',
  template: `
    <!-- 父组件模板:把商品ID传给子组件的goodsId属性(输入),同时绑定子组件的goodsClick事件(输出) -->
    <app-child 
      [goodsId]="currentGoodsId" 
      (goodsClick)="handleGoodsClick($event)"
    ></app-child>
  `,
})
export class ParentComponent {
  // 父组件自己的商品ID,要传给子组件
  currentGoodsId = 1001;

  // 父组件的方法,接收子组件传过来的点击事件参数
  handleGoodsClick(goodsInfo: {id: number, name: string}) {
    console.log('父组件收到子组件的点击:', goodsInfo);
    // 这里可以做后续操作,比如更新父组件的状态、调用接口等
  }
}

再写子组件,子组件要接收父组件传的商品ID,还要把点击事件传给父组件。

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

@Component({
  selector: 'app-child',
  template: `
    <!-- 子组件模板:显示父组件传过来的商品ID,点击按钮触发事件 -->
    <div>商品ID:{{ goodsId }}</div>
    <button (click)="onClick()">点击我传值给父组件</button>
  `,
})
export class ChildComponent {
  // 输入属性:接收父组件传过来的goodsId,类型是数字
  @Input() goodsId!: number;
  // 输出属性:定义一个事件,用来传值给父组件,EventEmitter的泛型是传的参数类型
  @Output() goodsClick = new EventEmitter<{id: number, name: string}>();

  // 子组件的点击方法,触发时调用goodsClick.emit()传值
  onClick() {
    // 要传给父组件的参数
    const goodsInfo = { id: this.goodsId, name: '苹果手机' };
    // 调用emit方法,把参数传给父组件
    this.goodsClick.emit(goodsInfo);
  }
}

2.3 优缺点和注意事项

优点:简单直接,不用额外的依赖,适合父子组件的小范围传值,性能也不错。 缺点:只能父子组件用,隔代或者兄弟组件传不了;如果传的是复杂对象,要注意对象引用的问题,比如父组件改了对象的某个属性,子组件可能感知不到,这时候要把对象整个替换掉。 注意事项:输入属性可以加默认值,比如@Input() goodsId: number = 0;,避免父组件没传值时报错;输出属性的事件名要和模板里的绑定名一致,别写错。

三、第二种场景:跨组件共享数据,用「服务注入」

3.1 啥场景用服务注入?

比如整个项目里的用户登录状态、购物车数据,很多组件都要用到;或者隔代组件(比如父组件的子组件的子组件)要传值;或者兄弟组件之间要传值。这种场景用服务最方便,因为服务是单例的(默认情况下),所有组件都能访问到同一个实例。

3.2 完整示例(技术栈:Angular 16)

先写一个服务,专门管理购物车的数据。

// 购物车服务 cart.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

// 定义购物车商品的类型
interface CartItem {
  id: number;
  name: string;
  price: number;
}

@Injectable({
  providedIn: 'root', // 把服务提供到根模块,整个项目只有一个实例
})
export class CartService {
  // 用BehaviorSubject来管理购物车数据,初始值是一个空数组
  private cartItems = new BehaviorSubject<CartItem[]>([]);
  // 把BehaviorSubject转换成Observable,让组件只能订阅,不能直接改
  cartItems$ = this.cartItems.asObservable();

  // 方法:添加商品到购物车
  addItem(item: CartItem) {
    // 先拿到当前的购物车数据
    const currentItems = this.cartItems.value;
    // 把新商品加进去,替换原来的数组(这样才能触发订阅更新)
    this.cartItems.next([...currentItems, item]);
  }

  // 方法:清空购物车
  clearCart() {
    this.cartItems.next([]);
  }
}

再写一个商品列表组件(兄弟组件1),用来添加商品到购物车。

// 商品列表组件 product-list.component.ts
import { Component } from '@angular/core';
import { CartService } from './cart.service';

@Component({
  selector: 'app-product-list',
  template: `
    <div>
      <h3>商品列表</h3>
      <button (click)="addApple()">添加苹果到购物车</button>
      <button (click)="addBanana()">添加香蕉到购物车</button>
    </div>
  `,
})
export class ProductListComponent {
  // 注入购物车服务
  constructor(private cartService: CartService) {}

  // 添加苹果到购物车
  addApple() {
    const apple = { id: 1, name: '苹果', price: 5 };
    this.cartService.addItem(apple);
  }

  // 添加香蕉到购物车
  addBanana() {
    const banana = { id: 2, name: '香蕉', price: 3 };
    this.cartService.addItem(banana);
  }
}

再写一个购物车组件(兄弟组件2),用来显示购物车的商品。

// 购物车组件 cart.component.ts
import { Component, OnInit } from '@angular/core';
import { CartService } from './cart.service';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-cart',
  template: `
    <div>
      <h3>购物车</h3>
      <!-- 用async管道订阅cartItems$,自动处理订阅和取消订阅 -->
      <ul>
        <li *ngFor="let item of cartItems$ | async">{{ item.name }} - ¥{{ item.price }}</li>
      </ul>
      <button (click)="clearCart()">清空购物车</button>
    </div>
  `,
})
export class CartComponent implements OnInit {
  // 购物车数据的Observable
  cartItems$!: Observable<{id: number, name: string, price: number}[]>;

  // 注入购物车服务
  constructor(private cartService: CartService) {}

  ngOnInit() {
    // 初始化时拿到服务里的cartItems$
    this.cartItems$ = this.cartService.cartItems$;
  }

  // 清空购物车
  clearCart() {
    this.cartService.clearCart();
  }
}

3.3 优缺点和注意事项

优点:适合跨组件、多组件共享数据,不用层层传值,代码结构清晰;如果用BehaviorSubject,还能拿到初始值,组件初始化时就能拿到最新的数据。 缺点:如果用得太随意,会导致组件之间的耦合变高,比如一个组件改了服务里的数据,其他组件都会受到影响,排查问题会变难;如果服务里的逻辑太复杂,会让服务变得臃肿。 注意事项:服务的providedIn属性要设置对,比如只想让某个模块里的组件用,就设成providedIn: '模块名';尽量把服务的逻辑拆分成多个小服务,不要把所有逻辑都放在一个服务里;用Observable的时候,记得取消订阅,避免内存泄漏(用async管道的话会自动取消订阅,很方便)。

四、第三种场景:父组件直接调用子组件的方法,用「模板引用」

4.1 啥场景用模板引用?

比如父组件要主动调用子组件的某个方法,比如子组件有个重置表单的方法,父组件要在某个操作后重置子组件的表单;或者父组件要获取子组件的某个属性。这种场景用模板引用最方便。

4.2 完整示例(技术栈:Angular 16)

先写子组件,子组件有个重置表单的方法。

// 子组件 child-form.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-child-form',
  template: `
    <div>
      <input type="text" [(ngModel)]="inputValue" placeholder="请输入内容">
    </div>
  `,
})
export class ChildFormComponent {
  // 子组件的输入值
  inputValue = '';

  // 子组件的方法:重置表单
  resetForm() {
    this.inputValue = '';
    console.log('子组件的表单已重置');
  }
}

再写父组件,父组件要调用子组件的resetForm方法。

// 父组件 parent-form.component.ts
import { Component, ViewChild } from '@angular/core';
import { ChildFormComponent } from './child-form.component';

@Component({
  selector: 'app-parent-form',
  template: `
    <div>
      <!-- 给子组件加模板引用名#childForm -->
      <app-child-form #childForm></app-child-form>
      <!-- 父组件的按钮,点击时调用子组件的resetForm方法 -->
      <button (click)="resetChildForm()">重置子组件表单</button>
    </div>
  `,
})
export class ParentFormComponent {
  // 用ViewChild装饰器,通过模板引用名#childForm拿到子组件的实例
  @ViewChild('childForm') childForm!: ChildFormComponent;

  // 父组件的方法:调用子组件的resetForm
  resetChildForm() {
    // 调用子组件的方法
    this.childForm.resetForm();
  }
}

4.3 优缺点和注意事项

优点:父组件可以直接访问子组件的实例,调用方法、获取属性都很方便。 缺点:会让父子组件的耦合变高,父组件依赖子组件的内部实现,如果子组件改了方法名或者属性名,父组件也要跟着改;只能父组件访问子组件,子组件不能访问父组件的实例。 注意事项:ViewChild要在子组件初始化之后才能拿到,所以如果在ngOnInit里调用子组件的方法,可能会拿到undefined,最好在ngAfterViewInit里操作;如果子组件是动态加载的,要用ViewChildren或者@ContentChildren。

五、怎么选:给你个选法

现在你知道了三种常用的通信方式,怎么选呢?给你个简单的判断逻辑:

  1. 先看是不是父子组件的简单传值:是就用输入输出,简单高效。
  2. 再看是不是要跨组件共享数据:是就用服务注入,适合多组件、隔代、兄弟组件。
  3. 最后看是不是父组件要主动调用子组件的方法:是就用模板引用,直接访问子组件实例。

另外,还有一种场景:如果组件之间的通信很复杂,比如要传的参数很多,或者有很多逻辑处理,那就要考虑用状态管理库,比如NgRx,但这是进阶的用法,大部分业务场景用上面三种就够了。

六、常见坑点避坑

  1. 输入输出传复杂对象:比如父组件传一个对象给子组件,子组件改了对象的某个属性,父组件也会变,因为对象是引用类型。如果不想互相影响,要传对象的深拷贝。
  2. 服务的单例问题:如果服务的providedIn设错了,可能会创建多个实例,导致数据不同步。比如在模块里提供了服务,又在根模块里提供了,就会有两个实例。
  3. ViewChild的时机问题:父组件的ngOnInit里拿不到子组件的实例,要等子组件初始化完成,也就是ngAfterViewInit之后才能拿。
  4. 服务里的Observable订阅问题:如果手动订阅了Observable,要在组件销毁的时候取消订阅,不然会导致内存泄漏,比如:
// 手动订阅的例子
import { Component, OnDestroy } from '@angular/core';
import { CartService } from './cart.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-test',
  template: '',
})
export class TestComponent implements OnDestroy {
  private subscription!: Subscription;

  constructor(private cartService: CartService) {
    // 手动订阅
    this.subscription = this.cartService.cartItems$.subscribe(items => {
      console.log('购物车数据:', items);
    });
  }

  ngOnDestroy() {
    // 组件销毁时取消订阅
    this.subscription.unsubscribe();
  }
}

七、总结

Angular组件通信的核心是「按需选择」,没有最好的方式,只有最适合的方式。父子简单传值用输入输出,跨组件共享用服务,父组件主动调用子组件用模板引用。选对了方式,不仅代码写起来轻松,bug也会少很多。