头图

We illustrate with a concrete example.

Consider that you are building a search input mask , which should display results as soon as you type.

If you have ever built something like this, then you may be aware of the challenges posed by this task.

  1. Don't hit the search endpoint every time you keystroke

Think of the search endpoint as if you pay by request. Whether it is your own hardware or not. We should not tap the search endpoint more frequently than necessary. Basically we just want to click it after the user stops typing, not every time the key is pressed.

  1. Do not use the same query parameters in subsequent requests to hit the search endpoint

Suppose you type foo, stop, type another o, then immediately backspace and return to foo. This should be just one request with the word foo, not two, even though we technically stopped twice after having foo in the search box.

3. Deal with out-of-order responses

When we have multiple requests in progress at the same time, we must consider the situation where they are returned in an unexpected order. Consider that we first type computer, stop, and request to be sent, and we type car, stop, and request to be sent. Now we have two ongoing requests. Unfortunately, after the request to carry the result for car, the request to carry the result for computer came back. This may be because they are served by different servers. If we do not handle such situations correctly, we may end up showing the results of computer and the search box will show car.

We will use the free and open Wikipedia API to write a small demo.

For simplicity, our demo will only contain two files: app.ts and wikipedia-service.ts. However, in the real world, we are likely to split things further.

Let's start with a Promise-based implementation that does not deal with any described edge cases.

This is what our WikipediaService looks like.

The Angular HTTP service jsonp is used:

The above figure converts the object returned by jsonp from the angular/http library into a promise using the toPromise method.

Simply put, we are injecting the Jsonp service to make a GET request against the Wikipedia API using a given search term. Please note that we call toPromise to go from Observable\<Response\> to Promise\<Response\>. Through then-chaining we finally get a Promise\<Array\<string\>\> as the return type of our search method.

So far so good, let's take a look at the app.ts file that saves our App component.

Take a look at how the wiki service is consumed:

There are no surprises here. We inject our WikipediaService and expose its functionality to the template through the search method. The template simply binds to keyup and calls search(term.value) to take advantage of Angular's great template reference function.

We unpack the Promise result returned by the search method of WikipediaService and expose it to the template as a simple string array, so that we can make *ngFor loop through it and build a list for us.

Unfortunately, this implementation did not address any of the described edge cases that we wanted to deal with. Let's refactor our code to make it behave as expected.

Let's change our code so that instead of hitting the endpoint with every keystroke, we only send the request when the user stops typing for 400 milliseconds. This is where Observables really shine. Reactive Extensions (Rx) provides a wide range of operators that allow us to change the behavior of Observables and create new Observables with the desired semantics.

In order to reveal such superpowers, we first need to obtain an Observable\<string\>, which carries the search term entered by the user. We can use Angular's formControl directive instead of manually binding to the keyup event. To use this directive, we first need to import ReactiveFormsModule into our application module.

After importing, we can use formControl in the template and set it to the name "term".

<input type="text" [formControl]="term"/>

In our component, we created an instance of FormControl from @angular/form and exposed it as a field under the name term on the component.

Behind the scenes, the term will automatically expose an Observable\<string\> as an attribute valueChanges that we can subscribe to. Now that we have an Observable\<string\>, tame user input is as simple as calling debounceTime(400) on our Observable. This will return a new Observable\<string\>, which will only emit a new value if no new value appears within 400 milliseconds.

This new object will have a new value after the time interval we expect. As for how to control the time interval, it is a black box for front-end developers.

export class App {
  items: Array<string>;
  term = new FormControl();
  constructor(private wikipediaService: WikipediaService) {
    this.term.valueChanges
             .debounceTime(400)
             .subscribe(term => this.wikipediaService.search(term).then(items => this.items = items));
  }
}

As we said, it would be a waste of resources to make another request for a search term that has already displayed results in our application. Fortunately, Rx simplifies many operations that hardly need to be mentioned. In order to achieve the desired behavior, all we have to do is to call the distinctUntilChanged operator immediately after we call debounceTime(400). Similarly, we will return an Observable\<string\>, but it ignores the same value as the previous one.

Dealing with out-of-order responses

Dealing with out-of-order responses can be a tricky task. Basically, we need a way to indicate that once we make a new request, we are no longer interested in the results of previous requests in progress. In other words: once we start a new request, we cancel all previous requests. As I mentioned briefly at the beginning, Observables are one-offs, which means we can unsubscribe from them.

This is where we want to change WikipediaService to return Observable\<Array\<string\>> instead of Promise\<Array\<string\>>. It's as simple as deleting toPromise and using map instead of then.

search (term: string) {
  var search = new URLSearchParams()
  search.set('action', 'opensearch');
  search.set('search', term);
  search.set('format', 'json');
  return this.jsonp
              .get('http://en.wikipedia.org/w/api.php?callback=JSONP_CALLBACK', { search })
              .map((response) => response.json()[1]);
}

Now our WikipediaSerice returns an Observable instead of a Promise, we just need to replace then with subscribe in our App component.

this.term.valueChanges
           .debounceTime(400)
           .distinctUntilChanged()
           .subscribe(term => this.wikipediaService.search(term).subscribe(items => this.items = items));

But now we have two subscribe calls. This is unnecessary verbosity and is usually a sign of the need for code refactoring. The good news is that now that the search returns an Observable<Array<string>>, we can simply use flatMap to project the Observable<string> into the desired Observable<Array<string>> by combining Observables.

this.term.valueChanges
         .debounceTime(400)
         .distinctUntilChanged()
         .flatMap(term => this.wikipediaService.search(term))
         .subscribe(items => this.items = items);

You may be wondering what flatMap does and why we can't use map here.

The answer is simple. The map operator requires a function that accepts a value T and returns a value U. For example, a function that accepts a string and returns a number. Therefore, when you use map, you will get an Observable\<U\> from Observable\<T\>. However, our search method itself generates an Observable<Array>. So, from our Observable<string> after distinctUntilChanged, map will take us to Observable<Observable<Array<string>>. This is not what we want.

On the other hand, the flatMap operator requires a function that accepts a T and returns an Observable\<U\> and generates an Observable\<U\> for us.

Note: This is not entirely correct, but it helps simplify.

This is fully in line with our situation. We have an Observable\<string\>, and then use a function to call flatMap, which accepts a string and returns an Observable\<Array\<string\>>.

Now that we have mastered the semantics, there is a little trick to save some typing time. We can let Angular unpack for us directly in the template instead of manually subscribing to Observable. All we have to do is to use AsyncPipe in our template and expose Observable\<Array\<string\>> instead of Array\<string\>.

@Component({
  selector: 'my-app',
  template: `
    <div>
      <h2>Wikipedia Search</h2>
      <input type="text" [formControl]="term"/>
      <ul>
        <li *ngFor="let item of items | async">{{item}}</li>
      </ul>
    </div>
  `
})
export class App {

  items: Observable<Array<string>>;
  term = new FormControl();

  constructor(private wikipediaService: WikipediaService) {
    this.items = this.term.valueChanges
                 .debounceTime(400)
                 .distinctUntilChanged()
                 .switchMap(term => this.wikipediaService.search(term));
  }
}

More original articles by Jerry, all in: "Wang Zixi":


注销
1k 声望1.6k 粉丝

invalid