如何防止 Angular 中重复调用 subscription?
结论
问题不在于 Subject 保存并累积了经过的卡片,而在于切换卡片时会反复创建 MinerCardComponent。每个实例都会执行一次 subscribe(),旧实例销毁后却没有取消订阅。这样一来,单击 Mine 调用一次 sendMessage() 时,之前留下的订阅都会收到消息。
可以这样处理:
- 只在
Mine的点击处理函数中调用sendMessage()。 - 组件销毁时取消订阅,或者使用
takeUntilDestroyed()自动清理。 - 如果卡片组件只负责发送数据,就不要让每张卡片都订阅同一个消息流。
- 给按钮显式设置
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 操作,就应保留一个订阅,并在组件销毁时统一清理。
备注:内容仅供参考。
