Angular 5 универсальное использование Transfer State
Я покажу вам, как настроить универсальный проект (серверный рендеринг приложения), используя Angular 5.
Это приложение перенесет свое состояние с сервера на браузер, чтобы удалить необходимость избыточного HTTP-запроса для повторной загрузки данных в браузере.
Давайте начнем с выполнения следующих команд:
npm install -g @angular/cli ng new --skip-install universal-demo-v5 cd universal-demo-v5 code . # Open VSCode
Обновите зависимости package.json:
{ "name": "universal-demo-v5", "version": "0.0.0", "license": "MIT", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e" }, "private": true, "dependencies": { "@angular/animations": "^5.0.0", "@angular/common": "^5.0.0", "@angular/compiler": "^5.0.0", "@angular/core": "^5.0.0", "@angular/forms": "^5.0.0", "@angular/http": "^5.0.0", "@angular/platform-browser": "^5.0.0", "@angular/platform-browser-dynamic": "^5.0.0", "@angular/router": "^5.0.0", "core-js": "^2.5.1", "rxjs": "^5.5.2", "zone.js": "^0.8.18" }, "devDependencies": { "@angular/cli": "1.5.0", "@angular/compiler-cli": "^5.0.0", "@angular/language-service": "^5.0.0", "@types/jasmine": "~2.6.2", "@types/jasminewd2": "~2.0.3", "@types/node": "~8.0.47", "codelyzer": "~4.0.0", "jasmine-core": "~2.8.0", "jasmine-spec-reporter": "~4.2.1", "karma": "~1.7.1", "karma-chrome-launcher": "~2.2.0", "karma-cli": "~1.0.1", "karma-coverage-istanbul-reporter": "^1.3.0", "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "protractor": "~5.2.0", "ts-node": "~3.3.0", "tslint": "~5.8.0", "typescript": "~2.4.2" } }
Теперь давайте настроим ваш серверный модуль
npm install -S @angular/platform-server@^5.0.0-rc.1 express npm install -D ts-loader webpack-node-externals npm-run-all npm install
Создайте файл по этому пути: src/app/app.server.module.ts
import { NgModule } from '@angular/core'; import { ServerModule } from '@angular/platform-server'; import { AppModule } from './app.module'; import { AppComponent } from './app.component'; @NgModule({ imports: [ AppModule, ServerModule ], bootstrap: [AppComponent], }) export class AppServerModule { }
Обновите src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'universal-demo-v5' }) ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
Нам нужно создать основной файл на этом пути src/main.server.ts для экспорта нашего серверного модуля
export { AppServerModule } from './app/app.server.module';
Теперь давайте обновим нашу конфигурацию @angular/cli в файле .angular-cli.json
{ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "project": { "name": "universal-demo-v5" }, "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" } }, { "platform": "server", "root": "src", "outDir": "dist/server", "assets": [], "index": "index.html", "main": "main.server.ts", "test": "test.ts", "tsconfig": "tsconfig.server.json", "testTsconfig": "tsconfig.spec.json", "prefix": "app", "scripts": [], "environmentSource": "environments/environment.ts", "environments": { "dev": "environments/environment.ts", "prod": "environments/environment.prod.ts" } } ], "e2e": { "protractor": { "config": "./protractor.conf.js" } }, "lint": [ { "project": "src/tsconfig.app.json", "exclude": "**/node_modules/**" }, { "project": "src/tsconfig.spec.json", "exclude": "**/node_modules/**" }, { "project": "e2e/tsconfig.e2e.json", "exclude": "**/node_modules/**" } ], "test": { "karma": { "config": "./karma.conf.js" } }, "defaults": { "styleExt": "scss", "component": {} } }
Нам нужно создать новый файл tsconfig.json для сервера по этому пути: src/tsconfig.server.json
{ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", "baseUrl": "./", "module": "commonjs", "types": [] }, "exclude": [ "test.ts", "**/*.spec.ts", "server.ts" ], "angularCompilerOptions": { "entryModule": "app/app.server.module#AppServerModule" } }
Нам нужно обновить этот файл src/tsconfig.app.json
{ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", "baseUrl": "./", "module": "es2015", "types": [] }, "exclude": [ "test.ts", "**/*.spec.ts", "server.ts" ] }
Теперь запустите следующие команды, чтобы проверить, правильно ли они строятся:
ng build -prod --build-optimizer --app 0 ng build --aot --app 1
После запуска обеих команд вы увидите следующий вывод:
Теперь давайте настроим ваш сервер Express.js, вам нужно создать этот файл: src/server.ts
import 'reflect-metadata'; import 'zone.js/dist/zone-node'; import { renderModuleFactory } from '@angular/platform-server' import { enableProdMode } from '@angular/core' import * as express from 'express'; import { join } from 'path'; import { readFileSync } from 'fs'; enableProdMode(); const PORT = process.env.PORT || 4200; const DIST_FOLDER = join(process.cwd(), 'dist'); const app = express(); const template = readFileSync(join(DIST_FOLDER, 'browser', 'index.html')).toString(); const { AppServerModuleNgFactory } = require('main.server'); app.engine('html', (_, options, callback) => { const opts = { document: template, url: options.req.url }; renderModuleFactory(AppServerModuleNgFactory, opts) .then(html => callback(null, html)); }); app.set('view engine', 'html'); app.set('views', 'src') app.get('*.*', express.static(join(DIST_FOLDER, 'browser'))); app.get('*', (req, res) => { res.render('index', { req }); }); app.listen(PORT, () => { console.log(`listening on http://localhost:${PORT}!`); });
Нам понадобится файл конфигурации webpack, просто для создания этого файла server.ts: webpack.config.js
const path = require('path'); var nodeExternals = require('webpack-node-externals'); module.exports = { entry: { server: './src/server.ts' }, resolve: { extensions: ['.ts', '.js'], alias: { 'main.server': path.join(__dirname, 'dist', 'server', 'main.bundle.js') } }, target: 'node', externals: [nodeExternals()], output: { path: path.join(__dirname, 'dist'), filename: '[name].js' }, module: { rules: [ { test: /\.ts$/, loader: 'ts-loader' } ] } }
Теперь мы можем добавить некоторые скрипты в наш файл package.json, чтобы построить наш проект.
{ "name": "universal-demo-v5", "version": "0.0.0", "license": "MIT", "scripts": { "ng": "ng", "start": "ng serve", "build": "run-s build:client build:aot build:server", "build:client": "ng build -prod --build-optimizer --app 0", "build:aot": "ng build --aot --app 1", "build:server": "webpack -p", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e" }, "private": true, "dependencies": { "@angular/animations": "^5.0.0", "@angular/common": "^5.0.0", "@angular/compiler": "^5.0.0", "@angular/core": "^5.0.0", "@angular/forms": "^5.0.0", "@angular/http": "^5.0.0", "@angular/platform-browser": "^5.0.0", "@angular/platform-browser-dynamic": "^5.0.0", "@angular/platform-server": "^5.0.0", "@angular/router": "^5.0.0", "core-js": "^2.5.1", "express": "^4.16.2", "rxjs": "^5.5.2", "zone.js": "^0.8.18" }, "devDependencies": { "@angular/cli": "1.5.0", "@angular/compiler-cli": "^5.0.0", "@angular/language-service": "^5.0.0", "@types/jasmine": "~2.6.2", "@types/jasminewd2": "~2.0.3", "@types/node": "~8.0.47", "codelyzer": "~4.0.0", "jasmine-core": "~2.8.0", "jasmine-spec-reporter": "~4.2.1", "karma": "~1.7.1", "karma-chrome-launcher": "~2.2.0", "karma-cli": "~1.0.1", "karma-coverage-istanbul-reporter": "^1.3.0", "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "npm-run-all": "^4.1.1", "protractor": "~5.2.0", "ts-loader": "^3.1.1", "ts-node": "~3.3.0", "tslint": "~5.8.0", "typescript": "~2.4.2", "webpack-node-externals": "^1.6.0" } }
Теперь вы можете попробовать, просто запустив:
npm run build
Вы должны увидеть следующий результат:
Теперь попробуйте в браузере, просто запустите: node dist/server.js
Откройте http://localhost:4200/, и вы увидите, как ваше приложение работает как на изображение ниже:
В инструментах разработчика на вкладке «Сеть» вы увидите информацию о запросе localhost, чтобы содержимое на странице отображалось на сервере.
Теперь давайте поговорим о состоянии, мы собираемся запросить некоторые данные из некоторого бесплатного API REST на нашем AppComponent.
Импортируем HttpClientModule в наш AppModule:
import { BrowserModule } from '@angular/platform-browser'; import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'universal-demo-v5' }), HttpClientModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
Делаем запрос за данными:
import { Component, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { title = 'app'; dogs: any; constructor( private http: HttpClient ) { } ngOnInit() { this.http .get('https://dog.ceo/api/breeds/list/all') .subscribe(data => { console.log(data); this.dogs = data; }); } }
Теперь давайте выведем полученные данные в нашем шаблоне компоненты:
<div style="text-align:center"> <h1> Welcome to {{title}}! </h1> </div> <pre>{{ dogs | json}}</pre>
Давайте посмотрим, что произойдет: npm run build && node dist/server.js
Посмотрите, на HTTP-запрос, получающий данные, они отправляются дважды, один раз на сервере и один раз в браузере, это не очень хорошая идея.
Мы можем решить эту проблему, используя новые Angular модули для передачи состояния, давайте посмотрим, как их использовать.
app.server.module.ts
import { NgModule } from '@angular/core'; import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; import { AppModule } from './app.module'; import { AppComponent } from './app.component'; @NgModule({ imports: [ AppModule, ServerModule, ServerTransferStateModule ], bootstrap: [AppComponent], }) export class AppServerModule { }
app.module.ts
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser'; import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule.withServerTransition({ appId: 'universal-demo-v5' }), HttpClientModule, BrowserTransferStateModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
app.component.ts
import { Component, OnInit } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { TransferState, makeStateKey } from '@angular/platform-browser'; const DOGS_KEY = makeStateKey('dogs'); @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { title = 'app'; dogs: any; constructor( private http: HttpClient, private state: TransferState ) { } ngOnInit() { this.dogs = this.state.get(DOGS_KEY, null as any); if (!this.dogs) { this.http .get('https://dog.ceo/api/breeds/list/all') .subscribe(data => { this.dogs = data; this.state.set(DOGS_KEY, data as any); }); } } }
Теперь нам просто нужно внести небольшую корректировку в файл main.ts, чтобы загружать наше приложение только тогда, когда документ готов, чтобы TransferState работал правильно:
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', () => { platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.log(err)); });
Давайте посмотрим, что теперь происходит: npm run build && node dist/server.js
Теперь вы можете видеть, что в браузере не было сделано никакого дополнительного HTTP-запроса, поскольку состояние было передано с сервера клиенту, в новом теге скрипта с id = universal-demo-v5-state