mirror of https://github.com/abpframework/abp
commit
3d2393b182
@ -0,0 +1,203 @@
|
||||
# Easy Unsubscription for Your Observables
|
||||
|
||||
`SubscriptionService` is a utility service to provide an easy unsubscription from RxJS observables in Angular components and directives. Please see [why you should unsubscribe from observables on instance destruction](https://angular.io/guide/lifecycle-hooks#cleaning-up-on-instance-destruction).
|
||||
|
||||
## Getting Started
|
||||
|
||||
You have to provide the `SubscriptionService` at component or directive level, because it is **not provided in root** and it works in sync with component/directive lifecycle. Only after then you can inject and start using it.
|
||||
|
||||
```js
|
||||
import { SubscriptionService } from '@abp/ng.core';
|
||||
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent {
|
||||
count$ = interval(1000);
|
||||
|
||||
constructor(private subscription: SubscriptionService) {
|
||||
this.subscription.addOne(this.count$, console.log);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The values emitted by the `count$` will be logged until the component is destroyed. You will not have to unsubscribe manually.
|
||||
|
||||
> Please do not try to use a singleton `SubscriptionService`. It simply will not work.
|
||||
|
||||
## Usage
|
||||
|
||||
### How to Subscribe to Observables
|
||||
|
||||
You can pass a `next` function and an `error` function.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
const source$ = interval(1000);
|
||||
const nextFn = value => console.log(value * 2);
|
||||
const errorFn = error => {
|
||||
console.error(error);
|
||||
return of(null);
|
||||
};
|
||||
|
||||
this.subscription.addOne(source$, nextFn, errorFn);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or, you can pass an observer.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
const source$ = interval(1000);
|
||||
const observer = {
|
||||
next: value => console.log(value * 2),
|
||||
complete: () => console.log('DONE'),
|
||||
};
|
||||
|
||||
this.subscription.addOne(source$, observer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `addOne` method returns the individual subscription, so that you may use it later on. Please see topics below for details.
|
||||
|
||||
### How to Unsubscribe Before Instance Destruction
|
||||
|
||||
There are two ways to do that. If you are not going to subscribe again, you may use the `closeAll` method.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.subscription.addOne(interval(1000), console.log);
|
||||
}
|
||||
|
||||
onSomeEvent() {
|
||||
this.subscription.closeAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will clear all subscriptions, but you will not be able to subscribe again. If you are planning to add another subscription, you may use the `reset` method instead.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.subscription.addOne(interval(1000), console.log);
|
||||
}
|
||||
|
||||
onSomeEvent() {
|
||||
this.subscription.reset();
|
||||
this.subscription.addOne(interval(1000), console.warn);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How to Unsubscribe From a Single Subscription
|
||||
|
||||
Sometimes, you may need to unsubscribe from a particular subscription but leave others alive. In such a case, you may use the `closeOne` method.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
countSubscription: Subscription;
|
||||
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.countSubscription = this.subscription.addOne(
|
||||
interval(1000),
|
||||
console.log
|
||||
);
|
||||
}
|
||||
|
||||
onSomeEvent() {
|
||||
this.subscription.closeOne(this.countSubscription);
|
||||
console.log(this.countSubscription.closed); // true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How to Remove a Single Subscription From Tracked Subscriptions
|
||||
|
||||
You may want to take control of a particular subscription. In such a case, you may use the `removeOne` method to remove it from tracked subscriptions.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
countSubscription: Subscription;
|
||||
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.countSubscription = this.subscription.addOne(
|
||||
interval(1000),
|
||||
console.log
|
||||
);
|
||||
}
|
||||
|
||||
onSomeEvent() {
|
||||
this.subscription.removeOne(this.countSubscription);
|
||||
console.log(this.countSubscription.closed); // false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How to Check If Unsubscribed From All
|
||||
|
||||
Please use `isClosed` getter to check if `closeAll` was called before.
|
||||
|
||||
```js
|
||||
@Component({
|
||||
/* class metadata here */
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
class DemoComponent implements OnInit {
|
||||
constructor(private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.subscription.addOne(interval(1000), console.log);
|
||||
}
|
||||
|
||||
onSomeEvent() {
|
||||
console.log(this.subscription.isClosed); // false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What's Next?
|
||||
|
||||
- [ListService](./List-Service.md)
|
||||
@ -1,39 +1,45 @@
|
||||
import { Component, OnDestroy, OnInit, Type } from '@angular/core';
|
||||
import { Component, OnInit, Type } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngxs/store';
|
||||
import { distinctUntilChanged } from 'rxjs/operators';
|
||||
import { ABP } from '../models/common';
|
||||
import { ReplaceableComponents } from '../models/replaceable-components';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
import { ReplaceableComponentsState } from '../states/replaceable-components.state';
|
||||
import { takeUntilDestroy } from '../utils/rxjs-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'abp-replaceable-route-container',
|
||||
template: `
|
||||
<ng-container *ngComponentOutlet="externalComponent || defaultComponent"></ng-container>
|
||||
`,
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
export class ReplaceableRouteContainerComponent implements OnInit, OnDestroy {
|
||||
export class ReplaceableRouteContainerComponent implements OnInit {
|
||||
defaultComponent: Type<any>;
|
||||
|
||||
componentKey: string;
|
||||
|
||||
externalComponent: Type<any>;
|
||||
|
||||
constructor(private route: ActivatedRoute, private store: Store) {}
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private store: Store,
|
||||
private subscription: SubscriptionService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.defaultComponent = this.route.snapshot.data.replaceableComponent.defaultComponent;
|
||||
this.componentKey = (this.route.snapshot.data
|
||||
.replaceableComponent as ReplaceableComponents.RouteData).key;
|
||||
|
||||
this.store
|
||||
const component$ = this.store
|
||||
.select(ReplaceableComponentsState.getComponent(this.componentKey))
|
||||
.pipe(takeUntilDestroy(this), distinctUntilChanged())
|
||||
.subscribe((res = {} as ReplaceableComponents.ReplaceableComponent) => {
|
||||
.pipe(distinctUntilChanged());
|
||||
|
||||
this.subscription.addOne(
|
||||
component$,
|
||||
(res = {} as ReplaceableComponents.ReplaceableComponent) => {
|
||||
this.externalComponent = res.component;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
}
|
||||
|
||||
@ -1,34 +1,25 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroy } from '../utils/rxjs-utils';
|
||||
import { Directive, ElementRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { fromEvent } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
@Directive({
|
||||
// tslint:disable-next-line: directive-selector
|
||||
selector: '[input.debounce]',
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
export class InputEventDebounceDirective implements OnInit, OnDestroy {
|
||||
export class InputEventDebounceDirective implements OnInit {
|
||||
@Input() debounce = 300;
|
||||
|
||||
@Output('input.debounce') readonly debounceEvent = new EventEmitter<Event>();
|
||||
|
||||
constructor(private el: ElementRef) {}
|
||||
constructor(private el: ElementRef, private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
fromEvent(this.el.nativeElement, 'input')
|
||||
.pipe(debounceTime(this.debounce), takeUntilDestroy(this))
|
||||
.subscribe((event: Event) => {
|
||||
this.debounceEvent.emit(event);
|
||||
});
|
||||
}
|
||||
const input$ = fromEvent(this.el.nativeElement, 'input').pipe(debounceTime(this.debounce));
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
this.subscription.addOne(input$, (event: Event) => {
|
||||
this.debounceEvent.emit(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,21 @@
|
||||
import { Directive, ElementRef, EventEmitter, OnInit, Output, OnDestroy } from '@angular/core';
|
||||
import { Directive, ElementRef, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { fromEvent } from 'rxjs';
|
||||
import { takeUntilDestroy } from '../utils/rxjs-utils';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
@Directive({
|
||||
// tslint:disable-next-line: directive-selector
|
||||
selector: '[click.stop]',
|
||||
providers: [SubscriptionService],
|
||||
})
|
||||
export class StopPropagationDirective implements OnInit, OnDestroy {
|
||||
export class StopPropagationDirective implements OnInit {
|
||||
@Output('click.stop') readonly stopPropEvent = new EventEmitter<MouseEvent>();
|
||||
|
||||
constructor(private el: ElementRef) {}
|
||||
constructor(private el: ElementRef, private subscription: SubscriptionService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
fromEvent(this.el.nativeElement, 'click')
|
||||
.pipe(takeUntilDestroy(this))
|
||||
.subscribe((event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
this.stopPropEvent.emit(event);
|
||||
});
|
||||
this.subscription.addOne(fromEvent(this.el.nativeElement, 'click'), (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
this.stopPropEvent.emit(event);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { OnDestroy } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import type { Observable, PartialObserver } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService implements OnDestroy {
|
||||
private subscription = new Subscription();
|
||||
|
||||
get isClosed() {
|
||||
return this.subscription.closed;
|
||||
}
|
||||
|
||||
addOne<T extends unknown>(
|
||||
source$: Observable<T>,
|
||||
next?: (value: T) => void,
|
||||
error?: (error: any) => void,
|
||||
): Subscription;
|
||||
addOne<T extends unknown>(source$: Observable<T>, observer?: PartialObserver<T>): Subscription;
|
||||
addOne<T extends unknown>(
|
||||
source$: Observable<T>,
|
||||
nextOrObserver?: PartialObserver<T> | Next<T>,
|
||||
error?: (error: any) => void,
|
||||
): Subscription {
|
||||
const subscription = source$.subscribe(nextOrObserver as Next<T>, error);
|
||||
this.subscription.add(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
closeOne(subscription: Subscription | undefined | null) {
|
||||
this.removeOne(subscription);
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
removeOne(subscription: Subscription | undefined | null) {
|
||||
if (!subscription) return;
|
||||
this.subscription.remove(subscription);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.subscription.unsubscribe();
|
||||
this.subscription = new Subscription();
|
||||
}
|
||||
}
|
||||
|
||||
type Next<T> = (value: T) => void;
|
||||
@ -0,0 +1,109 @@
|
||||
import { of, Subscription, timer } from 'rxjs';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
describe('SubscriptionService', () => {
|
||||
let service: SubscriptionService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SubscriptionService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service['subscription'].unsubscribe();
|
||||
});
|
||||
|
||||
describe('#addOne', () => {
|
||||
it('should subscribe to given observable with next and error functions and return the Subscription instance', () => {
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
const subscription = service.addOne(of(null), next, error);
|
||||
expect(subscription).toBeInstanceOf(Subscription);
|
||||
expect(next).toHaveBeenCalledWith(null);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should subscribe to given observable with observer and return the Subscription instance', () => {
|
||||
const observer = { next: jest.fn(), complete: jest.fn() };
|
||||
const subscription = service.addOne(of(null), observer);
|
||||
expect(subscription).toBeInstanceOf(Subscription);
|
||||
expect(observer.next).toHaveBeenCalledWith(null);
|
||||
expect(observer.next).toHaveBeenCalledTimes(1);
|
||||
expect(observer.complete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isClosed', () => {
|
||||
it('should return true if subscriptions are alive and false if not', () => {
|
||||
service.addOne(timer(1000), () => {});
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service['subscription'].unsubscribe();
|
||||
expect(service.isClosed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#closeAll', () => {
|
||||
it('should close all subscriptions and the parent subscription', () => {
|
||||
const sub1 = service.addOne(timer(1000), () => {});
|
||||
const sub2 = service.addOne(timer(1000), () => {});
|
||||
|
||||
expect(sub1.closed).toBe(false);
|
||||
expect(sub2.closed).toBe(false);
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service.closeAll();
|
||||
|
||||
expect(sub1.closed).toBe(true);
|
||||
expect(sub2.closed).toBe(true);
|
||||
expect(service.isClosed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reset', () => {
|
||||
it('should close all subscriptions but not the parent subscription', () => {
|
||||
const sub1 = service.addOne(timer(1000), () => {});
|
||||
const sub2 = service.addOne(timer(1000), () => {});
|
||||
|
||||
expect(sub1.closed).toBe(false);
|
||||
expect(sub2.closed).toBe(false);
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service.reset();
|
||||
|
||||
expect(sub1.closed).toBe(true);
|
||||
expect(sub2.closed).toBe(true);
|
||||
expect(service.isClosed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#closeOne', () => {
|
||||
it('should unsubscribe from given subscription only', () => {
|
||||
const sub1 = service.addOne(timer(1000), () => {});
|
||||
const sub2 = service.addOne(timer(1000), () => {});
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service.closeOne(sub1);
|
||||
expect(sub1.closed).toBe(true);
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service.closeOne(sub2);
|
||||
expect(sub2.closed).toBe(true);
|
||||
expect(service.isClosed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removeOne', () => {
|
||||
it('should remove given subscription from list of subscriptions', () => {
|
||||
const sub1 = service.addOne(timer(1000), () => {});
|
||||
const sub2 = service.addOne(timer(1000), () => {});
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
service.removeOne(sub1);
|
||||
expect(sub1.closed).toBe(false);
|
||||
expect(service.isClosed).toBe(false);
|
||||
|
||||
sub1.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue