ANGULAR UNIVERSAL
in


2019
Hi!
I'm
Craig
2019
This is a deep dive!

@phenomnominal
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?
Angular
universal.app/data/:id
<html>
<link
rel="stylesheet"
href="/static/styles.css">
<script
src="/static/script.js">
<body>
<universal-app>
<div
class="loading >
</div>
</universal-app>
</body>
</html>
rel="stylesheet"
href="/static/styles.css"
src="/static/script.js"
class="loading"
"stylesheet"
"/static/styles.css"
"/static/script.js"
"loading"
Generic application loading state
Static resources


@phenomnominal
2019


universal.app/static/script.js
universal.app/static/styles.css
() {
...
}
{
...
}
...
...
function () {
...
}
html {
...
}
Angular
@phenomnominal
2019


universal.app/api/search.json?query=hi
universal.app/api/data/:id.json
Angular
{
"id": "1234567890", ...
}
{
"results": [{
"id": 1234567890,
"content": "Hi", ...
}]
}
@phenomnominal
2019
What can we do to remove some of those round trips?

@phenomnominal
2019


Angular
Universal
universal.app/data/:id
<html>
<link
rel="stylesheet"
href="/static/styles.css">
<script
src="/static/script.js">
<body>
<universal-app>
<ua-homepage>
<ua-search>
<ua-content>
</ua-homepage>
</universal-app>
</body>
<script
id="server-app-state">
</script>
</html>
"stylesheet"
"/static/styles.css"
"/static/script.js"
"server-app-state"
rel=" "
href=" "
src=" "
id=
Real application content!
Inlined server request data!
@phenomnominal
2019
@phenomnominal
What problems does it solve?
Performance
SEO/No JavaScript environments
Social media links
Angular
Universal
2019
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
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


@phenomnominal
2019









@phenomnominal
2019
How do we make our
applications work with Angular Universal?
REAL WORLD!
@phenomnominal
2019


@phenomnominal
2019

We can rebuild it!
@phenomnominal
2019
@phenomnominal
2019
Angular CLI
ng new the-real-world && cd the-real-world
ng add @nguniversal/express-engine --clientProject the-real-world
npm run build:ssr
npm run serve:ssr
@phenomnominal
2019
Demo!
@phenomnominal
2019
A new app Angular CLI app
Build steps for two different versions of the app
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 main.ts that waits for DOMContentLoaded before bootstrap
A new main.server.ts file for the server bootstrap
@phenomnominal
2019
Build steps for server

JavaScript runtime (no DOM)
Driven by Chrome's V8 engine

Use everyone else's code via


@phenomnominal
2019
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
Node.js
const app = express();
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main');
app.engine('html', ngExpressEngine({
bootstrap: AppServerModuleNgFactory,
providers: [
provideModuleMap(LAZY_MODULE_MAP)
]
}));
app.set('view engine', 'html');
app.set('views', DIST_FOLDER);
app.get('*.*', express.static(DIST_FOLDER, {
maxAge: '1y'
}));
app.get('*', (req, res) => {
res.render('index', { req });
});
app.listen(PORT, () => {
console.log(`Node Express server listening on http://localhost:${PORT}`);
});
@phenomnominal
2019
TIME TRAVEL
@phenomnominal
2019
Demo!
@phenomnominal
2019
⭐️ @angular/cli application
⭐️ Beautiful 90s inspired table layouts
⭐️ Cast member data requested from API
⭐️ D3 rendered SVG world map
⭐️ @ngrx/store + router-state + entity
😔 Client render only
@phenomnominal
2019
document is not defined
@phenomnominal
2019

How do we run this on the server?

@phenomnominal
2019
Run your Angular application anywhere...
https://www.youtube.com/watch?v=_trUBHaUAR0
@phenomnominal
2019
Angular
Universal
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
Four patterns

@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.
Fall back to the actual DOM
Optional injection token!
Use the optional value
@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...
Set up globals
Require troublesome module
Clean up after
Directly check for window
@phenomnominal
2019
declare var global: any
if (typeof window === 'undefined') {
(global).window = {};
(global).requestAnimationFrame = () => void 0;
require('../src/cursor.js');
delete (global).window;
delete (global).requestAnimationFrame;
}
Check the platform...

As a last resort (please!)
@phenomnominal
2019
If you really must...
Injectable PLATFORM_ID token
Use the method for the specific platform
Check the 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();
}
}
One implementation for a specific environment
@phenomnominal
2019

Add @Optional injection annotation
Trusty if statement
Check for the presence of an injected service
Just turn it off
@phenomnominal
2019
@Component({
// ...
})
export class MapComponent implements AfterViewInit {
@ViewChild('map', { static: true }) public map: ElementRef;
constructor (
@Optional() private _mapService: MapService
) { }
public ngAfterViewInit (): void {
if (!this._mapService) {
return;
}
this._init();
}
}
Turn things off at a provider level
Provide null for specific platform
Corresponding injection token
@phenomnominal
2019
Just turn it off
@NgModule({
imports: [
// ...
],
bootstrap: [AppComponent],
providers: [
{ provide: VideoService, useValue: null }
]
})
export class AppServerModule { }
@phenomnominal
2019
Demo!
@phenomnominal
2019
@phenomnominal
2019
😕 data requested from API two times
⭐️ No errors!
😕 flickery re-render at after bootstrap
⭐️ Fast!
@phenomnominal
2019
Different functionality for each environment

The whole application runs twice.
This means duplicate API calls!
This call happens twice
@phenomnominal
2019
State Transfer
@Component({
// ...
})
export class AppComponent implements OnInit {
public castMember: CastMember;
constructor (
private _castMemberDataService: CastMemberDataService
) { }
public ngOnInit (): void {
this._castMemberDataService.getCastMember()
.subscribe(data => this.castMember = data);
}
}
Inject TransferState
Create a state key
Set the state on the first run
Re-use it
@phenomnominal
2019
State Transfer
import { TransferState, makeStateKey } from '@angular/platform-browser';
const CAST_MEMBER = makeStateKey('castMember');
@Component({
// ...
})
export class AppComponent implements OnInit {
public castMember: CastMember
constructor (
private _castMemberDataService: CastMemberDataService,
private _state: TransferState
) { }
public ngOnInit (): void {
this.castMember = this.state.get(CAST_MEMBER, null);
if (!this.castMember) {
this._castMemberDataService.getCastMember()
.subscribe(data => {
this.castMember = data;
this.state.set(CAST_MEMBER, data);
});
}
}
}
Or just transfer your whole store state!
One state key for your whole store
Read the whole store and serialise
@phenomnominal
2019
State Transfer
@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;
});
}
}
Use the same key on the client
Dispatch a new action with the whole serialised store
@phenomnominal
2019
State Transfer
Or just transfer your whole store state!
@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._transferState.remove(STATE_KEY);
this.store.dispatch(new TransferStateAction(state));
}
}
}
Use a simple cache layer to prevent duplicated calls
Split the GET efffect into two parts
Mark each API response with a cache time
Only GET if the cache has expired
@phenomnominal
2019
State Transfer
@Effect()
public getCastMemberDataEffect$ = this._actions.pipe(
ofType<GetCastMemberDataAction>(GetCastMemberDataAction.TYPE),
switchMap(action =>
this._store.pipe(
select(CastMemberDataSelectors.currentCastMemberData),
take(1),
filter(storeItem => this._cacheService.shouldFetch(storeItem, { expiryTime: 120000 })),
map(() => new GetCastMemberDataFromApi(action.options)
)
)
@Effect()
public getCastMemberDataFromApiEffect$ = this._actions.pipe(
ofType<GetCastMemberDataFromApi>(GetCastMemberDataFromApi.TYPE),
switchMap(action =>
this._castMemberDataService.getAsteroidData(action.options).pipe(
map(() => new GetCastMemberDataFromApiSucccess(action.options, response, new Data().toString())),
catchError(err => of(new GetCastMembersDataFromApiFail(actions, options, err)))
)
)
The state transfer data is inlined into the HTML response as a blob of JSON. This means...
Don't send too much data!
The whole store must be JSON serialisable!
@phenomnominal
2019
State Transfer
@phenomnominal
2019
Demo!
@phenomnominal
2019
One implementation for all environments
One implementation for a specific environment
Different implementations for each environment



Different functionality for each environment

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
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
It's very slow...
Bazel will fix it?
@phenomnominal
2019
WorkFlow/DX

So it's just a dumpster fire?
Lint rules for using DOM APIs
Lint rules for using isBrowser
Meta-reducer for unserialisable data
Test for too much serialised data
Universal E2E tests
Fix it before you break it
@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








@phenomnominal
2019

myspace/phenomnominal
geocities/phenomnomnominal
Angular Universal in the Real World
By Craig Spence
Angular Universal in the Real World
Craig Spence - Angular Denver 2019 - Angular Universal in the Real World
- 3,138