Loading...
There was a lot of criticism regarding AngularJS and the first generation of SPA frameworks that starting to get popular on 2009.
One of the main issues developers had with those frameworks are the slow initial load and the poor Search Engine Optimization or SEO.
Let's examine how the new generation frameworks: react and Angular managed to solve those problems
So the first generation spa frameworks when you typed the url of the app, the server would return an html similar to this
<html>
<body>
<script src="the-spa.js"></script>
</body>
</html>
so the html we got was pretty much empty, and the dude responsible to show us the screen was the js file.
So we had to download the html, then download the js (which was usually large sized). then run the js, then the screen will show.
This process was horribly slow on mobile phones especially with the old frameworks which had poor performance.
Also it's considered poor seo to let the google bot render the js and crawl our site from the rendered js. The bot rendering can be slow and not accurate and it will damage our search rating.
The new generation frameworks are universal meaning the server side can also run the JS code of the frameworks .
This means that when we create our angular component, the server can understand the code and create html of our component.
This means that the initial load of our app can now give us full HTML of our screen. So we will load the full HTML and see our screen, at the bottom of the body tag there will be a call to download our script, we will run our script and switch the current screen with the screen from our rendered js.
So even mobile phones with slow connection will be able to get a fast HTML and will see the screen fast.
So let's try and create a new @angular/cli application, and create server side rendering for our cli project using .NET and Visual Studio as IDE.
We are going to create a new ASP .NET MVC project.
inside the project we are going to start a new project using @angular/cli: ng new ClientApp
@angular/cli can also generate a bootstrap files for the server side. You can create the universal project by typing in the terminal: ng g universal ssr
You will also need to install needed packages for server side rendering, the cli command already modified the package.json so just type: npm install
In our visual studio we are going to add the files we created to our solution. We are also going to remove the node_modules folder from the solution otherwise visual studio will try to compile the cs files in there.
So what did @angular/cli did when we ran the command: ng g universal <project-name>
.
It created the following files
@angular/cli also updated the following files:
This file is the entry point file of our server.
We will need to set this file to work with our .NET server.
To do this we will first have to install a package called @nguniversal/aspnetcore-engine
The job of this package is to take our server module, the request and the root component selector and create an HTML from that, and do it in a way that .NET apps can run that js code.
Install the package with npm: npm install @nguniversal/aspnetcore-engine --save
Another package you will need to install is: npm install aspnet-prerendering --save
So our job is to take the main.server.ts and fill in the blanks which is to create a function, passing the function arguments that describe the request, this function needs to be run by our asp.net code.
For this to work we have to install a nugget called: Microsoft.AspNetCore.SpaServices this package contains helpers for building single page applications on ASP.NET MVC Core.
It gave me an error when first tried to install the SpaServices package, so I had to change the target framework to .NET Framework 4.7.
And of course you have to make sure that you have node installed on your system that is greater then version 6.
Another nuget package you will need will be: Microsoft.AspNetCore.SpaServices.Extensions
Let's start with running a simple node code and display it in our homescreen.
We are going to run the js code from inside the HomeController.cs
Inside the folder where the HomeController.cs is located, create a new JS file called hello.js with the following code:
var prerendering = require('aspnet-prerendering');
module.exports = prerendering.createServerRenderer(function(params) {
return new Promise(function(resolve, reject) {
resolve({
html: "<h1>This is from node code</h1>"
})
})
});
you will need to run: npm init --yes && npm install aspnet-prerendering --save in the folder where your HomeController.cs is located.
Code that we want to run with our .NET requires us to wrap that code with createServerRenderer located in the aspnet-prerendering package.
The createServerRenderer should return the result using a Promise, and calling the Promise resolve method.
Our C# code will call the function in the createServerRenderer and we can use the params argument to pass data from the C# server side to our JS code that runs on the server.
The params we can pass have the following structure:
export interface BootFuncParams {
location: any;
origin: string;
url: string;
baseUrl: string;
absoluteUrl: string;
domainTasks: Promise<any>;
data: any;
}
We can also send data from the JS code back to the C# code by calling the resolve of the Promise.
We can pass the following data structure back to our C# code:
export interface RenderToStringResult {
html: string;
statusCode?: number;
globals?: {
[key: string]: any;
};
}
export interface RedirectResult {
redirectUrl: string;
}
So when we want to create a JS code that will be run by our C# we need to return from the JS code an export default of createServerRenderer passing the function that will be called from C# and passing params to it, our code will run and we will resolve the response back.
In the file that we created we return an H1 tag with a message we would like to display in the rendered page from the server.
We can register the NodeServices and the SPA services in the Startup.cs file which will look like this:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ssr2
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddNodeServices();
services.AddSpaPrerenderer();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
}
notice that we added the node services and spa prerender services so we can later inject them in our controller.
Now in our HomeController.cs modify the Index to run our JS code.
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SpaServices.Prerendering;
using ssr2.Models;
namespace ssr2.Controllers
{
public class HomeController : Controller
{
public async Task<IActionResult> Index([FromServices] ISpaPrerenderer prerenderer)
{
var result = await prerenderer.RenderToString("./Controllers/home.js");
ViewData["PrerenderedHtml"] = result.Html;
return View();
}
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
public IActionResult Contact()
{
ViewData["Message"] = "Your contact page.";
return View();
}
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
}
We just change the Index method to be async, injected the ISpaPrerenderer service, and used it to run our JS code.
OK so we managed to run JS code with our ASP.NET and pass HTML from the js code back to our server to be displayed.
We already created our angular application using the cli, and we also used the cli to generate the universal app structure.
What we need to to modify the main.server.ts so the code will be runnable on our C# side.
We will use ngAspnetCoreEngine to render our ServerModule this will return a promise with the response, and we can pass the html back to the server.
so a simplified main.server.ts will now look like this.
import 'zone.js/dist/zone-node';
import { enableProdMode } from '@angular/core';
import { environment } from './environments/environment';
import { AppServerModule } from './app/app.server.module';
import { createServerRenderer } from 'aspnet-prerendering';
import { ngAspnetCoreEngine, IEngineOptions } from '@nguniversal/aspnetcore-engine';
if (environment.production) {
enableProdMode();
}
export default createServerRenderer((params) => {
console.log('!!!!!!');
console.log(JSON.stringify(params));
const setupOptions: IEngineOptions = {
appSelector: '<app-root></app-root>',
ngModule: AppServerModule,
request: params
};
return ngAspnetCoreEngine(setupOptions).then((response) => {
return {
html: response.html
}
})
});
The options we can transfer the ngAspnetCoreEngine are:
export interface IEngineOptions {
appSelector: string;
request: IRequestParams;
url?: string;
document?: string;
ngModule: Type<{}> | NgModuleFactory<{}>;
providers?: StaticProvider[];
}
Notice also that we need to pass a request to the IEngineOptions and we need to pass it from the C# code as our params of the function inside the createServerRenderer.
This data should be from the C# code, and the requst looks like this IRequestParams:
export interface IRequestParams {
location: any;
origin: string;
url: string;
baseUrl: string;
absoluteUrl: string;
domainTasks: Promise<any>;
data: any;
}
We can't run main.server.ts code directly in our C# code, we have to first transform the typescript code to JS and run the compiled JS.
In the terminal type:
ng build --app ssr
the compiled js filename by default is: main.bundle.js so let's try and run this file with our C# code.
Modify the HomeController.cs like so:
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SpaServices.Prerendering;
using ssr2.Models;
namespace ssr2.Controllers
{
public class HomeController : Controller
{
public async Task<IActionResult> Index([FromServices] ISpaPrerenderer prerenderer)
{
IRequest request = new IRequest();
request.url = this.Request.Path;
var result = await prerenderer.RenderToString("./ClientApp/dist-server/main.bundle.js", null, request);
ViewData["PrerenderedHtml"] = result.Html;
return View();
}
public IActionResult About()
{
ViewData["Message"] = "Your application description page.";
return View();
}
public IActionResult Contact()
{
ViewData["Message"] = "Your contact page.";
return View();
}
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
}
IRequest is a simple model where i can put extra data to be passed in the params
using System;
namespace ssr2.Models
{
public class IRequest
{
public String url { get; set; }
}
}
Try and launch your app and you should see the cli starter screen displayed as a full rendered HTML page
What happens if you install a module that can only run on the browser side.
Let's try and install ng-lottie.
Install the package using npm: npm install --save ng-lottie
Modify the app.module.ts to contain the new module in the imports array
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import {LottieAnimationViewModule} from 'ng-lottie'
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }),
LottieAnimationViewModule.forRoot()
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
build your ssr project again with: ng build --app ssr and try to run the app again on the server you should see an exception with the server side rendering
We need to seperate between modules that can only run on the browser like Lottie. For this case we need to seperate between the module that runs on the browser and the module that run on the server.
Create a new file called app.browser.module.ts with the following code:
import { NgModule } from '@angular/core';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { PrebootModule } from 'preboot';
import { LottieAnimationViewModule } from 'ng-lottie';
@NgModule({
bootstrap: [AppComponent],
imports: [
PrebootModule.withConfig({ appRoot: 'app-root' }),
AppModule,
LottieAnimationViewModule.forRoot()
],
providers: [
]
})
export class AppBrowserModule { }
Also we will need to change the main.ts which is the entry point for the browser to launch the browser module and the the app module.
The app module contains shared code and everything inside the app module should be runnable by server and browser. So our main.ts should look like so:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppBrowserModule } from './app/app.browser.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppBrowserModule)
.catch(err => console.log(err));
});
also make sure to remove the lottie module from the app module, we transfered that module to the browser module and it should not run with the server.
The app will now work but we have another problem, what if we want to use a component that is declared in the Lottie module.
obviously we won't be able to use components from the root module, so for this to work we will have to create that component dynamically.
First since the component we want to use is not exposed we will need to create a wrapper arround that component.
create a file called lottie-wrapper.component.ts with the following code:
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'nz-lottie-view',
template: `
<lottie-animation-view
[options]="options"
[width]="width"
[height]="height"
(animCreated)="handleAnimation($event)">
</lottie-animation-view>
`
})
export class LottieWrapperComponent {
@Input() options: any;
@Input() width: any;
@Input() height: any;
@Output() animCreated: EventEmitter<any> = new EventEmitter();
handleAnimation = (event) => {
this.animCreated.emit(event);
}
}
now add this component to the browser module and we will use this component instead of the lottie component.
We will also need to add this component to the entry components in that module since we will dynamically create this component in other modules.
This will allow you to use a component defined in a parent module in child modules.
the app.browser.module.ts now looks like so
import { NgModule } from '@angular/core';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { PrebootModule } from 'preboot';
import { LottieAnimationViewModule } from 'ng-lottie';
import { LottieWrapperComponent } from './lottie-wrapper.component';
@NgModule({
declarations: [LottieWrapperComponent],
bootstrap: [AppComponent],
imports: [
PrebootModule.withConfig({ appRoot: 'app-root' }),
AppModule,
LottieAnimationViewModule.forRoot()
],
providers: [
],
entryComponents: [LottieWrapperComponent]
})
export class AppBrowserModule { }
Now we will create another component in our app module which creates animation based on the demo in the lottie github
so create the file demo.component.ts with the following code
import {Component, ComponentFactoryResolver, OnInit, ViewChild, ViewContainerRef, Optional, Inject, PLATFORM_ID, Injector} from '@angular/core';
import { LottieWrapperComponent } from './lottie-wrapper.component';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'lottie-animation-view-demo-app',
template: `
<ng-template #placeholder></ng-template>
<div id="player">
<p>Speed: x{{animationSpeed}}</p>
<div class="range-container">
<input #range type="range" value="1" min="0" max="3" step="0.5"
(change)="setSpeed(range.value)">
</div>
<button (click)="stop()">stop</button>
<button (click)="pause()">pause</button>
<button (click)="play()">play</button>
</div>`
})
export class DemoComponent implements OnInit {
@ViewChild('placeholder', {read: ViewContainerRef}) dynamicComponentsPlaceholder: ViewContainerRef;
public lottieConfig: Object;
private anim: any;
public animationSpeed: number = 1;
constructor(@Inject(PLATFORM_ID) private _platformId: Object, private _di: Injector) {
this.lottieConfig = {
path: '../assets/pinjump.json',
autoplay: true,
loop: true
};
}
public ngOnInit() {
if (isPlatformBrowser(this._platformId)) {
const componentFactory = this._di.get(ComponentFactoryResolver);
const componentFactory1 = componentFactory.resolveComponentFactory(LottieWrapperComponent);
let componentRef = this.dynamicComponentsPlaceholder.createComponent<LottieWrapperComponent>(componentFactory1);
componentRef.instance.animCreated.subscribe((event) => {
this.handleAnimation(event);
})
componentRef.instance.options = this.lottieConfig;
componentRef.instance.height = 300;
componentRef.instance.width = 600;
}
}
handleAnimation(anim: any) {
this.anim = anim;
}
stop() {
this.anim.stop();
}
play() {
this.anim.play();
}
pause() {
this.anim.pause();
}
setSpeed(speed: number) {
this.animationSpeed = speed;
this.anim.setSpeed(speed);
}
}
So we are dynamically creating the component that can only be run in the browser and therfor is located in the browser module, don't forget to register the demo component in the app module (not in the browser module) and also place the selector of this component in the component template of the app component.
To summerize this extremly complex lesson...
Server side rendering is necessary for fast initial load and also SEO.
We can run JS code using our .NET project.
We can use angular cli to create universal application and run it with our .NET code.
We will create seperation of modules and create a browser module which will be bootstrapped by our browser as well as server module which will be bootstrapped by our server side.