mirror of https://github.com/abpframework/abp.git
26 changed files with 584 additions and 204 deletions
@ -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