1. 项目概述:Angular组件通信不是“传参”而是构建数据流契约

在 Angular 项目里,当你写完一个漂亮的用户列表组件( UserListComponent ),又做了一个功能完备的用户详情弹窗( UserDetailModalComponent ),却发现点击列表项后,弹窗里空空如也——这不是代码没写完,而是你还没和 Angular 建立起一套清晰、可维护、可预测的数据传递规则。 Component Communication in Angular 这个标题背后,根本不是“怎么把一个字符串从A塞到B”,而是一整套围绕 数据所有权、变更控制权、生命周期协同 展开的设计哲学。它直接决定你的项目是能轻松支撑20个组件协作的中型系统,还是三个月后就因状态散落、响应错乱而陷入“改一处崩三处”的泥潭。

我带过6个不同行业的 Angular 团队,从医疗影像看片系统到工业设备远程监控平台,所有最终崩盘的项目,90%以上都卡死在组件通信这一环。不是技术不会,而是早期图快,用 @Input 硬塞对象引用、用 EventEmitter 滥发事件、甚至直接 document.getElementById 拿 DOM 元素改值——这些操作单看都能跑通,但当业务逻辑叠加、异步请求嵌套、用户频繁切换视图时,数据流就变成一团无法追踪的毛线球。真正的 Angular 组件通信,核心是建立 显式契约 :谁负责提供数据?谁有权触发变更?数据变更后,哪些组件必须响应?响应的时机和顺序如何保证?这三点不明确,再炫的动画、再规范的命名都是空中楼阁。

关键词 @Input @Output Service 并非并列选项,而是分层使用的工具链。 @Input 解决父子组件间 单向数据流 的声明式绑定,它强制你思考“这个数据是否应该由父组件全权管理”; @Output 不是简单的“发个消息”,而是定义子组件向父组件 发起变更请求 的标准化接口,类似“我需要修改用户状态,请父组件按约定流程处理”;而 Service 则是跨层级、跨模块的 状态协调中枢 ,它不存储业务数据本身,而是封装数据获取、缓存、同步、冲突解决等逻辑。很多人误以为 Service 就是“全局变量”,实则恰恰相反——一个设计良好的 Service 会通过 BehaviorSubject Signal 严格控制状态读写权限,让任何组件都无法绕过约定直接篡改数据。如果你现在正被“子组件改了数据,父组件没更新”或“多个组件同时调用同一个 API 导致重复请求”困扰,那说明你缺的不是某个装饰器的用法,而是这套契约体系的落地实践。

2. 核心思路拆解:为什么必须分层设计通信机制?

2.1 从“能跑通”到“可维护”的本质跃迁

很多开发者初学 Angular 时,会自然地把组件通信理解为“数据搬运工”。看到官方文档里 @Input 接收父组件传来的用户对象, @Output 发送一个点击事件,就认为掌握了全部。但真实项目里,这种思维很快就会撞墙。举个典型场景:一个仪表盘页面包含 DeviceStatusCardComponent (设备状态卡片)、 AlertHistoryListComponent (告警历史列表)和 ControlPanelComponent (控制面板)。当用户在控制面板点击“重启设备”按钮时,三个组件都需要同步更新:状态卡片要变红、历史列表要新增一条记录、控制面板按钮要禁用并显示加载中。如果只用 @Input/@Output ,你得在控制面板发出事件 → 父仪表盘组件接收 → 再分别 @Input 给另外两个子组件,形成一个脆弱的“事件链”。一旦中间某个环节漏掉 OnChanges 监听或 ChangeDetectionStrategy.OnPush 配置不当,整个链条就断了。

分层设计的核心价值,就是把这种 隐式依赖 转化为 显式契约 。我们不再问“怎么让A通知B”,而是定义:“设备控制指令”由 DeviceControlService 统一接收和分发;“设备实时状态”由 DeviceStatusService 提供响应式数据流;“告警历史”由 AlertHistoryService 管理增删查。每个组件只关心自己该订阅什么、该触发什么,完全不感知其他组件的存在。这样做的好处是灾难性的:当你要新增一个 MaintenanceScheduleComponent (维保计划组件)时,只需让它订阅 DeviceStatusService 的状态变更,无需修改现有任何一个组件的代码。这就是所谓“开闭原则”在通信层面的落地——对扩展开放,对修改关闭。

