Angular Universal não assume a renderização no cliente
Atualmente, estou tentando adicionar Server Side Rendering (SSR) ao meu aplicativo angular, seguindo o guia oficial do angular para angular universal . Consegui configurar o servidor expresso e a resposta que recebo do servidor contém meu aplicativo, embora pareça um pouco estranho, como se algum CSS estivesse faltando, resultando em ícones FontAwesome sendo renderizados muito grandes, etc.
Por alguma razão, porém, o cliente / navegador nunca assume (re) renderização e o site permanece o mesmo que veio como resposta do servidor. Não é interativo e totalmente estático. Ao clicar em controles (exceto para links) ou elementos interativos, nada acontece.
Aqui você pode ver partes do App Shell / HTML .
Aqui está o tráfego da rede .
O package.json :
{
"name": "my-app",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"dev:ssr": "ng run my-app:serve-ssr",
"serve:ssr": "node dist/my-app/server/main.js",
"build:ssr": "ng build --prod && ng run my-app:server:production",
"prerender": "ng run my-app:prerender"
},
"private": true,
"dependencies": {
"@agm/core": "^1.1.0",
"@angular/animations": "^10.0.14",
"@angular/common": "^10.0.14",
"@angular/compiler": "^10.0.14",
"@angular/core": "^10.0.14",
"@angular/elements": "^10.0.14",
"@angular/forms": "^10.0.14",
"@angular/localize": "~10.0.14",
"@angular/platform-browser": "^10.0.14",
"@angular/platform-browser-dynamic": "^10.0.14",
"@angular/platform-server": "^10.0.14",
"@angular/router": "^10.0.14",
"@angular/service-worker": "^10.0.14",
"@fortawesome/angular-fontawesome": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-brands-svg-icons": "^5.14.0",
"@fortawesome/free-regular-svg-icons": "^5.14.0",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@ng-bootstrap/ng-bootstrap": "^7.0.0",
"@ng-select/ng-select": "^4.0.4",
"@nguniversal/express-engine": "^10.0.2",
"@webcomponents/webcomponentsjs": "^2.2.10",
"bootstrap": "^4.3.1",
"bufferutil": "^4.0.1",
"core-js": "^2.5.4",
"document-register-element": "^1.13.2",
"domino": "^2.1.6",
"express": "^4.15.2",
"html-webpack-plugin": "^2.30.1",
"intersection-observer": "^0.7.0",
"jquery": "^3.5.1",
"ng-lazyload-image": "^5.1.2",
"ng5-slider": "^1.1.14",
"ngx-cookie-service": "^2.2.0",
"ngx-infinite-scroll": "^7.1.0",
"popper.js": "^1.14.7",
"rxjs": "^6.6.2",
"tslib": "^2.0.0",
"utf-8-validate": "^5.0.2",
"webpack": "^4.44.1",
"xmlhttprequest": "^1.8.0",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.1000.8",
"@angular/cli": "^10.0.8",
"@angular/compiler-cli": "^10.0.14",
"@angular/language-service": "^10.0.14",
"@nguniversal/builders": "^10.0.2",
"@types/express": "^4.17.0",
"@types/jasmine": "~2.8.8",
"@types/jasminewd2": "~2.0.3",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~3.3.0",
"karma-jasmine-html-reporter": "^1.5.0",
"ngx-perfect-scrollbar": "^7.2.1",
"node-sass": "^4.14.1",
"protractor": "~7.0.0",
"ts-node": "~7.0.0",
"tslint": "~6.1.0",
"typescript": "^3.9.7"
}
}
O angular.json :
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"my-app": {
"i18n": {
"locales": {
"de-AT": {
"translation": "src/locale/messages.de-AT.xlf",
"baseHref": ""
}
}
},
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"watch": false,
"index": "src/index.html",
"outputPath": "dist/my-app/browser",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/apple-touch-icon-precomposed.png",
"src/robots.txt",
"src/sitemap.xml",
"src/assets",
"src/manifest.json"
],
"styles": [
"src/styles.scss"
],
"scripts": [
{
"input": "node_modules/document-register-element/build/document-register-element.js"
}
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"i18nMissingTranslation": "error",
"localize": [
"de-AT"
],
"optimization": true,
"outputHashing": "bundles",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "3mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "20kb"
}
],
"serviceWorker": true,
"ngswConfigPath": "src/ngsw-config.json"
},
"dev": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.dev.ts"
}
],
"aot": false,
"i18nMissingTranslation": "error",
"localize": [
"de-AT"
],
"optimization": false,
"outputHashing": "none",
"extractCss": true,
"namedChunks": true,
"extractLicenses": true,
"vendorChunk": false,
"budgets": [
{
"type": "initial",
"maximumWarning": "10mb",
"maximumError": "12mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "20kb"
}
],
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "my-app:build"
},
"configurations": {
"production": {
"browserTarget": "my-app:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "my-app:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles": [
"src/styles.scss"
],
"scripts": [],
"assets": [
"src/favicon.ico",
"src/robots.txt",
"src/sitemap.xml",
"src/assets",
"src/manifest.json"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json",
"src/tsconfig.server.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/my-app/server",
"main": "server.ts",
"tsConfig": "src/tsconfig.server.json"
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"sourceMap": false,
"optimization": true
}
}
},
"serve-ssr": {
"builder": "@nguniversal/builders:ssr-dev-server",
"options": {
"browserTarget": "my-app:build",
"serverTarget": "my-app:server"
},
"configurations": {
"production": {
"browserTarget": "my-app:build:production",
"serverTarget": "my-app:server:production"
}
}
},
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"browserTarget": "my-app:build:production",
"serverTarget": "my-app:server:production",
"routes": [
"/"
]
},
"configurations": {
"production": {}
}
}
}
},
"my-app-e2e": {
"root": "e2e/",
"projectType": "application",
"prefix": "",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "my-app:serve"
},
"configurations": {
"production": {
"devServerTarget": "my-app:serve:production"
}
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude": [
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "my-app"
}
O server.ts :
import 'zone.js/dist/zone-node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';
const domino = require('domino');
const fs = require('fs');
const path = require('path');
const template = fs
.readFileSync(path.join('dist/my-app/browser/de-AT', 'index.html'))
.toString();
const window = domino.createWindow(template);
// Ignite UI browser objects abstractions
(global as any).window = window;
(global as any).document = window.document;
(global as any).Event = window.Event;
(global as any).KeyboardEvent = window.KeyboardEvent;
(global as any).MouseEvent = window.MouseEvent;
(global as any).FocusEvent = window.FocusEvent;
(global as any).PointerEvent = window.PointerEvent;
(global as any).HTMLElement = window.HTMLElement;
(global as any).HTMLElement.prototype.getBoundingClientRect = () => {
return {
left: '',
right: '',
top: '',
bottom: ''
};
};
(global as any).XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest;
// Other optional depending on your application configuration
(global as any).object = window.object;
(global as any).navigator = window.navigator;
(global as any).localStorage = window.localStorage;
(global as any).DOMTokenList = window.DOMTokenList;
Object.defineProperty(window.document.body.style, 'transform', {
value: () => {
return {
enumerable: true,
configurable: true,
};
},
});
(global as any).CSS = null;
(global as any).Prism = null;
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { existsSync } from 'fs';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), 'dist/my-app/browser/de-AT');
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
server.set('view engine', 'html');
server.set('views', distFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y'
}));
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
});
return server;
}
function run(): void {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export * from './src/main.server';
O tsconfig.base.json :
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"module": "es2020",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "es2015",
"downlevelIteration": true,
"typeRoots": [
"node_modules/@types",
"typings.d.ts"
],
"lib": [
"es2018",
"dom"
]
}
}
O tsconfig.server.json :
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "../out-tsc/server",
"target": "es2016",
"types": [
"node"
]
},
"files": [
"main.server.ts",
"../server.ts"
],
"angularCompilerOptions": {
"entryModule": "./app/app.server.module#AppServerModule"
}
}
O app.server.module.ts :
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { UniversalInterceptor } from './universal-interceptor';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
AppModule,
ServerModule
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: UniversalInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppServerModule {}
Realmente não sei o que estou fazendo de errado. Alguma ideia?
Respostas
Depois de uma longa investigação, encontrei o culpado. Parece que, ao adicionar Angular Universal, a CLI envolveu o DOMContentLoadedouvinte de evento para inicializar o aplicativo em torno de outro DOMContentLoadedouvinte de evento no arquivo main.ts. Era assim que parecia:
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
document.addEventListener('DOMContentLoaded', () => { // Remove this
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
});
}); // Remove this
Portanto, a remoção dos segundos ouvintes de evento fez o aplicativo inicializar novamente.