Když vytváříte web pomocí javascriptových frameworků, můžete se a pravděpodobně se také někdy v budoucnu dostanete do potíží s renderováním webu v prostředích kde javascript nefunguje. Vezměme si například třeba Facebookové příspěvky, když přidáte příspěvek na Facebook, facebook si z nich veme věci jako meta tagy z hlavičky obsahující titulek, popisky, nebo obrázky ze stránky. Tyhle věci ale například při používání Angularu vykresluje právě angular a dělá to teprve co se spustí javascript.
Při psaní Rozepiš jsem na tenhle problém narazil taky a jako řešení jsem našel knihovnu Angular Universal. Ve zkratce, knihovna používá jednoduchý skript, který je spuštěn v node.js na serveru, tenhle skript nejdříve na serveru vykreslí stránku, kterou uživatel zamýšlel spustit a její obsah pošle jako odpověd při dotazu na stránku. Ta se nejdříve zobrazí a tím se vyřeší problém například právě s Facebookovými příspěvky nebo jinými knihovnami používajícími html stránky bez spouštění javascriptu. Potom teprve, pokud je to možné, se v prohlížeči spustí javascriptové skripty a načte se dynamická aplikace.

Jak na to

Určitě jste si ale tenhle článek nepřišli přečíst abych vám tady vyprávěl o svých problémech ale spíš o tom jak jsem je vyřešil. Takže vám tady teď taky ukážu jak na to, jak to použít a čeho se vyvarovat při používání. Je totiž pár věcí které na serveru nefungují a nedají se použít, ale i s těmito omezeními se mi Angular Universal skvěle osvědčil. Ještě než začnu ale řeknu, že svoji aplikaci jsem upravoval podle návodu zde a že budu tento návod taky popisovat. Dodám do něj ale pár svých poznámek a změn, na které jsem narazil a které by vám mohly pomoci při nějakých chybách a podobně.

Pokud jste ještě vytvářet svůj projekt nezačali, je tady ještě možnost ve výše uvedeném repozitáři si stáhnout aplikaci, která je rovnou připravena na rozjezd v prohlížeči i na serveru, tady budu popisovat jak  na to, když už, jako já, máte aplikaci vytvořenou a  potřebujete do ni angular universal přidat.

Ze všeho nejdříve si tedy do projektu nainstalujeme knihovny @angular/platform-server @nguniversal/module-map-ngfactory-loader a ts-loader. První z nich je potřeba jednoduše pro rozjetí angularu na serveru, druhý pro používání lazy-loading komponent a třetí pro práci s webpackem, který budeme používat při buildování serverového scriptu. Celý příkaz poté vypadá takhle:

npm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader

Upravujeme Angularové moduly

Dál budeme pokračovat tím, že si aplikaci upravíte pro použití na serveru, začněme tím, že v importech v AppModule, kde se importuje BrowserModule k němu přidáme .withServerTransitions()

@NgModule({
  bootstrap: [AppComponent],
  imports: [
    // Add .withServerTransition() to support Universal rendering.
    // The application ID can be any identifier which is unique on
    // the page.
    BrowserModule.withServerTransition({appId: 'my-app'}),
    ...
  ],
 
})
export class AppModule {}

Dále pak vedle app.module.ts vytvoříme nový soubor s názvem app.server.module.ts to bude náš hlavní modul pro serverovou část aplikace, modul bude importovat náš hlavní AppModule, ServerModule z @angular/platform-server a ModuleMapLoaderModule pro práci s lazy-loading komponentami. Na konci ještě přidáme bootstrap naší hlavní komponenty aplikace. Celý kód app.server.module.ts bude tedy vypadat následovně:

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({
  imports: [
    // The AppServerModule should import your AppModule followed
    // by the ServerModule from @angular/platform-server.
    AppModule,
    ServerModule, 
    ModuleMapLoaderModule // <-- *Important* to have lazy-loaded routes work
  ],
  // Since the bootstrapped component is not inherited from your
  // imported AppModule, it needs to be repeated here.
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Vytváříme soubory pro sestavování serverové části

Poté co jsme vytvořily soubory potřebné pro angularovou aplikaci, se nyní zaměříme na soubory pro její vybuildování, k tomu budeme potřebovat nový main.ts, konkrétně tedy main.server.ts soubor a tsconfig soubor pro server, pro nás tedy tsconfig.server.json.

Soubor main.server.ts bude ve stejné složce jako soubor main.ts a bude dělat pouze jednu věc a to tu, že exportuje AppServerModule ze souboru app.server.module.ts, kód tedy bude vypadat následovně:

export { AppServerModule } from "./app/app.server.module";

A druhý soubor, který bude ve stejné složce, tedy soubor tsconfig.server.json, bude vypadat následonvě:

{
    "extends": "../tsconfig.json",
    "compilerOptions": {
        "outDir": "../out-tsc/app",
        "baseUrl": "./",
        "module": "commonjs",
        "types": []
    },
    "exclude": [
        "test.ts",
        "**/*.spec.ts"
    ],
    "angularCompilerOptions": {
        "entryModule": "app/app.server.module#AppServerModule"
    }
}

Jediná změna oproti tsconfigu naší aplikace, tedy souboru tsconfig.app.json jsou ty, že module byl nastaven na commonjs a byl vytvořili jsme vstupní modul pro naši aplikaci, který jsme nastavili na AppServerModule v souboru app/app.server.module.js.

Upravujeme soubor pro angular cli

Po vytvoření souborů pro sestavení naší serverové aplikace, ještě musíme říct Angularu, konkrétně tedy Angular CLI, že má kromě aplikace pro prohlížeče, sestavit také aplikaci pro server. S tím nám pomůže soubor .angular-cli.json v základní složce našeho projektu. V tomto souboru si všimněme pole apps, toto pole v současnosti obsahuje pouze jednu aplikaci, naši aplikaci, kterou sestavíme do složky dist a spustíme na webu jako javascriptovou aplikaci. K této aplikaci nyní přibude ještě jedna a to právě aplikace pro naši serverovou část.

První věcí kterou však musíme udělat, je upravit, kam se bude sestavovat naše aplikace pro prohlížeče. V současné době se totiž sestavuje do složky dist a tu budeme chtít dále použít pro obě aplikace. Cestu outDir v naší aplikaci tedy změníme z dist na dist/browser. Dále pak vezmeme celou naši aplikaci v apps a překopírujeme ji hned za ni, tím v poli apps vytvoříme druhou aplikaci, která je nyní však úplně stejná jako první a proto do ni musíme udělat pár změn. První změnou bude změna outDir na dist/server, tím si naši severovou aplikaci uložíme také do složky dist vedle naší aplikace pro prohlížeč. Dále změníme main z main.ts na main.server.ts, změníme tsconfig z tsconfig.app.json na tsconfig.server.json a jako poslední přidáme novou položku platform s hodnotou server. Kompletní přehled pole apps v mém .angular-cli.json můžete vidět níže.

"apps": [
        {
            "root": "src",
            "outDir": "dist/browser",
            "assets": [
                "assets",
                "favicon.ico"
            ],
            "index": "index.html",
            "main": "main.ts",
            "polyfills": "polyfills.ts",
            "test": "test.ts",
            "tsconfig": "tsconfig.app.json",
            "testTsconfig": "tsconfig.spec.json",
            "prefix": "app",
            "styles": [
                "styles.scss"
            ],
            "scripts": [],
            "environmentSource": "environments/environment.ts",
            "environments": {
                "dev": "environments/environment.ts",
                "prod": "environments/environment.prod.ts"
            }
        },
        {
            "root": "src",
            "outDir": "dist/server",
            "assets": [
                "assets",
                "favicon.ico"
            ],
            "index": "index.html",
            "main": "main.server.ts",
            "tsconfig": "tsconfig.server.json",
            "testTsconfig": "tsconfig.spec.json",
            "prefix": "app",
            "styles": [
                "styles.scss"
            ],
            "scripts": [],
            "environmentSource": "environments/environment.ts",
            "environments": {
                "dev": "environments/environment.ts",
                "prod": "environments/environment.prod.ts"
            },
            "platform": "server"
        }
    ]

Buildujeme Angularovou aplikaci pro prohlížeč i server

S nastavením, které jsme právě udělali, bysmě měli být bez problému schopni vytvořit aplikaci jak pro prohlížeč, tak i pro serverovou část. Oboji se dá udělat pomocí jednoduchých Angularových příkazů, jak už jistě víte, angular cli pomocí přikazu ng build –prod vybuilduje produkční verzi aplikace. Pro nás to teď znamená, že nám vybuilduje produkční verzi aplikace pro prohlížeč. Abychom vybuildovali i verzi pro server, musíme příkaz spustit ještě jednou, ale nyní musíme specifikovat, kterou aplikaci chceme vybuildovat, tedy aplikaci v poli apps, která je na indexu 1, náš příkaz bude tedy vypadat ng build –prod –app 1 –output. Pro přehled ještě uvádím oba příkazy níže

#Build aplikace pro prohlížeč
ng build --prod
#Build aplikace pro server
ng build --prod --app 1 --output

Vytváříme Express Server na node.js pro spuštění naší serverové části

Teď když máme všechno připraveno ke spuštění aplikace nám ji už stačí jenom spustit, k tomu si vytvoříme jednoduchý skript pro node.js, který spustí naší aplikaci a zároveň uživatelům předá i data potřebná pro spuštění v browseru. K tomu nám poslouží Express engine modul napsaný pro Angular Universal. Ten si nainstalujeme spuštěním následujícího skriptu:

npm install --save @nguniversal/express-engine

Teď už nám stačí pouze jednoduchý skript, který nám vykreslí html stránku a pošle ji  uživateli při requestu, kód serveru uložíme do základní složky projektu do souboru server.ts a může vypadat následovně:

import { enableProdMode } from "@angular/core";
import { ngExpressEngine } from "@nguniversal/express-engine";
import "reflect-metadata";
import "zone.js/dist/zone-node";
 
import * as express from "express";
import * as proxy from "express-http-proxy";
import { readFileSync } from "fs";
import { join } from "path";
 
// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();
 
// Express server
const app: any = express();
 
const PORT: any = process.env.PORT || 4000;
const DIST_FOLDER: any = join(process.cwd(), "dist");
 
// Our index.html we'll use as our template
const template: any = readFileSync(join(DIST_FOLDER, "browser", "index.html")).toString();
 
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const { AppServerModuleNgFactory, LAZY_MODULE_MAP }: any = require("./dist/server/main.bundle");
 
// Express Engine
// Import module map for lazy loading
import { provideModuleMap } from "@nguniversal/module-map-ngfactory-loader";
 
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
app.engine("html", ngExpressEngine({
    bootstrap: AppServerModuleNgFactory,
    providers: [
        provideModuleMap(LAZY_MODULE_MAP)
    ]
}));
 
app.use("/api", proxy("http://server", {
    proxyReqPathResolver: (req): string => {
        const url: string = require("url").parse(req.url).path;
        return "/api" + url;
    }
}));
 
app.set("view engine", "html");
app.set("views", join(DIST_FOLDER, "browser"));
 
// Server static files from /browser
app.get("*.*", express.static(join(DIST_FOLDER, "browser"), {
    maxAge: "1y"
}));
 
// ALl regular routes use the Universal engine
app.get("*", (req, res) => {
    res.render("index", { req });
});
 
// Start up the Node server
app.listen(PORT, () => {
    console.log(`Node Express server listening on http://localhost:${PORT}`);
});

Výše zmíněný kód spustí server, který, jak jsem již psal pošle podle url vyrenderovanou angularovou stránku do odpovědi v requestu a zároveň dokáže poskytovat statické javascriptové soubory pro stahování dalších souborů pro spuštění browserové javascriptové aplikace. Dále si můžete všimnout kódu

app.use("/api", proxy("http://server", {
    proxyReqPathResolver: (req): string => {
        const url: string = require("url").parse(req.url).path;
        return "/api" + url;
    }
}));

Ten vám bude pomůže, když budete mít k aplikaci připojené nějaké api například na jiném serveru a budete se k němu chtít dostat, klasicky přesměruje všechny requesty příchozí z url začínající /api na jinou stránku, zde konkrétně na http://server, nacházející se v jednom z dockerových kontejnerů. K tomuto mi výborně posloužila knihovnsa express-http-proxy, která se dá stáhnout pomocí npm konkrétně příkazem:

npm install --savsSe express-http-proxy

 Sestavujeme a spouštíme server

S posledním skriptem, který jsme napsali jsme kompletně připraveni spustit server a aplikace, před tím ale ji musíme ještě vybuildovat a k tomu nám pomůže už opravdu poslední soubor který budeme psát. Vzhledem k tomu, že tento server budeme buildovat pomocí webpacku, uděláme si k jeho konfiguraci nový soubor, nazvaný webpack.server.config.js. Ten potom použijeme jako konfigurační soubor, pomocí kterého sestavíme server.

const path = require('path');
const webpack = require('webpack');
 
module.exports = {
  entry: {  server: './server.ts' },
  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  // this makes sure we include node_modules and other 3rd party libraries
  externals: [/(node_modules|main\..*\.js)/],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  },
  plugins: [
    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for "WARNING Critical dependency: the request of a dependency is an expression"
    new webpack.ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      path.join(__dirname, 'src'), // location of your src
      {} // a map of your routes
    ),
    new webpack.ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      path.join(__dirname, 'src'),
      {}
    )
  ]
}

