I started out wanting to write a step-by-step guide for upgrading an app from Angular 1.5 to Angular 2, before I was politely informed by my editor that she needed an article rather than a novel. After much deliberation, I accepted that I needed to start with a broad survey of the changes in Angular 2, hitting all the points covered in Jason Aden’s Getting Past Hello World in Angular 2 article. …Oops. Go ahead and read it to get an overview of Angular 2’s new features, but for a hands-on approach keep your browser right here.
I want this to become a series that eventually encompasses the entire process of upgrading our demo app to Angular 2. For now, though, let’s start with a single service. Let’s take a meandering walk through the code and I’ll answer any questions you may have, such as….
‘OH NO WHY IS EVERYTHING SO DIFFERENT’
Angular: The Old Way
If you’re like me, the Angular 2 quickstart guide might have been the first time you ever looked at TypeScript. Real quickly, according to its own website, TypeScript is “a typed superset of JavaScript that compiles to plain JavaScript”. You install the transpiler (similar to Babel or Traceur) and you wind up with a magical language that supports ES2015 & ES2016 language features as well as strong typing.
You may find it reassuring to know that none of this arcane setup is strictly necessary. It’s not terribly difficult to write Angular 2 code in plain old JavaScript, although I don’t think it’s worth it to do so. It’s nice to recognize familiar territory, but so much of what’s new and exciting about Angular 2 is its new way of thinking rather than its new architecture.
What’s new and exciting about Angular 2 is its new way of thinking rather than its new architecture.
So let’s look at this service that I upgraded from Angular 1.5 to 2.0.0-beta.17. It’s a fairly standard Angular 1.x service, with just a couple of interesting features that I tried to note in the comments. It’s a bit more complicated than your standard toy application, but all it’s really doing is querying Zilyo, a freely-available APIthat aggregates listings from rental providers like Airbnb. Sorry, it’s quite a bit of code.
zilyo.service.js (1.5.5)
'use strict';
function zilyoService($http, $filter, $q) {
// it's a singleton, so set up some instance and static variables in the same place
var baseUrl = "https://zilyo.p.mashape.com/search";
var countUrl = "https://zilyo.p.mashape.com/count";
var state = { callbacks: {}, params: {} };
// interesting function - send the parameters to the server and ask
// how many pages of results there will be, then process them in handleCount
function get(params, callbacks) {
// set up the state object
if (params) {
state.params = params;
}
if (callbacks) {
state.callbacks = callbacks;
}
// get a count of the number of pages of search results
return $http.get(countUrl + "?" + parameterize(state.params))
.then(extractData, handleError)
.then(handleCount);
}
// make the factory
return {
get : get
};
// boring function - takes an object of URL query params and stringifies them
function parameterize(params) {
return Object.keys(params).map(key => `${key}=${params[key]}`).join("&");
}
// interesting function - takes the results of the "count" AJAX call and
// spins off a call for each results page - notice the unpleasant imperativeness
function handleCount(response) {
var pages = response.data.result.totalPages;
if (typeof state.callbacks.onCountResults === "function") {
state.callbacks.onCountResults(response.data);
}
// request each page
var requests = _.times(pages, function (i) {
var params = Object.assign({}, { page : i + 1 }, state.params);
return fetch(baseUrl, params);
});
// and wrap all requests in a promise
return $q.all(requests).then(function (response) {
if (typeof state.callbacks.onCompleted === "function") {
state.callbacks.onCompleted(response);
}
return response;
});
}
// interesting function - fetch an individual page of results
// notice how a special callback is required because the $q.all wrapper
// will only return once ALL pages have been fetched
function fetch(url, params) {
return $http.get(url + "?" + parameterize(params)).then(function(response) {
if (typeof state.callbacks.onFetchPage == "function") {
// emit each page as it arrives
state.callbacks.onFetchPage(response.data);
}
return response.data; // took me 15 minutes to realize I needed this
}, (response) => console.log(response));
}
// boring function - takes the result object and makes sure it's defined
function extractData(res) {
return res || { };
}
// boring function - log errors, provide teaser for greater ambitions
function handleError (error) {
// In a real world app, we might send the error to remote logging infrastructure
var errMsg = error.message || 'Server error';
console.error(errMsg); // log to console instead
return errMsg;
}
}
// register the service
angular.module('angularZilyoApp').factory('zilyoService', zilyoService);
The wrinkle in this particular app is that it shows results on a map. Other services handle multiple pages of results by implementing pagination or lazy scrollers, which allows them to retrieve one neat page of results at a time. However, we want to show all results within the search area, and we want them to appear as soon as they return from the server rather than suddenly appearing once all pages are loaded. Additionally, we want to display progress updates to the user so that they have some idea of what’s happening.
In order to accomplish this in Angular 1.5, we resort to callbacks. Promises get us partway there, as you can see from the
$q.all
wrapper that triggers the onCompleted
callback, but things still get pretty messy.
Then we bring in lodash to create all of the page requests for us, and each request is responsible for executing the
onFetchPage
callback to make sure that it’s added to the map as soon as it’s available. But that gets complicated. As you can see from the comments, I got lost in my own logic and couldn’t get a handle on what was being returned to which promise when.
The overall neatness of the code suffers even more (far more than is strictly necessary), because once I become confused, it only spirals downward from there. Say it with me, please…
‘THERE HAS TO BE A BETTER WAY’
Angular 2: A New Way of Thinking
There is a better way, and I’m going to show it to you. I’m not going to spend too much time on the ES6 (a.k.a. ES2015) concepts, because there are far better places to learn about that stuff, and if you need a jumping-off point, ES6-Features.org has a good overview of all of the fun new features. Consider this updated AngularJS 2 code:
zilyo.service.ts (2.0.0-beta.17)
import {Injectable} from 'angular2/core';
import {Http, Response, Headers, RequestOptions} from 'angular2/http';
import {Observable} from 'rxjs/Observable';
import 'rxjs/Rx';
@Injectable()
export class ZilyoService {
constructor(private http: Http) {}
private _searchUrl = "https://zilyo.p.mashape.com/search";
private _countUrl = "https://zilyo.p.mashape.com/count";
private parameterize(params: {}) {
return Object.keys(params).map(key => `${key}=${params[key]}`).join("&");
}
get(params: {}, onCountResults) {
return this.http.get(this._countUrl, { search: this.parameterize(params) })
.map(this.extractData)
.map(results => {
if (typeof onCountResults === "function") {
onCountResults(results.totalResults);
}
return results;
})
.flatMap(results => Observable.range(1, results.totalPages))
.flatMap(i => {
return this.http.get(this._searchUrl, {
search: this.parameterize(Object.assign({}, params, { page: i }))
});
})
.map(this.extractData)
.catch(this.handleError);
}
private extractData(res: Response) {
if (res.status < 200 || res.status >= 300) {
throw new Error('Bad response status: ' + res.status);
}
let body = res.json();
return body.result || { };
}
private handleError (error: any) {
// In a real world app, we might send the error to remote logging infrastructure
let errMsg = error.message || 'Server error';
console.error(errMsg); // log to console instead
return Observable.throw(errMsg);
}
}
Cool! Let’s walk through this line by line. Again, the TypeScript transpiler lets us use any ES6 features that we want because it converts everything to vanilla JavaScript.
The
import
statements at the beginning are simply using ES6 to load in the modules that we need. Since I do most of my development in ES5 (aka regular JavaScript), I must admit that it’s a bit annoying to suddenly need to start listing every object that I plan to use.
However, keep in mind that TypeScript is transpiling everything down to JavaScript and is secretly usingSystemJS to handle module loading. The dependencies are all being loaded in asynchronously, and it is (allegedly) able to bundle your code in a way that strips out symbols that you haven’t imported. Plus it all supports “aggressive minification”, which sounds very painful. Those import statements are a small price to pay to avoid dealing with all of that noise.
Import statements are a small price to pay for what's going on behind the scenes.
Anyway, apart from loading in selective features from Angular 2 itself, take special notice of the line
import {Observable} from 'rxjs/Observable';
. RxJS is a mind-bending, crazy-cool reactive programming library that provides some of the infrastructure underlying Angular 2. We will definitely be hearing from it later.
Now we come to
@Injectable()
.
I’m still not totally sure what that does to be honest, but the beauty of declarative programming is that we don’t always need to understand the details. It’s called a decorator, which is a fancy TypeScript construct capable of applying properties to the class (or other object) that follows it. In this case,
@Injectable()
teaches our service how to be injected into a component. The best demonstration comes straight from the horse’s mouth, but it’s quite long so here’s a sneak peek of how it looks in our AppComponent:@Component({
...
providers: [HTTP_PROVIDERS, ..., ZilyoService]
})
Next up is the class definition itself. It has an
export
statement before it, which means, you guessed it, we can import
our service into another file. In practice, we’ll be importing our service into our AppComponent
component, as above.
@Injectable() teaches our service how to be injected into a component.
Right after that is the constructor, where you can see some real dependency injection in action. The line
constructor(private http:Http) {}
adds a private instance variable named http
that TypeScript magically recognizes as an instance of the Http service. Point goes to TypeScript!
After that, it’s just some regular-looking instance variables and a utility function before we get to the real meat and potatoes, the
get
function. Here we see Http
in action. It looks a lot like Angular 1’s promise-based approach, but under the hood it’s way cooler. Being built on RxJS means that we get a couple of big advantages over promises:- We can cancel the
Observable
if we no longer care about the response. This might be the case if we’re building a typeahead autocomplete field, and no longer care about the results for “ca” once they’ve entered “cat”. - The
Observable
can emit multiple values and the subscriber will be called over and over to consume them as they are produced.
The first one is great in a lot of circumstances, but it’s the second that we’re focusing on in our new service. Let’s go through the
get
function line by line:return this.http.get(this._countUrl, { search: this.parameterize(params) })
It looks pretty similar to the promise-based HTTP call you would see in Angular 1. In this case, we’re sending the query parameters to get a count of all matching results.
.map(this.extractData)
Once the AJAX call returns, it will send the response down the stream. The method
map
is conceptually similar to an array’s map
function, but it also behaves like a promise’s then
method because it waits for whatever was happening upstream to complete, regardless of synchronicity or asynchronicity. In this case, it simply accepts the response object and teases out the JSON data to pass downstream. Now we have:.map(results => {
if (typeof onCountResults === "function") {
onCountResults(results.totalResults);
}
return results;
})
We still have one awkward callback that we need to slide in there. See, it’s not all magic, but we can process
onCountResults
as soon the AJAX call returns, all without leaving our stream. That’s not too bad. As for the next line:
.flatMap(results => Observable.range(1, results.totalPages))
Uh oh, can you feel it? A subtle hush has come over the onlooking crowd, and you can tell that something major is about to happen. What does this line even mean? The right-hand part isn’t that crazy. It creates an RxJS range, which I think of as a glorified
Observable
-wrapped array. If results.totalPages
equals 5, you end up with something like Observable.of([1,2,3,4,5])
.flatMap
is, wait for it, a combination of flatten
and map
. There’s a great video explaining the concept at Egghead.io, but my strategy is to think of every Observable
as an array. Observable.range
creates its own wrapper, leaving us with the 2-dimensional array [[1,2,3,4,5]]
. flatMap
flattens the outer array, leaving us with [1,2,3,4,5]
, then map
simply maps over the array, passing the values downstream one at a time. So this line accepts an integer (totalPages
) and converts it into a stream of integers from 1 to totalPages
. It may not seem like much, but that’s all we need to set up.
THE PRESTIGE
I really wanted to get this on one line to increase its impact, but I guess you can’t win them all. Here we see what happens to the stream of integers that we set up on the last line. They flow into this step one by one, then are added to the query as a page parameter before finally being packaged into a brand new AJAX request and sent off to fetch a page of results. Here’s that code:
.flatMap(i => {
return this.http.get(this._searchUrl, {
search: this.parameterize(Object.assign({}, params, { page: i }))
});
})
If
totalPages
was 5, we construct 5 GET requests and send them all off simultaneously. flatMap
subscribes to each new Observable
, so when the requests return (in any order) they are unwrapped and each response (like a page of results) is pushed downstream one at a time.
Let’s look at how this whole thing works from another angle. From our originating “count” request, we find the total number of pages of results. We create a new AJAX request for each page, and no matter when they return (or in what order), they are pushed out into the stream as soon as they are ready. All that our component needs to do is subscribe to the Observable returned by our
get
method, and it will receive each page, one after the other, all from a single stream. Take that, promises.
The component will receive each page, one after the other, all from a single stream.
It’s all a bit anti-climactic after that:
.map(this.extractData).catch(this.handleError);
As each response object arrives from the
flatMap
, its JSON is extracted in the same manner as the response from the count request. Tacked on to the end there is the catch
operator, which helps illustrate how stream-based RxJS error handling works. It’s pretty similar to the traditional try/catch paradigm, except that the Observable
object works for asynchronous error handling as well.
Whenever an error is encountered, it races downstream, skipping past operators until it encounters an error handler. In our case, the
handleError
method re-throws the error, allowing us to intercept it within the service but also to let the subscriber provide its own onError
callback that fires even further downstream. Error handling shows us that we haven’t taken full advantage of our stream, even with all the cool stuff we’ve already accomplished. It’s trivial to add a retry
operator after our HTTP requests, which retries an individual request if it returns an error. As a preventative measure, we could also add an operator between the range
generator and the requests, adding some form of rate-limiting so that we don’t spam the server with too many requests all at once.Recap: Learning Angular 2 Isn’t Just About a New Framework
Learning Angular 2 is more like meeting an entirely new family, and some of their relationships are complicated. Hopefully I’ve managed to demonstrate that these relationships evolved for a reason, and there’s a lot to be gained by respecting the dynamics that exist within this ecosystem. Hopefully you enjoyed this article as well, because I’ve barely scratched the surface, and there’s a lot more to say on this subject.
No comments:
Post a Comment