2.2 @Input/@Output:父子通信的“宪法性条款”

@Input @Output 是 Angular 框架层硬编码的通信原语,它们的存在意义远超语法糖。 @Input 强制执行 单向数据流(Unidirectional Data Flow) ,这是前端框架稳定性的基石。想象一下,如果子组件可以随意修改 @Input 接收的对象属性,父组件的状态就彻底失控了。Angular 通过 OnPush 策略进一步强化这一点:只有当 @Input 绑定的引用发生变更(比如新对象、新数组),或者触发了 EventEmitter 事件,组件才会重新渲染。这直接避免了“父组件状态没变,子组件却疯狂重绘”的性能黑洞。

@Output 的精妙之处在于它定义的是 意图(Intent)而非结果(Result) 。子组件通过 @Output 发出的 EventEmitter ,本质上是在说:“我这里发生了某件事,需要外部协调处理”。比如 UserFormComponent 的保存按钮点击,它不该自己调用 HTTP 请求,而应发出 saveRequested = new EventEmitter<User>() 。父组件收到后,才决定是调用 UserService.save() 、显示确认弹窗,还是先校验权限。这种分离让子组件彻底无状态化,可复用性极高——同一个表单组件,既能嵌入用户管理页,也能嵌入批量导入页,只需父组件处理不同的 saveRequested 逻辑即可。

提示:永远不要在 @Output 中传递复杂对象的深层属性(如 user.profile.avatarUrl ),而应传递完整对象或 ID。否则当父组件需要更新 user.profile 整个对象时,子组件无法感知变更,导致 UI 与数据不一致。

2.3 Service:跨组件通信的“中央银行”

当通信跨越父子关系,进入兄弟组件、隔代组件甚至不同模块时, @Input/@Output 就力不从心了。此时 Service 成为唯一可靠的选择,但它绝不是“万能胶水”。一个设计糟糕的 Service 会成为新的混乱源头。关键在于区分 State Management Service Data Access Service

  • Data Access Service (如 UserService ):职责纯粹——封装 HTTP 调用、缓存策略、错误重试。它不持有任何应用状态,每次调用 getUser(id) 都是独立的。
  • State Management Service (如 SelectedUserService ):职责是管理共享状态的生命周期和访问契约。它内部用 BehaviorSubject<User | null> 存储当前选中用户,并暴露 selectedUser$ 作为只读 Observable。任何组件都可以订阅它,但 只有 Service 自身能调用 next() 方法更新值 。这就确保了状态变更的唯一入口,杜绝了多点修改导致的竞态条件。

我见过最典型的反模式是:开发者创建一个 SharedDataService ,里面放一堆 public user: User public config: Config 的 public 属性,然后所有组件直接 this.sharedData.user = newUser 。这等于把全局变量包装了一层 class 外壳,完全违背了 Angular 的响应式设计思想。真正的 Service 必须通过 private 属性 + public Observable/Signal + public 更新方法(如 selectUser(user: User) )来构建受控的数据管道。

3. 核心细节解析与实操要点:从声明到落地的关键陷阱

3.1 @Input 的深度用法与避坑指南

@Input 看似简单,但实际使用中隐藏着大量影响稳定性的细节。首先, 输入属性的类型声明必须精确 。常见错误是声明为 @Input() user: any; ,这会让 TypeScript 类型检查形同虚设。正确做法是定义明确的接口:

export interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

@Component({
  selector: 'app-user-card',
  template: `<div>{{ user.name }} (ID: {{ user.id }})</div>`
})
export class UserCardComponent {
  @Input() user!: User; // 使用非空断言,但需确保父组件必传
}

更安全的做法是结合 @Input 的 setter,实现属性变更的拦截和验证:

export class UserCardComponent implements OnChanges {
  private _user: User | null = null;
  
  @Input()
  set user(value: User | null) {
    if (!value) {
      console.warn('UserCardComponent received null user, using default');
      this._user = { id: 0, name: 'Unknown', email: '', isActive: false };
      return;
    }
    
    // 深度克隆避免父组件意外修改
    this._user = { ...value };
  }
  
  get user(): User | null {
    return this._user;
  }

  ngOnChanges(changes: SimpleChanges): void {
    if (changes['user']) {
      console.log('User changed from', changes['user'].previousValue, 'to', changes['user'].currentValue);
      // 触发自定义逻辑,如预加载头像
      this.loadAvatar();
    }
  }
}

