+2

Observables in Angular 2

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 new Observable<string> that only emits a new value when there are no coming new values for 500ms.

  • distinctUntilChanged(): returns an Observable<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 of keyword.

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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí