OPTEN, das einzige Umbraco-zertifizierte Unternehmen der Schweiz

Introduction to Angular: Thinking in streams

Angular is a great front-end web framework from google. It is very powerful, but not immediately intuitive, the reason is that the Angular paradigm is to use streams (Observables) throughout, and to grasp this requires a change of thinking. So the aim of this blog is to provide an overview, a basic introduction, and to encourage you to think another way.

Imperative vs Declarative.

Angular subscribes to the imperative rather than the declarative programming paradigm.

What does that sentence mean? Sounds like gobbledygook. Well I will attempt to explain it here. Declarative thinking is thinking for a solution step by step (what do you want to do), Imperative thinking is at a higher level (what do you want to achieve).

Imagine that your friend is coming to meet you, they call and say that they are at the station, how do they get to your house from there? There are two answers that you could give to this question. The imperative answer is: “go past the station until you get to the church, then turn left, go through two sets of traffic lights, take the next right, and my house is the one with the red door on your right.” The declarative answer would be: “my address is 123 easy street, 8050”. The declarative way of thinking gives instructions for each step. The declarative assumes that they have a SatNav and will type in your address and find your house that way. Generally a declarative approach assumes an underlying imperative abstraction (in this case the SatNav).

A programming language which is declarative is SQL, you will write “select name from user where country = ‘Switzerland’”. You say what you want, and you do not know or care how the sql gets the result for you.

For another illustration, write a javascript function to multiply all numbers in an array by 2. The declarative way would be as follows:

function double (arr) {
    let results = []
    for (let i = 0; i < arr.length; i++){
        results.push(arr[i] * 2)
    }
    return results
}

The declarative way would be to use the javascript map function:

function double (arr) {
    return arr.map((item) => item * 2)
}

The declarative way uses less code, because you just tell javascript what you want to happen and let it get on with it.

Angular fits into this paradigm, because instead of having to write a lot of jquery to update a part of your website with ajax calls. The angular framework takes care of updating the dom for you, you just have to tell angular what you want to do, and it takes care of it for you. This means that your code is cleaner, easier to understand and quicker to write. This section was adapted from a brilliant blog post from Tyler McGinnis. I recommend reading his original blog if you want an expanded explanation of this.

Who to follow.

There is a brilliant introduction by André Staltz, where he takes you step by step through a project to create a “Who to follow” suggestions box for github users, you can see his blog here. Rather than reinvent the wheel, I decided to create the same project as he did, except using the angular framework and typescript as opposed to plain javascript/jquery as he does. 

So the overview of the project is to implement the basic functionality of the “who to follow box” on twitter, there will be 3 users, it will be possible to refresh all 3 by clicking on the refresh button, or just one by clicking on the x next to the users name.

 Who To Follow

Angular Cli.

We will use the Angular Cli (command line interface) because it allows you to set up a new angular website really fast, and sets up the recommended folder structure. The npm package is here. You need to install this globally first using the command:

npm install -g @angular/cli

now to start a new angular website, you simply navigate to the folder where you want the project to be and type the new command:

ng new WhoToFollow

Then cd to that newly created folder and run the serve command.

ng serve

Now if you open a browser and navigate to http://localhost:4200/ you will see the new website, showing the text ‘app works’.

What did I just install.

An Angular app is divided into components. If you open the app folder you will see that the Angular cli generated a new component, app.component.ts. This component has a title property, which is set to the text ‘app works’. At the top of the component, the templateUrl is set to the html file, and in this html file, the title property is rendered. There is also a css file and some tests.

The lifecycle is a follows: when the application starts, the main.ts file runs, in this file the AppModule is called, in the app module the app component is declared, and then bootstrapped.

The tslint.json file is a style guide, when you run ng lint, it checks all the code according to the guidelines in this file to see that it conforms to the guidelines.

There are two types of tests which have been installed, unit testing and end to end testing. The unit tests for the app.component are found in the app.component.spec.ts file, you can run the unit tests with the ng test command. This opens a new browser running the Karma testing environment showing the test results. The end-to-end tests are in the e2e folder. You can run these tests with the command ng e2e (but make sure that you are serving the website first).

Thinking in streams.

When working with Angular you will quickly run into the idea of observables which is their word for a stream. When you program with streams, then you are doing reactive programming.

Facebook originally developed the react framework because it was finding it difficult to keep track of changes over time for their chat system. You can see Pete Hunt give an explanation of the original problem in their f8 conference in 2014:

A good way to solve this problem is to put all these changes to user status into a stream, then let the Angular framework render this stream to html.

There is a subject, the subject emits events asynchronously over time, and there can be observers who subscribe to the stream.

In the facebook chat example the stream would look like this.

Stream

What we can do in the Angular framework is to subscribe to this stream, and let the framework render the stream to the dom over time. This makes development so much easier. On top of this the rxjs framework which is installed with Angular gives you lots of amazing functions to apply to the streams.

Creating the first stream.

If everything is a stream, then we can start by creating a new stream containing the github api url. Change the code in the app.component.ts to look like this:

import { Component, OnInit, OnDestroy } from '@angular/core';

import { Subscription } from 'rxjs/Subscription';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {

    title = 'suggested git users';
    apiUrl;

    requestSubscription: Subscription;
    apiUrlSubject = new BehaviorSubject('https://api.github.com/users');

    constructor() {}

    ngOnInit(): void {
        this.requestSubscription = this.apiUrlSubject.asObservable()
        .subscribe(url => {
        this.apiUrl = url;
        });
    }

    ngOnDestroy(): void {
        this.requestSubscription.unsubscribe();
    }
}

And the code in the app.component.html to look like this:

<h1>
    {{title}}
</h1>

{{apiUrl}}

What we have done is create a new subject, which emits the github api url, then subscribed to the observable emitted from this subject. The function in the subscriber sets the apiUrl variable to the url value. Then we render this apiUrl value in the html. Also it is important that we unsubscribe from the observable when this component is destroyed, otherwise we will have a memory leak.

The refresh button.

This stream only emits one value at the moment, which is a bit boring, so now we will add the refresh button, which will push a new url to the stream every time it is pressed. So add the refresh method to the app.component.ts.

refresh() {
    var random = Math.floor(Math.random() * 500);
    this.apiUrlSubject.next(`https://api.github.com/users?since=${random}`);
}

And add a refresh button to the app.component.html:

<button type='button' (click)='refresh()'>
    Refresh
</button>

Angular handles the click event, just as it handles updating the dom. So when you click on the refresh button it will add a new url to the apiUrlSubject, which will be observed by the subscriber, and the value of the apiUrl variable will be updated.

The async pipe.

Now is a good time to introduce the async pipe, this is an operator in angular which means that you can provide the angular view with an observable instead of a value, and angular will automatically subscribe to that observable for you, it also means that you do not need to unsubscribe on destroy, Angular also takes care of that for you, so makes the code even cleaner. The app.component.ts now looks like this:

import { Component, OnInit, OnDestroy } from '@angular/core';

import { Subscription } from 'rxjs/Subscription';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {

    title = 'suggested git users';

    request$: Observable;
    apiUrlSubject = new BehaviorSubject('https://api.github.com/users');

    constructor() {}

    ngOnInit(): void {
        this.request$ = this.apiUrlSubject.asObservable();
    }

    refresh() {
        var random = Math.floor(Math.random() * 500);
        this.apiUrlSubject.next(`https://api.github.com/users?since=${random}`);
    }
}

And we need to add the async pipe to the app.component.html:

{{request$ | async}}

Getting the git users.

Now we have a stream of urls, we can call this url and get a list of git users from the api. The app.component.ts now looks like this:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Http } from '@angular/http';

import { Subscription } from 'rxjs/Subscription';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/map';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {

    title = 'suggested git users';

    usersToFollow$: Observable;
    apiUrlSubject = new BehaviorSubject('https://api.github.com/users');

    constructor(private http: Http) {}

    ngOnInit(): void {
        this.usersToFollow$ = this.apiUrlSubject.asObservable()
        .mergeMap(url => {
            return this.http.get(url)
            .map(res => res.json());
        })
    }

    refresh() {
        var random = Math.floor(Math.random() * 500);
        this.apiUrlSubject.next(`https://api.github.com/users?since=${random}`);
    }
}

And the app.component.html looks like:

<h1>
    {{title}}
</h1>

<button type='button' (click)='refresh()'>Refresh</button>

<ul>
    <li *ngFor='let user of usersToFollow$ | async'>
        {{user.login}}
    </li>
</ul>

In the component, first we need to import the http class from the “@angular/http” module, this handles all http calls for us. Angular also implements dependency injection for us, so we inject this http class into the constructor. Then we use the mergeMap operator, to take the url from the url stream, and then return a new observable of the json from the http response. Then in the html we use the *ngFor operator to loop through all users in the json object array, and for each user, render the login name.

Creating a new component.

Angular makes it really easy to encapsulate code into components, in our case, it would be really nice to have another component for each user. The cli makes this really easy, you just need to type the command ‘ng g component user’. This generates a new folder named user, with the new component in. It also adds this new component to the app.module.ts for you.

Then open this new user folder, and change the code so that the user.component.ts looks like:

import { Component, OnInit, Input } from '@angular/core';

@Component({
    selector: 'app-user',
    templateUrl: './user.component.html',
    styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

    @Input() user;

    constructor() { }

    ngOnInit() {
    }
}

The user.component.html:

<img [src]='user.avatar_url' />
{{user.login}} 

The user.component.css:

img {
    width: 40px;
    height: 40px;
    border-radius: 20px;
}

The app.component.html:

<h1>
    {{title}}
</h1>

<button type='button' (click)='refresh()'>Refresh</button>

<ul>
    <li *ngFor='let user of usersToFollow$ | async'>
        <app-user [user]='user'></app-user>
    </li>
</ul>

In the user.component.ts, the selector is app-user, then when we use the tag in the app.component.html, angular knows to use this component, and by adding the @Input(), this means that we can pass the user to the child component.

Only show 3 users.

We only want to show 3 suggested users, not all 30, so we can change the app.component.ts to only take the first 3 users my adding this line:

.map(users => users.slice(0, 3))

Adding the close button.

The last piece of functionality which we want to implement is a close button for each user. Change the code so that the user.component.html looks like:

<img [src]='user.avatar_url'>
{{user.login}}
<a (click)='close()'>✕</a>

The user.component.ts;

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';

@Component({
    selector: 'app-user',
    templateUrl: './user.component.html',
    styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {

    @Input() user;
    @Output() closedUser = new EventEmitter();

    constructor() { }

    ngOnInit() {
    }

    close() {
        this.closedUser.emit(this.user.url);
    }
}

The app.component.html:

<h1>
    {{title}}
</h1>

<button type='button' (click)='refresh()'>Refresh</button>

<ul>
    <li *ngFor='let user of usersToFollow$ | async'>
        <app-user [user]='user' (closedUser)='close($event)'></app-user>
    </li>
</ul>

<p *ngIf='closedUser !== undefined'>User {{closedUser}} was closed</p>

The app.component.ts:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Http } from '@angular/http';

import { Subscription } from 'rxjs/Subscription';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/map';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {

    title = 'suggested git users';
    closedUser = '';

    usersToFollow$: Observable;
    apiUrlSubject = new BehaviorSubject('https://api.github.com/users');

    constructor(private http: Http) {}

    ngOnInit(): void {
        this.usersToFollow$ = this.apiUrlSubject.asObservable()
        .mergeMap(url => {
            return this.http.get(url)
            .map(res => res.json());
        })
        .map(users => users.slice(0, 3))
    }

    refresh() {
        var random = Math.floor(Math.random() * 500);
        this.apiUrlSubject.next(`https://api.github.com/users?since=${random}`);
    }

    close(userUrl: string) {
        this.closedUser = userUrl;
    }    
}

Angular makes it easy for child components to raise events in the parent component. We do this here by making an @Output() EventEmitter and then emitting the user url on the close click event. 

Caching.

Now the close button only shows the username, we want to actually close the user and load a new user, we get 30 users with every Http request, so instead of making a new http request it would be faster performance-wise to cache these 30 users and just load another from the cache. To do this we change the app.component.ts code to the following:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Http } from '@angular/http';

import { Subscription } from 'rxjs/Subscription';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/merge';
import 'rxjs/add/operator/scan';
import 'rxjs/add/operator/do';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {

    title = 'suggested git users';
    userCache;

    cachedUsers$: Observable;
    usersToFollow$: Observable;

    closedUsers = new Subject();
    apiUrlSubject = new BehaviorSubject('https://api.github.com/users');

    constructor(private http: Http) {}

    ngOnInit(): void {
        this.cachedUsers$ = this.closedUsers.asObservable()
        .scan((all: string[], user: string) => {
            all.push(user);
            return all;
        }, [])
        .map(closedUsers => {
            return this.LoadInNewCachedUser(closedUsers)
        });

        this.usersToFollow$ = this.apiUrlSubject.asObservable()
        .mergeMap(url => {
            return this.http.get(url)
            .map(res => res.json());
        })
        .do(res => this.userCache = res)
        .merge(this.cachedUsers$)
        .map(users => users.slice(0, 3));
    }

    refresh() {
        var random = Math.floor(Math.random() * 500);
        this.apiUrlSubject.next(`https://api.github.com/users?since=${random}`);
    }

    close(userUrl: string) {
        this.closedUsers.next(userUrl);
    }

    private LoadInNewCachedUser(closedUsers: string[]) {
        const unclosedCachedUsers = this.userCache.slice()
        .filter(user => closedUsers.some(closed => closed === user.url) === false)
        .filter(user => this.userCache.slice(0, 3).some(top3 => top3.url === user.url) === false);

        this.userCache = this.userCache.map(user => {
            if (closedUsers.some(closedUser => closedUser === user.url)) {
                return unclosedCachedUsers.pop();
            } else {
                return user;
            }
        });

        return this.userCache;
    }
}

Now whenever we make a call to the github api, we use the do operator to save the response in the userCache variable. Then we have created another subject closedUsers, whenever a user is closed the name of the user is emitted from this subject. The scan function, pushes all closed usernames into an array, then the map function passes these closed users into a new method LoadInNewCachedUser which replaces any closed users in the cache. The merge function, then merges these two streams together into one resulting stream meaning that the view is updated.

What next?

By creating this app, I hope I have given you a taster of Angular and rxjs. Using Streams it is possible to do a lot with a little code, meaning that you can code faster, and the code will be easier to read. Angular helps also in the declarative way that you can write the html. If you want to see the finished app, you can download it from my github here.

There is so much more to learn about angular, I have only scratched the surface in this blog. I recommend the Tour of Heroes tutorial on the official Angular site. If you want to learn more rxjs functions, then they are all listed here, and you can see marble diagrams of them here. I also recommend using the @ngrx/store to manage state if you have a more complex app.


kommentieren


1 Kommentar(e):

Andre Bossard
Oktober 5, 2017 03:07

Hi, I think in some occasions, "declarative" and "imperative" are used wrong in this article. E.g.: "Angular subscribes to the imperative rather than the declarative programming paradigm." should probably be "Angular subscribes to the _declarative_ rather than the _imperative_ programming paradigm.". And "The declarative way would be as follows:" should be "The _imeprative_ way would be as follows:" But besides that: Really good article! Thanks a lot.