Analysing the Angular 2 Universal Starter Kit
Hi folks,
Today we are going to dive into the Angular 2 Universal Starter Kit. If you have never heard about it it's ok, your boss is not going to fire you tomorrow because of that (not yet). There are other Angular 2 Universal seed projects in the wild but this one is complete and very simple to start with, furthermore it's been created by the Angular Class team, which is composed of some Angular official contributors and they have great humor (ex: this post's thumbnail).
What is Angular Universal ?
Single-Page Applications (SPA) are great, they offer better performance and better User Experience, however they come with a price.
- Google bots are not great at indexing SPA apps (they are improving though, other search engines like Baidu are doing worse) and you will have a hard time optimising your SEO there.
- It takes a lot of time to initially load the beast.
Angular Universal solves those issues by allowing server-side rendering (SSR)!
I wouldn't do a better job then them describing their technology's workflow so head there to see how the magic works.
Now let's get our hands dirty!
Architecture
The architecture can be a little bit complicated at the beginning but just like the bicycle, once you get it, it's easy.
Basically it's a separation between client-side (app.browser.module.ts) and server-side (app.node.module.ts).
In the application, those two files look the same at the beginning but as the app evolves, they will diverge. In app.browser.module.ts, you can only import client-side modules like the Angular 2 Bootstrap module for example whereas in app.node.module.ts you can only import server-side modules.
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { RouterModule } from "@angular/router";
import { UniversalModule } from "angular2-universal";
import { App, Home } from "./app/app";
@NgModule({
bootstrap: [App],
declarations: [App, Home],
imports: [
UniversalModule, // NodeModule, NodeHttpModule, and NodeJsonpModule are included
FormsModule,
RouterModule.forRoot([{ path: "", component: Home, pathMatch: "full" }])
]
})
export class MainModule {}
The only difference with app.browser.module.ts will be:
imports: [
.
.
.
UniversalModule, // BrowserModule, HttpModule, and JsonpModule are included
.
.
.
]
Universal Module will be composed of different modules that only work in the browser. If you create a new Angular 2 component, don't forget to add it to the app.browser.module.ts!
The keystone of the Angular 2 Universal application is the server.ts file:
// the polyfills must be the first thing imported in node.js
import "angular2-universal-polyfills";
import * as path from "path";
import * as express from "express";
import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser";
// Angular 2
import { enableProdMode } from "@angular/core";
// Angular 2 Universal
import { createEngine } from "angular2-express-engine";
// App
import { MainModule } from "./app.node.module.ts";
// enable prod for faster renders
enableProdMode();
const app = express();
const ROOT = path.join(path.resolve(__dirname, ".."));
// Express View
app.engine(".html", createEngine({}));
app.set("views", __dirname);
app.set("view engine", "html");
app.use(cookieParser("Angular 2 Universal"));
app.use(bodyParser.json());
// Serve static files
app.use(
"/assets",
express.static(path.join(__dirname, "assets"), { maxAge: 30 })
);
app.use(express.static(path.join(ROOT, "dist/client"), { index: false }));
import { serverApi } from "./backend/api";
// Our API for demos only
app.get("/data.json", serverApi);
function ngApp(req, res) {
res.render("index", {
req,
res,
ngModule: MainModule,
preboot: false,
baseUrl: "/",
requestUrl: req.originalUrl,
originUrl: "http://localhost:3000"
});
}
// Routes with html5pushstate
// ensure routes match client-side-app
app.get("/", ngApp);
app.get("/about", ngApp);
app.get("/about/*", ngApp);
app.get("/home", ngApp);
app.get("/home/*", ngApp);
app.get("*", function(req, res) {
res.setHeader("Content-Type", "application/json");
var pojo = { status: 404, message: "No Content" };
var json = JSON.stringify(pojo, null, 2);
res.status(404).send(json);
});
// Server
let server = app.listen(process.env.PORT || 3000, () => {
console.log(`Listening on: http://localhost:${server.address().port}`);
});
Quite a big file isn't it? Let's focus on what is new here.
Angular Universal
// Angular 2
import { enableProdMode } from "@angular/core";
// enable prod for faster renders
enableProdMode();
Let's start by enabling the production mode. In this mode only one cycle of change detection is done. So your app is basically two times faster.
We then use the angular2-express-engine in order to render our Angular 2 app:
import { createEngine } from "angular2-express-engine";
app.engine(".html", createEngine({}));
Routing wise
Angular Class good guy team added a fake server API for us, but you can create your own too:
import { serverApi } from "./backend/api";
// Our API for demos only
app.get("/data.json", serverApi);
For our app routes:
// Routes with html5pushstate
// ensure routes match client-side-app
app.get("/", ngApp);
app.get("/about", ngApp);
app.get("/about/*", ngApp);
app.get("/home", ngApp);
app.get("/home/*", ngApp);
As the comments say, add one route for each client's routes.
Configuration
Finally the last important part:
// App
import { MainModule } from "./app.node.module";
function ngApp(req, res) {
res.render("index", {
req,
res,
ngModule: MainModule,
preboot: false,
baseUrl: "/",
requestUrl: req.originalUrl,
originUrl: "http://localhost:3000"
});
}
We get our server-side main node module and then use it in ngApp every time that our client side routes get hit.
The Preboot Case
This preboot option here is very important. By default this option is set to false. Typo from the Angular Universal team? Too many beers at Angular Beers?
Before Angular 2 kicks in, the user can do many actions. What would happen if we don't stock those actions? They will just disappear once the client is ready.
Let's have a look if this feature is broken (which would explain why it's not used by default) or not.
First thing we log the events stocked into preboot_browser.ts:
console.log("events", events);
// replay all the events from the server view onto the client view
events.forEach(function(event) {
return replayEvent(appData, event);
});
Then we add an input into our app.ts:
@Component({
selector: 'home',
template: `<input type="text" [(ngModel)]="name">{{name}}`
})
And we add a timeout in our client.ts:
document.addEventListener("DOMContentLoaded", () => {
console.log("before timeout");
setTimeout(function() {
console.log("in timeout");
platformRef.bootstrapModule(MainModule);
}, 10000);
});
Finally we set the preboot option to true.
The result:
As you can see, the events are stocked and the text typed is present.
If we set preboot to false, the text that is typed by the user will not be transferred to Angular 2 and will just disappear.
Conclusion
Google bots are great but it will take a lot of time in order to be fully SEO SPA friendly. That's why people dedicate time in order to fill this gap instead of waiting years.
Angular 2 Universal is a mix between client and server side that can be a bit hard to understand at the beginning, just remember to put your client modules in app.browser.module and your server modules in app.node.module.
Finally use preboot to catch events from the user before Angular is ready, even though it's not set by default to true, it's working as it should.
We have seen in this tuts an implementation with a JavaScript backend, however, this can be done with other technologies like JAVA for example.