Universally Speaking

Hi, I'm Craig

This is a deep dive!

@phenomnominal 2019

Assumptions:

You know some Angular ✅

You've heard of Angular Universal ✅

What we're covering:

How does Angular Universal work?

What problems does it solve?

How do we make our applications work with Universal?

Why would you not want to use Universal?

@phenomnominal 2019

🌟

🌟

🌟

🌟

🌟

🌟

How does Angular Universal work?

@phenomnominal 2019

Angular

my-site.nz/item/:id
  <html>
      <link 
          rel="stylesheet"
          href="/static/styles.css">
      <script
          src="/static/script.js">
      <body>
          <trade-me>
              <div
                  class="my-loading">
              </div>
          </trade-me>
      </body>
  </html>
          rel="stylesheet"
          href="/static/styles.css"

          src="/static/script.js"



                  class="my-loading"


              "stylesheet"
               "/static/styles.css"

              "/static/script.js"



                        "my-loading"


Generic application loading state

Static resources

@phenomnominal 2019

Angular

my-site.nz/static/script.js
my-site.nz/static/styles.css
       function () {
           ...
       }
       
       html { 
           ...
       }
                () {
           ...
       }
       
            { 
           ...
       }
                
           ...
       
       
             
           ...
       
@phenomnominal 2019

Angular

my-site.nz/api/item/:id.json
    {"id":"1234567890", ... }
@phenomnominal 2019

Can we remove some of those round trips?

@phenomnominal 2019

Angular Universal

my-site.nz/item/:id
  <html>
      <link 
          rel="stylesheet"
          href="/static/styles.css">
      <script
          src="/static/script.js">
      <body>
          <my-site>
              <my-site-homepage>
                  <my-site-search>
              </my-site-homepage>
          </my-site>
      </body>
      <script
          id="server-app-state">
      </script>
</html>
          rel="stylesheet"
          href="/static/styles.css"

          src="/static/script.js"








          id=

              "stylesheet"
               "/static/styles.css"

              "/static/script.js"

          






             "server-app-state"

Real application content!

Inlined server request data

@phenomnominal 2019
@phenomnominal 2019

What problems does it solve?

Performance

Slower "Time to First Byte" due to server processing time

Faster "First Paint" over client-only application, particularly for mobile devices

This can be mitigated with caching - both on the server & on the client with a service worker

Slower "Time to Interactive" due to client-side rehydration

Faster discovery of resources required for your application

Measure, experiment, and find what works for your app

@phenomnominal 2019

SEO/No JS Environments

Some crawlers have JavaScript capabilities, but who knows how that really works (not me!)

Support for browsers with JavaScript disabled, or old engines.

(Check out Igor Minar's  talk from ng-conf 2018 for how angular.io handles SEO without Angular Univeral)

If you're competing against other websites, server rendering can give you an advantage over client-only SEO

@phenomnominal 2019

Social links

Pre-rendered content for Facebook, Twitter, etc.

@phenomnominal 2019

🚀

🇳🇿

🌎

🦄

⭐️

Neat.

🛰

⭐️

⭐️

⭐️

⭐️

⭐️

⭐️

⭐️

⭐️

🆖

🛸

@phenomnominal 2019

How do we make Angular Universal work with our applications?

@phenomnominal 2019

🤔

@phenomnominal 2019
@phenomnominal 2019

Asteroids!

Facts about asteroids:

Categorised as "minor planets"

Not comets or meteoroids

14,464 known "near-Earth" asteroids

ALLEGEDLY killed the dinosaurs

Cool names like "Odysseus" and "James Bond"

💥

💥

💥

💥

💥

💥

💥

💥

@phenomnominal 2019

Asterank

@phenomnominal 2019

We can rebuild it

@phenomnominal 2019

Angular CLI

@phenomnominal 2019
ng new universally-speaking && cd universally-speaking
ng add @nguniversal/express-engine --clientProject universally-speaking
npm run build:ssr
npm run serve:ssr

Demo.

@phenomnominal 2019
@phenomnominal
2018

⭐️ A new app Angular CLI app

⭐️ Build steps for two different versions of the app and the server

⭐️ All the necessary Angular Universal dependencies

🎉🎉🎉🎉🎉

⭐️ A new server.ts file which contains the server code

⭐️ A modified app.module.ts that uses withServerTransition

⭐️ A new app.server.module.ts file for the server application

⭐️ A modified main.ts that waits for DOMContentLoaded before             bootstrap

⭐️  A new main.server.ts file for the server bootstrap

What do we have?

@phenomnominal 2019

JavaScript runtime (no DOM)

Driven by Chrome's V8 engine

Use everyone else's code via 

@phenomnominal 2019

Node.js

Require the built Universal app

Set up the engine for running the application

Pass all requests to the engine

Create the Express server

Start the server

@phenomnominal 2019
const app = express();

const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/server/main');

app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

app.get('*', (req, res) => {
  res.render('index', { req });
});

app.listen(PORT, () => {
  console.log(`Node Express server listening on http://localhost:${PORT}`);
});

Time passing

@phenomnominal 2019
@phenomnominal 2019

Demo.

@phenomnominal
2018

What do we have?

⭐️ @angular/cli application

⭐️ Asteroid data requested from Asterank

⭐️ WebGL animation

⭐️ @ngrx/store + router-state + entity

😔 Client render only

@phenomnominal 2019
window is not defined
@phenomnominal 2019

How do we run this on the server?

🤔

@phenomnominal 2019

Angular Universal

Run your Angular application anywhere...

https://www.youtube.com/watch?v=_trUBHaUAR0
@phenomnominal 2019

Anywhere...

@phenomnominal 2019
@angular/platform-browser
@angular/platform-browser-dynamic
@angular/platform-server
@angular/platform-webworker // Gone!!
@angular/platform-webworker-dynamic // Gone!!
@angular/platform-terminal // !!!

How do we run this anywhere?

🤔

@phenomnominal 2019

One implementation for all environments

One implementation for a specific environment

Different implementations for each environment

Different functionality for each environment

@phenomnominal 2019

Different implementations for each environment

@phenomnominal 2019

Platform-specific modules

Remember app.module.ts & app.server.module.ts?

@phenomnominal 2019
// app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';

@NgModule({
    bootstrap: [AppComponent],
    declarations: [AppComponent],
    imports: [
        BrowserModule.withServerTransition({ appId: 'serverApp' }),
        RouterModule.forRoot([{
            // Routes...
        }], {
            initialNavigation: 'enabled'
        })
    ]
})
export class AppModule { }
// app.server.module.ts

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
    bootstrap: [AppComponent],
    imports: [
        AppModule,
        ModuleMapLoaderModule,
        ServerModule
    ]
})
export class AppServerModule { }

Services

Provide different implementations for different environments:

Server implementation for Renderer

Server implementation for HttpClient

@phenomnominal 2019
// @angular/platform-server

@NgModule({
    exports: [BrowserModule],
    imports: [HttpModule, HttpClientModule, NoopAnimationsModule],
    providers: [
        SERVER_RENDER_PROVIDERS,
        SERVER_HTTP_PROVIDERS,
        // ...
    ] 
})
export class ServerModule { }

One implementation for all environments

@phenomnominal 2019

Avoiding the DOM

Do not use the DOM directly! There's almost always a way to do the same thing with Angular's abstractions.

@phenomnominal 2019

Components

@Input, @Output, @HostBinding, @ViewChild, @ContentChild, ngStyle, ngClass.

You may occasionally need to reach for the Renderer:

Normal dependency injection

@phenomnominal 2019
import { Component, Renderer2 } from '@angular/core'

@Component({
    // ...
})
export class MyComponent {
    constructor (
        private _renderer: Renderer2
    ) { }
}

But what if you have to use the DOM?

🤔

@phenomnominal 2019

Be explicit about it

Be defensive!

Explicit null type is good

Only run code if window is available.

@phenomnominal 2019
@Injectable({
    providedIn: 'root'
})
export class WindowRef<T extends Window = Window> {
    private readonly _window: T | null;

    constructor () {
        this._window = this._getWindow();
    }

    public useWindow <U> (handler: (window: T) => U): U | null {
        return this._window ? handler(this._window): null;
    }

    public _getWindow (): T | null {
        return typeof window !== 'undefined' ? window as T : null;
    }
}

Provide an alternative

Use custom services to abstract around DOM APIs.

Only fall back to the actual DOM if you have to

Optional injection token!

Use the optionally injected value if available

@phenomnominal 2019
export const URL_LOCATION_TOKEN = new InjectionToken('URL_LOCATION');

@Injectable({
    // ...
})
export class UrlLocationService {
    constructor (
        private _window: WindowRef,
        @Optional() @Inject(URL_LOCATION_TOKEN) private _location: Location
    ) {
        this._location = this._location || this._window.useWindow(w => w.location);
    }

    public getHostname (): string {
        return this._location.hostname;
    }
}

Hack it?

Some third-party code won't want to play nicely...

(here's lookin' at you three-trackballcontrols 😅)

Set up globals

Require troublesome module

Clean up after

Directly check for window

@phenomnominal 2019
if (typeof window === 'undefined') {
  (global as any).window = {};
  require('three-trackballcontrols');
  delete (global as any).window;
}

Check the platform...

@phenomnominal 2019

Check the platform

If you really must...

Injectable PLATFORM_ID token

Use the method for the specific platform

@phenomnominal 2019
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { Inject, PLATFORM_ID } from '@angular/core';

@Component({
    // ...
})
export class MyComponent {
    constructor (
        @Inject(PLATFORM_ID) private _platformId: Object
    ) { }

    public myExpensiveDomHeavyOperation (): void {
        if (!isPlatformBrowser(this._platformId) {
            return;
        }

        this._useTheDom();
    }
}
@phenomnominal 2019

Demo.

@phenomnominal
2018
document is not defined

One implementation for a specific environment

@phenomnominal 2019

Just turn it off

Add @Optional injection annotation

Trusty if statement

Check for the presence of an injected service:

@phenomnominal 2019
@Component({
  selector: 'us-asteroid-visualisation',
  templateUrl: './asteroid-visualisation.component.html',
  styleUrls: ['./asteroid-visualisation.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AsteroidVisualisationComponent implements OnInit {
  @ViewChild('container') public container: ElementRef;
  
  constructor (
    private _asteroidDataFacade: AsteroidDataFacade,
    private _renderer: Renderer2,
    @Optional() private _visualisationService: VisualisationService
  ) { }
  
  public ngOnInit (): void {
    if (this._visualiationService) {
      const canvas = this.visualisationService.init(this.container);
      this._renderer.appendChild(this.container.nativeElement, canvas);
    } 
  }
}

Just turn it off

Turn things off at a provider level:

Provide null for specific platform

Corresponding injection token

@phenomnominal 2019
@NgModule({
    imports: [
        // ...
    ],
    bootstrap: [AppComponent],
    providers: [
        { provide: VisualisationService, useValue: null }
    ]
})
export class AppServerModule { }
@phenomnominal 2019

Demo.

Different functionality for each environment

@phenomnominal 2019

State Transfer

The whole application runs twice.

This means duplicate API calls! 🤯

This call happens twice

@phenomnominal 2019
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  // ...
})
export class AppComponent implements OnInit {
  asteroids: Array<Asteroid>;
  
  constructor (
    private _asteroidDataService: AsteroidDataService
  ) { }
  
  public ngOnInit (): void {
    this._asteroidDataService.getAsteroids().subscribe(data => this.asteroids = data);
  }
}

State Transfer

Inject TransferState

Create a state key

Set the state on the first run

Re-use it if it's available

@phenomnominal 2019
import { TransferState, makeStateKey } from '@angular/platform-browser';

const ASTEROIDS = makeStateKey('asteroids');

@Component({ ... })
export class AppComponent implements OnInit {
  public asteroids = Array<Asteroid>;
  
  constructor (
    private _asteroidDataService: AsteroidDataService,
    private _transferState: TransferState
  ) { }

  public ngOnInit (): void {
    this.asteroids = this._transferState.get(ASTEROIDS, null);

    if (!this.asteroids) {
      this._asteroidDataService.getAsteroids()
        .subscribe(data => {
          this.asteroids = data;
          this._transferState.set(ASTEROIDS, data);
        });
    }
  }
}

State Transfer

The state transfer data is inlined into the HTML response as a blob of JSON. This means...

Don't send too much data!

Your whole state must be JSON serialisable!

@phenomnominal 2019

State Transfer + Store

@phenomnominal 2019
@Injectable()
export class AsteroidDataEffects {
  // ...

  @Effect()
  public getAsteroidDataFromApiEffect$ = this._actions$.pipe(
    ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataFromApiAction.TYPE),
    switchMap(action => {
      return this._asteroidDataService.getAsteroidData(action.options).pipe(
        map(
          response => 
            new GetAsteroidDataFromApiSuccessAction(action.options, response)
        ),
        catchError(
          err => 
            of(new GetAsteroidDataFromApiFailAction(action.options, err))
        )
      );
    })
  );
}

Using a centralised data store makes this even more interesting:

When a Get action happens

Call through to the API

State Transfer + Store

@phenomnominal 2019

If you're using a central data store to manage your application state, you can just transfer the whole store!

State Transfer + Store

One state key for your whole store

Read the whole store and serialise

@phenomnominal 2019
@NgModule({
  imports: [BrowserTransferStateModule]  
})
export class ServerStateModule {
  constructor (
    private _transferState: TransferState,
    private _store: Store<State>
  ) {
     this._transferState.onSerialize(STATE_KEY, () => {
       let stateToTransfer = null;
       this._store.pipe(
         select(state => state),
         take(1)
       ).subscribe(state => stateToTransfer = state);
       
       return stateToTransfer;
     }); 
  }
}

