PAYATHON 2026

如何防止 Angular 中重复调用 subscription?

数据小许

结论

问题不在于 Subject 保存并累积了经过的卡片,而在于切换卡片时会反复创建 MinerCardComponent。每个实例都会执行一次 subscribe(),旧实例销毁后却没有取消订阅。这样一来,单击 Mine 调用一次 sendMessage() 时,之前留下的订阅都会收到消息。

可以这样处理:

  1. 只在 Mine 的点击处理函数中调用 sendMessage()。
  2. 组件销毁时取消订阅,或者使用 takeUntilDestroyed() 自动清理。
  3. 如果卡片组件只负责发送数据,就不要让每张卡片都订阅同一个消息流。
  4. 给按钮显式设置 type="button",避免表单默认提交引发额外调用。

为什么看起来像是消息在累积

下面这段代码会在每次创建 MinerCardComponent 时建立一个订阅:

this.subscription = this.messageService.getMessage().subscribe(message => {
  console.log('Constructor miner-card');
});

如果通过 *ngIf、ngSwitch 或动态组件切换卡片,切换过程可能是:

销毁旧卡片 → 创建新卡片 → 再次 subscribe()

如果组件销毁时没有执行 unsubscribe(),旧订阅仍然存在。比如切换十次后,可能已经留下了十个订阅。此时单击一次 Mine,虽然 subject.next() 只执行一次,十个订阅的回调却都会运行,所以看起来像发送了十条消息。

再看这段代码:

getMessage(): Observable<...> {
  console.log('Get Message Message Service');
  return this.subject.asObservable();
}

这里的日志表示有人获取了 Observable,随后可能对它进行订阅,并不代表消息已经发出。真正发送消息的是:

this.subject.next(...);

只在单击 Mine 时发送当前卡片

消息服务可以改成类型明确的实现:

import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';

export interface MinerMessage {
  text: string;
}

@Injectable({ providedIn: 'root' })
export class MessageService {
  private readonly subject = new Subject<MinerMessage>();

  sendMessage(message: string): void {
    this.subject.next({ text: message });
  }

  getMessage(): Observable<MinerMessage> {
    return this.subject.asObservable();
  }
}

卡片组件只维护当前卡片,并在单击 Mine 时发送消息:

export class MinerCardComponent {
  minerCounter = 1;

  constructor(private readonly messageService: MessageService) {}

  precedingBlock(): void {
    this.minerCounter--;

    if (this.minerCounter < 1) {
      this.minerCounter = 10;
    }
  }

  nextBlock(): void {
    this.minerCounter++;

    if (this.minerCounter > 10) {
      this.minerCounter = 1;
    }
  }

  mine(): void {
    const currentCard = this.minerCounter;
    this.messageService.sendMessage(`Card ${currentCard}`);
  }
}

模板中的事件也要分开处理:

<button type="button" (click)="precedingBlock()">
  Previous
</button>

<button type="button" (click)="nextBlock()">
  Next
</button>

<button type="button" (click)="mine()">
  Mine
</button>

切换卡片时只修改 minerCounter,不会发送消息。只有 mine() 会调用 sendMessage()。

实际项目一般应发送当前卡片的数据对象,而不只是卡片编号。例如:

mine(): void {
  const currentCard = this.cards[this.minerCounter - 1];
  this.messageService.sendMessage(currentCard.hash);
}

数组下标是否减一,要看 minerCounter 从 1 还是从 0 开始。

正确清理订阅

需要接收消息的组件可以订阅服务,但要在组件销毁时清理订阅。

兼容多数 Angular 版本的写法

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

export class ReceiverComponent implements OnInit, OnDestroy {
  private readonly destroy$ = new Subject<void>();

  constructor(private readonly messageService: MessageService) {}

  ngOnInit(): void {
    this.messageService
      .getMessage()
      .pipe(takeUntil(this.destroy$))
      .subscribe(message => {
        console.log('Received:', message);
      });
  }

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

也可以直接保存 Subscription:

import { OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';

export class ReceiverComponent implements OnDestroy {
  private readonly subscription: Subscription;

  constructor(private readonly messageService: MessageService) {
    this.subscription = this.messageService
      .getMessage()
      .subscribe(message => {
        console.log('Received:', message);
      });
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

一般更适合在 ngOnInit() 中建立订阅,不要在构造函数中处理组件的业务逻辑。

较新 Angular 版本的写法

如果项目使用的 Angular 版本支持 takeUntilDestroyed(),可以交给框架自动清理:

import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class ReceiverComponent {
  private readonly destroyRef = inject(DestroyRef);

  constructor(private readonly messageService: MessageService) {
    this.messageService
      .getMessage()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(message => {
        console.log('Received:', message);
      });
  }
}

该 API 是否可用,要以项目当前的 Angular 版本为准。

更合适的组件职责划分

如果 MinerCardComponent 只是消息发送方,通常不需要调用 getMessage():

export class MinerCardComponent {
  constructor(private readonly messageService: MessageService) {}

  mine(card: MinerCard): void {
    this.messageService.sendMessage(card.hash);
  }
}

订阅消息的代码应放在负责接收数据的组件中:

export class ResultComponent implements OnInit, OnDestroy {
  private readonly destroy$ = new Subject<void>();

  constructor(private readonly messageService: MessageService) {}

  ngOnInit(): void {
    this.messageService
      .getMessage()
      .pipe(takeUntil(this.destroy$))
      .subscribe(message => {
        // 在这里处理当前 Mine 操作发送的数据
        console.log(message.text);
      });
  }

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

各部分的职责如下:

切换按钮 → 只更新当前卡片
Mine 按钮 → 发送当前卡片
目标组件 → 订阅并处理消息

还需要检查的细节

如果按钮位于 <form> 内且没有指定 type,浏览器会默认把 <button> 当作提交按钮。因此,切换按钮应明确写成:

<button type="button" (click)="precedingBlock()">Previous</button>

如果 Mine 本来就通过表单提交,可以保留提交行为:

<form (ngSubmit)="mine()">
  <button type="button" (click)="precedingBlock()">Previous</button>
  <button type="submit">Mine</button>
</form>

也不要用 take(1) 掩盖订阅泄漏。take(1) 表示订阅收到一条消息后自动结束,只适合仅需接收一次的业务。如果接收组件要处理之后的每一次 Mine 操作,就应保留一个订阅,并在组件销毁时统一清理。

备注:内容仅供参考。