V souboru exportujeme modul, kterému dáme jaký soubor má vzít jako vstupní, jaké soubory má dále hledat a odkud má brát knihovny, poté mu řekneme, kam má vydat konečnou verzi zbuildovaného projektu, pro nás to tedy znamená [name].js což je pro nás server.js ve složce dist. Dále mu pak řekneme, jaké pluginy má použít pro úpravu souborů, pokud to budeme potřebovat. Tyto základní pluginy jsou nastaveny pro funkci angularu na serveru, v další části se o nich ještě zmíním při zmínce o chybách, na které si musíme dát pozor.

Pro sestavení serverového skriptu použijeme příkaz:

webpack --config webpack.server.config.js --progress --colors

Pro zjednodušení sestavování aplikace a všech jejich částí vytvořil tým za angular universal pár skriptů, které si můžete uložit do package.json a které zjednoduší vačí práci, konkrétně se jedná o 4 skripty, dva na spuštění a sestavení a další dva, které ty dva právě používají, spuštěním build:dynamic sestavíte celou aplikace a pomocí serve:dynamic ji spustíte.

"build:dynamic": "npm run build:client-and-server-bundles && npm run webpack:server",
"serve:dynamic": "node dist/server.js",
"build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
"webpack:server": "webpack --config webpack.server.config.js --progress --colors"