Specific NgModule for the server app

State Transfer + Store

Use the same key on the client

Dispatch a new action with the whole serialised store 

@phenomnominal 2019
@NgModule({
  imports: [BrowserTransferStateModule]  
})
export class ClientStateModule {
  constructor (
    private _transferState: TransferState,
    private _store: Store<State>
  ) {
     if (this._transferState.hasKey(STATE_KEY)) {
       const state = this._transferState.get<State>(STATE_KEY, {});
       this._trasferState.remove(STATE_KEY);
       
       this._store.dispatch(new TransferStateAction(state));
     }
  }
}

Specific NgModule for the client app

State Transfer + Store

@phenomnominal 2019
@Injectable()
export class AsteroidDataEffects {
  // ...

  @Effect()
  public getAsteroidDataFromApiEffect$ = this._actions$.pipe(
    ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataFromApiAction.TYPE),
    switchMap(action => {
      return this._asteroidDataService.getAsteroidData(action.options).pipe(
        map(
          response => 
            new GetAsteroidDataFromApiSuccessAction(action.options, response)
        ),
        catchError(
          err => 
            of(new GetAsteroidDataFromApiFailAction(action.options, err))
        )
      );
    })
  );
}

Let's quickly look at our Effect again:

State Transfer + Store

@phenomnominal 2019
@Effect()
public getAsteroidDataEffect$ = this._actions$.pipe(
  ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataAction.TYPE),
  switchMap(action => {
    this._store.select(AsteroidDataSelectors.currentAsteroidData).pipe(
      take(1),
      map(() => new GetAsteroidDataFromApi(action.options))
    )
  })
)

@Effect()
public getAsteroidDataFromApiEffect$ = this._actions$.pipe(
  ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataFromApiAction.TYPE),
  switchMap(action => {
    return this._asteroidDataService.getAsteroidData(action.options).pipe(
      map(response =>
        new GetAsteroidDataFromApiSuccessAction(action.options, response)
      )
    );
  })
);

We can split this in two:

State Transfer + Store

Use a simple cache layer to prevent duplicated calls

Mark each API response with a cache time

Only do the call if the cache time has expired

@phenomnominal 2019
@Effect()
public getAsteroidDataEffect$ = this._actions$.pipe(
  ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataAction.TYPE),
  switchMap(action => {
    this._store.select(AsteroidDataSelectors.currentAsteroidData).pipe(
      take(1),
      filter(item => this._cacheService.shouldFetch(item, TWO_MINUTES)),
      map(() => new GetAsteroidDataFromApi(action.options))
    )
  })
)

@Effect()
public getAsteroidDataFromApiEffect$ = this._actions$.pipe(
  ofType<GetAsteroidDataFromApiAction>(GetAsteroidDataFromApiAction.TYPE),
  switchMap(action => {
    return this._asteroidDataService.getAsteroidData(action.options).pipe(
      map(response =>
        new GetAsteroidDataFromApiSuccessAction(action.options, response, Date.now())
      )
    );
  })
);
@phenomnominal 2019

Demo.

One implementation for all environments

One implementation for a specific environment

Different implementations for each environment

Different functionality for each environment

@phenomnominal 2019

Cool.

@phenomnominal 2019

Why wouldn't you use Angular Universal?

🤔

@phenomnominal 2019

Performance

Universal is not a magical silver rocket.

If performance is your only goal, just ship less JavaScript

Rocket Lab! (YEAH NZ 🇳🇿)

It can definitely make things worse if you're not careful!

@phenomnominal 2019

Complexity

You now need a "real" server, not just static files.

Harder to get working, harder mental model, harder to get people shipping value

There will be lots more code - logging, monitoring

@phenomnominal 2019

Workflow/DX

It's very slow...

Bazel will fix it?

@phenomnominal 2019

So it's just a dumpster fire?

@phenomnominal 2019
@phenomnominal 2019

Should I use Angular Universal?

If you want/need your Angular app to be crawled by Search Engines

@phenomnominal 2019

If you want/need improved No JS/old browser support

@phenomnominal 2019

If you want/need content previews for Social Media

@phenomnominal 2019

AND if you're willing to fight for better perceived performance

@phenomnominal 2019

Angular Universal

@phenomnominal 2019

Chur.

@phenomnominal 2019

Universally Speaking

By Craig Spence

Universally Speaking

Craig Spence - ComponentsConf 2019 - Universally Speaking

  • 3,106