这里的关键点有三:第一,用 setter 替代直接赋值,获得拦截能力;第二,对 null 值做防御性处理,避免模板中 {{ user.name }} 报错;第三, ngOnChanges 是响应 @Input 变更的黄金钩子,但要注意它只在 @Input 绑定的引用变化时触发,如果父组件传入的是同一个对象但修改了其属性, ngOnChanges 不会执行——这正是 OnPush 策略要求你用不可变数据的原因。

注意: @Input 默认是 undefined ,但 undefined 在模板中 *ngIf="user" 会为 false ,而 null 也会为 false 。若需区分“未传值”和“传了 null”,应使用 @Input({ required: true }) (Angular 14+)强制父组件传值,或在 setter 中用 arguments.length === 0 判断。

3.2 @Output 的事件命名规范与生命周期管理

@Output 的最大误区是把它当成 console.log 的替代品。一个健康的 @Output 命名必须遵循 动宾结构 + 过去时态 ,清晰表达“发生了什么”。比如 userSaved deviceRestarted filterApplied ,而不是 save restart change 。后者让人无法判断是“准备保存”还是“保存完成”,前者则明确表示动作已结束。

更重要的是事件对象的设计。永远不要在 EventEmitter 中发送原始 DOM 事件(如 MouseEvent ),因为这会把视图层细节泄露到业务逻辑层。正确做法是封装语义化对象:

// ❌ 错误:泄露 DOM 细节
@Output() click = new EventEmitter<MouseEvent>();

// ✅ 正确:语义化意图
export interface UserCardClickEvent {
  userId: number;
  source: 'avatar' | 'name' | 'action-button';
  timestamp: Date;
}

@Output() userClicked = new EventEmitter<UserCardClickEvent>();

在组件销毁时,必须手动 unsubscribe 所有 @Output 订阅,否则会造成内存泄漏。虽然 Angular 会自动清理 @Output EventEmitter ,但如果父组件在 userClicked.subscribe() 中创建了闭包(如引用了组件实例),这个闭包会阻止垃圾回收。标准做法是在 ngOnDestroy 中清理:

export class UserCardComponent implements OnDestroy {
  @Output() userClicked = new EventEmitter<UserCardClickEvent>();
  private destroy$ = new Subject<void>();

  constructor(private userService: UserService) {}

  onClick() {
    this.userClicked.emit({
      userId: this.user.id,
      source: 'name',
      timestamp: new Date()
    });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

父组件订阅时,应使用 takeUntil(this.destroy$) 操作符:

// 父组件
ngOnInit() {
  this.userCardComponent.userClicked
    .pipe(takeUntil(this.destroy$))
    .subscribe(event => {
      this.handleUserClick(event);
    });
}

3.3 Service 的依赖注入与作用域控制

Service 的威力与其危险性成正比。Angular 的依赖注入(DI)系统默认创建 Singleton(单例) 实例,这意味着整个应用只有一个 UserService 实例。这通常是期望行为,但有时你需要 局部单例 ——比如一个对话框组件内的 DialogFormService ,只对该对话框实例有效,关闭后即销毁。

实现方式是利用 @Injectable providedIn 选项:

// 全局单例(推荐用于数据服务)
@Injectable({
  providedIn: 'root' // 注入到 root injector,全应用共享
})
export class UserService { }

// 模块级单例(注入到特定 NgModule)
@Injectable({
  providedIn: 'any' // 注入到所有 lazy-loaded module 的 injector
})
export class AnalyticsService { }

// 组件级单例(注入到组件及其子组件的 injector)
@Component({
  selector: 'app-dialog',
  providers: [DialogFormService] // 关键:在此声明,仅本组件树可见
})
export class DialogComponent { }

providers: [DialogFormService] 是关键。它告诉 Angular:“为 DialogComponent 创建一个新的 injector,并在此 injector 中注册 DialogFormService ”。当 DialogComponent 被销毁时,其 injector 连同 DialogFormService 实例一并被垃圾回收。这完美解决了“对话框关闭后,其表单服务还在后台监听数据变更”的资源浪费问题。

另一个重要细节是 Service 的构造函数不应包含副作用 。比如:

// ❌ 危险:构造函数中发起 HTTP 请求
@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {
    this.http.get('/api/users').subscribe(); // 错误!组件未初始化时就执行
  }
}

// ✅ 正确:延迟到首次调用时
@Injectable({ providedIn: 'root' })
export class UserService {
  private users$: Observable<User[]> | null = null;

  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    if (!this.users$) {
      this.users$ = this.http.get<User[]>('/api/users').pipe(
        shareReplay({ bufferSize: 1, refCount: true })
      );
    }
    return this.users$;
  }
}