Věci které se budou hodit

Potom co jsem vám tady popsal snad všechno co byste mohli potřebovat k vytvoření aplikace a určitě už na tom taky pracujete se vám stane, že vám aplikace nebude fungovat, nepůjde build nebo se nevykreslí na webu a vyhodí chybu. K tomu vám tady bude sloužit tahle část, která vám poradí s většinou problémů, se kterými sem se setkal, spousta z nich je popsaná v repozitáři na githubu v sekci Universal “Gotchas”  a nejdůležitější z nich je, že document, window, navigator a ostatní typy pro browser se na serveru nevyskytují a při použití budou házet chybu při vykreslování. Stejně tak není doporučeno manipulovat rovnou s nativeElement v angularu. Angular pro tohle vytvořil funkce isPlatformServer, a isPlatformBrowser, které nám řeknou zda jsme zrovna na serveru nebo v prohlížeči. Funkce lze použít konkrétně takhle:

import { PLATFORM_ID } from '@angular/core';
 import { isPlatformBrowser, isPlatformServer } from '@angular/common';
 
 constructor(@Inject(PLATFORM_ID) private platformId: Object) { ... }
 
 ngOnInit() {
   if (isPlatformBrowser(this.platformId)) {
      // Client only code.
      ...
   }
   if (isPlatformServer(this.platformId)) {
     // Server only code.
     ...
   }
 }

Další věcí, která se vám bude určitě hodit je, jak se poprat s typy, které jsem zmínil výše a které jsou již v knihovnách použity, to je věc, která se mi hodila už mnohokrát. K tomu jsem si ve složce src vytvořil složku server-mocks, kde skladuji upravené soubory knihoven, které tyto typy používají, většinou stačí řádek tohoto typu z knihovny smazat nebo zakomentovat, protože není nutné aby se na serveru tento kód provedl. Tento soubor můžeme následně v souboru webpack.server.config.js nahradit za náš upravený soubor a nemusíme tedy dělat složité věci a změny v knihovnách, tato změna totiž bude pouze na serveru a v prohlížeči se nezobrazí.

Ve výše zmíněném souboru použijeme v poli plugins Webpack.NormalModuleReplacementPlugin, který vezme soubor podle námi zadaného názvu a nahradí ho za jiný, v tomhle případě námi upravený soubor. Pole plugins s několika takto nahrazenými soubory může vypadat třeba následovně:

plugins: [
        new webpack.ContextReplacementPlugin(
            // fixes WARNING Critical dependency: the request of a dependency is an expression
            /(.+)?angular(\\|\/)core(.+)?/,
            path.join(__dirname, 'src'), // location of your src
            {} // a map of your routes
        ),
        new webpack.ContextReplacementPlugin(
            // fixes WARNING Critical dependency: the request of a dependency is an expression
            /(.+)?express(\\|\/)(.+)?/,
            path.join(__dirname, 'src'),
            {}
        ),
        new webpack.NormalModuleReplacementPlugin(
            /angular2-tinymce.component.js/,
            path.resolve(__dirname, "src/server-mocks/empty.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /tabset.component.js/,
            path.resolve(__dirname, "src/server-mocks/bootstrap-tabset-mock.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /perfect-scrollbar\/src\/js\/plugin\/instances.js/,
            path.resolve(__dirname, "src/server-mocks/perfect-scrollbar.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /ngx-perfect-scrollbar\/dist\/lib\/perfect-scrollbar.component.js/,
            path.resolve(__dirname, "src/server-mocks/perfect-scrollbar-component.js")
        )
    ]

Zásadně nedoporučuji nahrazovat soubory prázdnými javascriptovými soubory nebo je úplně mazat! To jsem totiž jednou udělal a pak jsem půl dne hledal chybu, samozřejmě jsem nemohl sestavit aplikaci protože hledala třídu, která byla v souboru, který byl nahrazen souborem prázdným.

Pokud se vám chyba ale stejně objeví a nebudete vědět co s ní nebo kde se vyskytuje, můžete si vytvořit sourceMap a pomocí ní si chybu vyhledat v typescriptových souborech a i v node_modules složce. Pro vytvoření sourceMap musíme udělat pár věcí, první je přidat do souboru webpack.server.config.js dva nové řádky. První je module.exports.outputs.sourceMapFilename = “[file].map”  a druhý je module.exports.devtool = “source-map”. Díky těmto nastavením vytvoří webpack soubor server.map vedle souboru server.js ve složce dist. Dále potřebujeme nainstalovat podporu pro source map pro node.js. Toho docílíme spuštěním následujícího skriptu:

npm install --save-dev source-map-support

A teď už jsme připraveni na spustit naši aplikaci s podporou sourceMap, toho docílíme následujícím skriptem:

node -r source-map-support/register dist/server.js

Poslední věcí, kterou tady ukážu bude celý soubor webpack.server.config.js, s přidanými pluginu a mapami.

const path = require('path');
const webpack = require('webpack');
 
module.exports = {
    entry: {
        // This is our Express server for Dynamic universal
        server: './server.ts'
    },
    target: 'node',
    resolve: { extensions: ['.ts', '.js'] },
    // Make sure we include all node_modules etc
    externals: [/(node_modules|main\..*\.js)/,],
    output: {
        // Puts the output at the root of the dist folder
        path: path.join(__dirname, 'dist'),
        filename: '[name].js',
        sourceMapFilename: '[file].map'
    },
    module: {
        rules: [
            { test: /\.ts$/, loader: 'ts-loader' }
        ]
    },
    plugins: [
        new webpack.ContextReplacementPlugin(
            // fixes WARNING Critical dependency: the request of a dependency is an expression
            /(.+)?angular(\\|\/)core(.+)?/,
            path.join(__dirname, 'src'), // location of your src
            {} // a map of your routes
        ),
        new webpack.ContextReplacementPlugin(
            // fixes WARNING Critical dependency: the request of a dependency is an expression
            /(.+)?express(\\|\/)(.+)?/,
            path.join(__dirname, 'src'),
            {}
        ),
        new webpack.NormalModuleReplacementPlugin(
            /angular2-tinymce.component.js/,
            path.resolve(__dirname, "src/server-mocks/empty.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /tabset.component.js/,
            path.resolve(__dirname, "src/server-mocks/bootstrap-tabset-mock.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /perfect-scrollbar\/src\/js\/plugin\/instances.js/,
            path.resolve(__dirname, "src/server-mocks/perfect-scrollbar.js")
        ),
        new webpack.NormalModuleReplacementPlugin(
            /ngx-perfect-scrollbar\/dist\/lib\/perfect-scrollbar.component.js/,
            path.resolve(__dirname, "src/server-mocks/perfect-scrollbar-component.js")
        )
    ],
    devtool: "source-map"
}

Doufám že vám tenhle článek pomůže v rozšiřování vašich zkušeností s angularem, sám se se s tím setkal poprvé, stejně tak se sestavováním pomocí vlastního souboru ve webpacku a dalšími věcmi, které jsem zde septal. Tak to snad pomůže I někomu jinému. Pokud byste měli jakékoliv připomínky nebo komentáře, určitě mi je napište do komentářů, rád se poučím dál :).

7 komentářů

Napsat komentář

Vaše emailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *