Observables in Angular 2
Bài đăng này đã không được cập nhật trong 3 năm
1. Brief introduction about Observables
Since Angular 2 was introduced, some people seem to feel perplexed why the Observable
abstraction was favored over the Promise
abstraction when it comes to dealing with asynchronous behaviors
To put it simply, Observables are similar to Promises in the way that they help manage asynchronous data and offer some other useful patterns. One of the most crystal-clear differences is that a Promise handles a single event when an asynchronous operation completes or fail. In other word, a Promise once called will always return one value (resolved object) or one error (rejected object). On the other hand, an Observable functions like a Stream
capable of emitting multiple values over time.
Observables also gain the upper-hand over Promises in the battle of disposability. If the result of an HTTP request to a server or some other expensive asynchronous operation is no longer needed, the Subscription
of an Observable
allows cancelling the subscription, which makes them disposable. Meanwhile, a Promise
will eventually call either the success or failed callback no matter whether the results are needed or not.
Observables provides operators like map
, forEach
, reduce
, ... similar to those of Array
.
"The legendary duel battle between Promise and Observable, I guess???"
2. The scenario of Incremental Search
We are going to build a search input that should instantly display possible results as user type.
2.1. Using Promise
2.1.1. Implementations
For simplicity our demo will simply consists: App
Module, App
Component HeroSearch
Component and Hero
Service.
/* ./app.module.ts */
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { AppComponent } from './app.component';
import { HeroSearchComponent } from './hero/hero-search/hero-search.component';
import { HeroService } from './hero/hero.service';
@NgModule({
declarations: [
AppComponent,
HeroSearchComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule
],
providers: [
HeroService
],
bootstrap: [AppComponent]
})
export class AppModule { }
/* ./app.component.ts */
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `<app-hero-search></app-hero-search>`,
})
export class AppComponent { }
/* ./hero/hero.service.ts */
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
@Injectable()
export class HeroService {
constructor(private http: Http) { }
searchHero(keyword: string): Promise<Array<string>> {
return this.http.get(`https://api.myjson.com/bins/ijdkv`)
.toPromise()
.then(response => response
.json()
.filter(hero => hero.toLowercase().indexOf(keyword.toLowerCase()) !== -1));
}
}
The Http
service is injected to make a GET request that fetches a list of data, then filters the ones matched with a given search keyword. Noticeably, toPromise
is called in order to get a <Promise<Response>>
from an Observable<Response>>
and then
-chaining is used to retrieve the final result as Promise<Array<string>>
, which is also the return type defined in our searchHero
method.
/* ./hero/search-hero/search-hero.component.ts */
import { Component } from '@angular/core';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: ['./hero-search.component.scss']
})
export class HeroSearchComponent implements OnInit {
heroes: Array<string>;
constructor(private heroService: HeroService) { }
searchHero(keyword): void {
this.heroService.searchHero(keyword)
.then(heroes => this.heroes = heroes);
}
}
Next, we inject our HeroService
and expose its functionality via searchHero
method to the template. The template simply binds to keyup
and calls searchHero(term.value)
. The result of the Promise
that searchHero
method of HeroService
returns is unwrapped and exposed as a simple Array<string>>
so that we can have the directive *ngFor
loop through it, which displays output results.
<div>
<h2>Searching for hero's name</h2>
<input type="text" #keyword (keyup)="searchHero(keyword)">
<ul>
<li *ngFor="let hero of heroes">{{hero}}</li>
</ul>
</div>
2.2. Challenges
Making this kind application display the correct output is not some kind of Herculean task if you have ever, at least once in your life, tried building it. Nevertheless, just like the title of the opening theme of a once-famous movie franchise named "Wizard of Waverly Place" - EVERYTHING IS NOT LIKE WHAT I SEEMS, I am about to unravel some pains in the neck lying somewhere beneath our code implemented above.
The search endpoint is hammered/abused more often than needed
This is some kind of mistreatment that I have ran into a lot of time during my tiny working experience in IT projects. Imagine the scenario in which you type the keyword "green". Try logging the value of keyword
param in function searchHero
of HeroService
and you shall see 'g', then 'gr', 'gree', ... and so on, which also means 5 requests are sent until we reach the complete keyword. If your server is incapable of handling a large number of incoming requests at the same time, well then, congratulation. You have just contributed to setting up a deadly time bomb for your application
The search endpoint is hit with the same query params for successive request
In case you have found a treatment for the pain above, here comes another one. Consider typing 'green', then stop, and type another 'n', followed by an immediate Backspace and finally, rest back at 'green'. There should be just one request sent with the keyword 'green' and not two, even if you did technically stopped twice after having 'green' in the text field.
Out-of-order responses are not handled
There might happen a case, in which, when multiple requests are sent at the same time, their respective responses travel back in an unexpected order due to the fact that we cannot guarantee which one will come back first. You first type 'green', then stop and a request goes out. Then you switches to the keyword 'crimson' and now,we have two requests in-flight. Unfortunately, the request that carries the results for 'crimson' might win against the one carrying 'green' in a race of time. As a result, you end up showing results for 'green' whereas the search box read 'yellow'.
This is where Observable
really shine. Using Observables, you can single-handedly deal with all problems above in the most effortless way.
3. Using Observable
In order to implement Observable
, we must import from the rxjs
package (Reactive Extentions), which offer a wide range of unique opertors that allow altering the behavior of Observable
.
To unveil such super powers we first need to get to modify our HeroService
. searchHero
method will now return an Observable<Array<string>>
instead of Promise<Array<string>>
. We also drop toPromise
and use map
instead of then
.
/* /hero/hero.service.ts */
import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Rx';
@Injectable()
export class HeroService {
constructor(private http: Http) { }
searchHero(keyword: string): Observable<Array<string>> {
return this.http.get(`https://api.myjson.com/bins/ijdkv`)
.map(response => response
.json()
.filter(hero => hero.toLowerCase().indexOf(keyword.toLowerCase()) !== -1));
}
Before diving into Observable
, we will apply some changes to the search input. Instead of manually binding to the keyup
or keydown
event, let's take advantage of Angular's formControl
directive. To use this directive, we first need to import the ReactiveFormsModule
into our application module. Once imported, we can use formControl
from within our template and set it to the name keyword
.
/* ./app.module.ts */
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
...
],
imports: [
BrowserModule,
HttpModule,
ReactiveFormsModule
],
<div>
<h2>Searching for hero's name</h2>
<input type="text" [formControl]="keyword">
<ul>
<li *ngFor="let hero of heroes">{{hero}}</li>
</ul>
</div>
In our component, keyword
now is an instance of FormControl
from @angular/forms
. Behind the scene, FormControl
provides property valueChanges
which is an Observable
interface so that you can subscribe for the change of the form control. We modify the HeroSearch
component as below:
/* /hero/hero-search.component.ts */
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs/Rx'
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: ['./hero-search.component.scss']
})
export class HeroSearchComponent implements OnInit {
heroes: Array<string>;
keyword = new FormControl();
constructor(private heroService: HeroService) {
this.heroes = this.keyword.valueChanges
.debounceTime(500)
.distinctUntilChanged()
.switchMap(keyword => this.heroService.searchHero(keyword))
.subscribe(heroes => this.heroes = heroes);
}
}
There are some operators of Observable used to deal with problems above:
-
debounceTime()
: returns a newObservable<string>
that only emits a new value when there are no coming new values for 500ms. -
distinctUntilChanged()
: returns anObservable<string>
but one that ignores values that are the same as previous. -
switchMap()
: this operator keeps subscribing latest observable at the moment, which is convenient for properly implementing incremental search.switchMap
operator automatically unsubscribes from previous subscriptions as soon as the outer Observable emits new values. -
subscribe()
: subscribe for the value changes ofkeyword
.
Last step, there are some little tricks you can do to save some typing and free your component class from its misery. Let's changes our searchHero
method name into rawSearchHero
and create another smarter searchHero
API on top of it
/* ./hero/hero-service.ts */
searchHero(keyword: Observable<string>, debounceDuration = 400): Observable<Array<string>> {
return keyword
.debounceTime(debounceDuration)
.distinctUntilChanged()
.switchMap(keyword => this.rawSearchHero(keyword));
}
rawSearchHero(keyword: string): Observable<Array<string>> {
return this.http.get(`https://api.myjson.com/bins/ijdkv`)
.map(response => response
.json()
.filter(hero => hero.toLowerCase().indexOf(keyword.toLowerCase()) !== -1));
}
/* ./hero/hero-search.component.ts */
export class HeroSearchComponent implements OnInit {
heroes: Observable<Array<string>>;
keyword = new FormControl();
constructor(private heroService: HeroService) {
this.heroes = heroService.searchHero(this.keyword.valueChanges);
}
}
We can let Angular do all the hard job instead of manually subscribing to the Observables by implementing the last missing precious masterpiece of our collection: the AsyncPipe
.
<ul>
<li *ngFor="let hero of heroes | async">{{hero}}</li>
</ul>
4. References
[Promise] (https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise) [Observable] (http://reactivex.io/documentation/observable.html)
All rights reserved