shareReplay 确保 HTTP 请求只执行一次,后续订阅直接获取缓存结果,既高效又安全。

4. 实操过程与核心环节实现:一个完整的跨组件通信案例

4.1 场景设定:电商商品详情页的协同更新

我们构建一个典型场景:商品详情页( ProductDetailPageComponent )包含三个子组件:

  • ProductImageGalleryComponent :图片轮播(可缩放、切换)
  • ProductPriceInfoComponent :价格、库存、促销信息
  • ProductAddToCartButtonComponent :加入购物车按钮(需根据库存状态启用/禁用)

核心需求:当用户在图片轮播中切换到不同 SKU(如颜色、尺寸)时,价格、库存、按钮状态必须实时同步更新。SKU 切换由 ProductImageGalleryComponent 触发,但数据源来自 ProductService ,状态需被所有三个组件消费。

4.2 步骤一:定义数据模型与服务契约

首先,定义清晰的 SKU 数据结构和状态服务:

// models/product.model.ts
export interface ProductSku {
  id: string;
  name: string; // 如 "红色 - XL"
  price: number;
  stock: number;
  isOnSale: boolean;
  salePrice?: number;
}

export interface Product {
  id: string;
  name: string;
  description: string;
  skus: ProductSku[];
}

// services/product-state.service.ts
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Product, ProductSku } from '../models/product.model';

@Injectable({
  providedIn: 'root'
})
export class ProductStateService {
  private productService = inject(ProductService);
  private selectedSkuSubject = new BehaviorSubject<ProductSku | null>(null);
  public selectedSku$: Observable<ProductSku | null> = this.selectedSkuSubject.asObservable();

  // 初始化时选择第一个 SKU
  init(productId: string): void {
    this.productService.getProduct(productId).subscribe(product => {
      const firstSku = product.skus[0];
      this.selectSku(firstSku);
    });
  }

  selectSku(sku: ProductSku): void {
    this.selectedSkuSubject.next(sku);
  }

  // 获取当前选中 SKU 的库存状态(供按钮组件使用)
  isStockAvailable(): boolean {
    const sku = this.selectedSkuSubject.value;
    return sku ? sku.stock > 0 : false;
  }
}

注意 init() 方法:它不返回 Promise 或 Observable,而是直接订阅并调用 selectSku() 。这是因为状态服务的职责是“管理状态”,而非“获取数据”。数据获取交给 ProductService ,状态更新交给 ProductStateService ,职责分离清晰。

4.3 步骤二:实现图片轮播组件(触发方)

ProductImageGalleryComponent 负责展示图片并响应用户切换:

// components/product-image-gallery/product-image-gallery.component.ts
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { Product, ProductSku } from '../../models/product.model';
import { ProductStateService } from '../../services/product-state.service';

@Component({
  selector: 'app-product-image-gallery',
  template: `
    <div class="gallery">
      <img 
        *ngFor="let sku of product.skus; let i = index" 
        [src]="sku.imageUrl" 
        [class.active]="i === currentIndex"
        (click)="onSkuSelect(i)"
      >
      <button (click)="prev()" [disabled]="currentIndex === 0">Prev</button>
      <button (click)="next()" [disabled]="currentIndex === product.skus.length - 1">Next</button>
    </div>
  `,
  styles: [`
    .gallery img { width: 100px; height: 100px; cursor: pointer; }
    .gallery img.active { border: 2px solid blue; }
  `]
})
export class ProductImageGalleryComponent implements OnInit {
  @Input() product!: Product;
  @Output() skuChanged = new EventEmitter<ProductSku>();

  currentIndex = 0;
  private stateService = inject(ProductStateService);

  ngOnInit(): void {
    // 初始化时同步到状态服务
    if (this.product.skus.length > 0) {
      this.stateService.selectSku(this.product.skus[0]);
    }
  }

  onSkuSelect(index: number): void {
    this.currentIndex = index;
    const selectedSku = this.product.skus[index];
    this.skuChanged.emit(selectedSku); // 同时发给父组件(可选)
    this.stateService.selectSku(selectedSku); // 核心:更新全局状态
  }

  prev(): void {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      this.onSkuSelect(this.currentIndex);
    }
  }

  next(): void {
    if (this.currentIndex < this.product.skus.length - 1) {
      this.currentIndex++;
      this.onSkuSelect(this.currentIndex);
    }
  }
}

关键点: onSkuSelect() 同时做了两件事—— emit() 通知父组件(保留传统 @Output 路径),以及 stateService.selectSku() 更新共享状态。这样既兼容旧有逻辑,又为新组件提供统一数据源。

4.4 步骤三:实现价格信息组件(消费者)

ProductPriceInfoComponent 完全依赖 ProductStateService selectedSku$

// components/product-price-info/product-price-info.component.ts
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { AsyncPipe, NgIf, NgClass } from '@angular/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ProductSku } from '../../models/product.model';
import { ProductStateService } from '../../services/product-state.service';

@Component({
  selector: 'app-product-price-info',
  template: `
    <div *ngIf="selectedSku$ | async as sku" class="price-info">
      <h2>{{ sku.name }}</h2>
      <div class="price">
        <span class="original" *ngIf="sku.isOnSale">{{ sku.price | currency }}</span>
        <span class="sale">{{ sku.salePrice | currency }}</span>
      </div>
      <div class="stock" [ngClass]="{ 'low-stock': sku.stock <= 5 }">
        库存: {{ sku.stock }} 件
      </div>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [AsyncPipe, NgIf, NgClass]
})
export class ProductPriceInfoComponent implements OnInit {
  selectedSku$: Observable<ProductSku | null>;
  private stateService = inject(ProductStateService);

  constructor() {
    this.selectedSku$ = this.stateService.selectedSku$.pipe(
      // 过滤掉 null,确保模板中 always 有值
      map(sku => sku || this.getDefaultSku())
    );
  }

  private getDefaultSku(): ProductSku {
    return {
      id: 'default',
      name: '请选择规格',
      price: 0,
      stock: 0,
      isOnSale: false
    };
  }
}

这里 ChangeDetectionStrategy.OnPush AsyncPipe 是黄金组合。 OnPush 告诉 Angular:“这个组件只在 @Input 变更或 EventEmitter 触发时检查变化”,而 AsyncPipe 订阅 selectedSku$ 并在新值到来时自动触发变更检测。两者结合,实现了极致的性能优化——SKU 切换时,只有 ProductPriceInfoComponent 会重新渲染,其他无关组件完全不受影响。

4.5 步骤四:实现购物车按钮组件(消费者+触发方)

ProductAddToCartButtonComponent 不仅消费状态,还要在点击时触发添加逻辑:

// components/product-add-to-cart-button/product-add-to-cart-button.component.ts
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { AsyncPipe, NgIf, NgClass } from '@angular/common';
import { Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { ProductSku } from '../../models/product.model';
import { ProductStateService } from '../../services/product-state.service';
import { CartService } from '../../services/cart.service';

@Component({
  selector: 'app-product-add-to-cart-button',
  template: `
    <button 
      (click)="addToCart()"
      [disabled]="!(isStockAvailable$ | async)"
      [ngClass]="{ 'btn-disabled': !(isStockAvailable$ | async) }"
    >
      {{ (isStockAvailable$ | async) ? '加入购物车' : '缺货' }}
    </button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [AsyncPipe, NgIf, NgClass]
})
export class ProductAddToCartButtonComponent implements OnInit {
  isStockAvailable$: Observable<boolean>;
  private stateService = inject(ProductStateService);
  private cartService = inject(CartService);

  constructor() {
    // 直接订阅状态服务的库存判断方法
    this.isStockAvailable$ = this.stateService.selectedSku$.pipe(
      map(sku => sku ? sku.stock > 0 : false)
    );
  }

  addToCart(): void {
    // 从状态服务获取当前选中 SKU
    const currentSku = this.stateService.selectedSkuSubject.value;
    if (currentSku && currentSku.stock > 0) {
      this.cartService.addItem(currentSku).subscribe({
        next: () => console.log('Added to cart successfully'),
        error: (err) => console.error('Add to cart failed', err)
      });
    }
  }
}

注意 addToCart() 方法:它没有从 @Input 接收 SKU,而是直接从 ProductStateService 获取 selectedSkuSubject.value 。这是因为按钮的职责是“执行动作”,而动作的目标(当前 SKU)是全局状态的一部分,无需通过 @Input 传递。这减少了组件间的耦合,让按钮组件可以被复用到任何需要“添加当前选中项”的场景。

5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训

5.1 “数据更新了,UI 却没变”——变更检测失效的 5 种根因

这是 Angular 开发者最常遇到的“幽灵 bug”。表面看是 @Input 没生效,实则是变更检测机制被意外绕过。以下是我在 12 个项目中总结的 5 种高频原因及排查路径:

现象 根本原因 排查命令 解决方案
@Input 对象属性变了,但 ngOnChanges 不触发 父组件修改了对象属性,但未改变 @Input 绑定的 引用 console.log('Input ref:', this.inputObj === oldRef) 使用 Object.assign({}, obj) {...obj} 创建新引用;或在子组件中用 ngDoCheck 手动深比较
BehaviorSubject 发了 next() ,但 async 管道不更新 BehaviorSubject 的初始值为 null ,且订阅发生在 next() 之前 console.log('Current value:', subject.getValue()) 初始化时传入默认值 new BehaviorSubject<ProductSku | null>(null) ;或用 startWith() 操作符
OnPush 组件内 *ngIf 条件为 true ,但内容不显示 *ngIf 绑定的 Observable 未正确 subscribe ,或 async 管道被 OnPush 阻断 ngAfterViewInit() { console.log('View init'); } 确保 async 管道在 OnPush 组件中使用;避免在 OnPush 组件中手动 subscribe 后忘记 markForCheck()
子组件 @Output 事件父组件没收到 父组件模板中 @Output 绑定语法错误(如 (click) 写成 (onClick) ngOnChanges(changes) { console.log('Changes:', changes); } 检查事件名拼写;确认父组件 @ViewChild 获取的是组件实例而非元素;用 @HostListener 在父组件监听原生事件验证 DOM 是否正常
Service Subject 发送数据,多个组件订阅但只有一处收到 Subject 被多次 new ,导致每个组件订阅了不同的实例 console.log('Service instance:', this.service === otherInstance) 确保 Service providedIn: 'root' ;检查是否在组件 providers 中重复提供了同一 Service

实操心得:当遇到 UI 不更新,第一反应不是加 ChangeDetectorRef.detectChanges() ,而是打开 Chrome DevTools 的 Angular DevTools 扩展,点击组件,查看其 Input 属性值和 Change Detection 状态。90% 的问题能在这里一眼定位。

5.2 “内存泄漏”——EventEmitter 和 Subscription 的隐形杀手

@Output EventEmitter 本身不会导致内存泄漏,但开发者在父组件中 subscribe() 后忘记 unsubscribe() ,就会让组件实例无法被 GC。更隐蔽的是 @Input 绑定的 Observable

// ❌ 危险:在 ngOnInit 中 subscribe,但未 unsubscribe
ngOnInit() {
  this.dataService.getData().subscribe(data => {
    this.items = data;
  });
}

// ✅ 正确:使用 takeUntil 或 async pipe
ngOnInit() {
  this.data$ = this.dataService.getData(); // 模板中用 async pipe
}

// 或
ngOnInit() {
  this.dataService.getData()
    .pipe(takeUntil(this.destroy$))
    .subscribe(data => this.items = data);
}

另一个高危场景是 @Input 绑定一个 Subject

// ❌ 危险:父组件传入 Subject,子组件直接订阅
@Input() dataSubject!: Subject<string>;

ngOnInit() {
  this.dataSubject.subscribe(val => this.process(val)); // 泄漏!
}

// ✅ 正确:父组件传入 Observable,子组件用 async pipe
@Input() data$: Observable<string> | undefined;

提示:Angular 16+ 推荐使用 signal 替代 Observable 进行简单状态管理。 signal 是纯同步、零订阅的,从根本上规避了内存泄漏风险。例如 selectedSku = signal<ProductSku \| null>(null) ,更新用 selectedSku.set(newSku) ,模板中用 selectedSku() ,无需 async 管道。

5.3 “循环依赖”——Service 之间相互注入的死亡螺旋

AService 构造函数注入 BService ,而 BService 构造函数又注入 AService 时,Angular DI 系统会抛出 Cannot instantiate cyclic dependency! 错误。这不是代码 bug,而是架构信号——你的服务职责划分出了问题。

解决方案有三:

  1. 重构职责 :将 A 和 B 的公共逻辑提取到第三个 SharedLogicService ,A 和 B 都注入它;
  2. 延迟注入 :在需要时用 inject() 函数动态获取,而非在构造函数中:
    export class AService {
      constructor(private injector: Injector) {}
      
      doSomething() {
        const bService = this.injector.get(BService); // 延迟获取
        bService.doWork();
      }
    }
    
  3. 使用 InjectionToken :定义一个 token,在运行时提供具体实现,打破编译时依赖:
    export const B_SERVICE_TOKEN = new InjectionToken<BService>('BService');
    
    @Injectable({
      providedIn: 'root',
      useFactory: (bService: BService) => bService,
      deps: [BService]
    })
    export class AService { }
    

我建议优先采用方案1。循环依赖几乎总是意味着两个服务在做同一件事,或者边界模糊。花 20 分钟画一张服务交互图,往往能发现更优雅的解耦方式。

5.4 “跨模块通信失败”——Lazy Loading 下的 Provider 隔离

当应用使用 loadChildren 进行懒加载时,每个懒加载模块都有自己的 injector。如果 ProductStateService ProductModule providedIn: 'root' ,它在 AppModule ProductModule 中是同一个实例;但如果错误地在 ProductModule providers: [] 中声明,那么 ProductModule 会创建自己的 ProductStateService 实例,与 AppModule 的实例完全隔离。

排查方法:在 ProductStateService 构造函数中打印日志:

constructor() {
  console.log('ProductStateService created at', new Date().toISOString());
}

如果在导航到产品页时看到两条日志,说明存在两个实例。

解决方案: 永远优先使用 providedIn: 'root' 。只有当你明确需要模块级单例(如每个模块有自己的配置服务)时,才在模块的 providers 中声明。对于状态服务, root 是唯一安全的选择。

6. 工具选型与未来演进:Signal、Signals、NgRx 的取舍之道

6.1 Signal:Angular 16+ 的轻量级状态管理革命

Angular 16 引入的 signal 是对 @Input/@Output Service 通信模式的一次重大升级。它用极简语法实现了响应式编程的核心优势:自动追踪依赖、精准变更检测、零订阅管理。

对比传统 BehaviorSubject

// 传统方式(需要 import rxjs, Subject, Observable)
private countSubject = new BehaviorSubject<number>(0);
public count$ = this.countSubject.asObservable();

increment() {
  this.countSubject.next(this.countSubject.value + 1);
}

// Signal 方式(内置,无需 rxjs)
count = signal(0);

increment() {
  this.count.update(v => v + 1);
  // 或 this.count.set(this.count() + 1);
}

在模板中使用:

<!-- BehaviorSubject 需要 async pipe -->
<div>Count: {{ count$ | async }}</div>

<!-- Signal 直接调用 -->
<div>Count: {{ count() }}</div>

signal 的优势在于 编译时确定性 。Angular 编译器能静态分析 count() 的调用位置,从而在 count 变更时,只标记那些真正依赖它的模板区域进行检查,性能远超 OnPush + async 的组合。对于大多数组件内部状态(如表单输入、折叠状态、临时筛选条件), signal 是首选。

注意: signal 不是 Observable 的替代品。它适用于 同步、本地、小范围 的状态管理。HTTP 请求、WebSocket 流、跨组件广播等场景,仍需 Observable Service

6.2 Signals:Angular 17 的细粒度响应式

Angular 17 的 signals (复数)是 signal 的进化版,引入了 computed effect ,让响应式编程更接近 SolidJS 的体验:

count = signal(0);
doubleCount = computed(() => this.count() * 2); // 自动响应 count 变更

// effect 在 count 或 doubleCount 变更时执行
effect(() => {
  console.log('Count is now', this.count(), 'and double is', this.doubleCount());
});

computed 是纯函数,自动缓存结果; effect 是副作用函数,适合日志、API 调用等。它们共同构成了一个完整的、无订阅管理的响应式系统。对于新项目,我强烈建议从 signals 开始构建状态逻辑,它比 NgRx

更多推荐