Angular開發(fā)實踐之服務端渲染
Angular Universal
Angular在服務端渲染方面提供一套前后端同構解決方案,它就是Angular Universal(統(tǒng)一平臺),一項在服務端運行 Angular 應用的技術。
標準的 Angular 應用會執(zhí)行在瀏覽器中,它會在 DOM 中渲染頁面,以響應用戶的操作。
而 Angular Universal 會在服務端通過一個被稱為服務端渲染(server-side rendering - SSR)的過程生成靜態(tài)的應用頁面。
它可以生成這些頁面,并在瀏覽器請求時直接用它們給出響應。 它也可以把頁面預先生成為 HTML 文件,然后把它們作為靜態(tài)文件供服務器使用。
工作原理
要制作一個 Universal 應用,就要安裝 platform-server 包。 platform-server 包提供了服務端的 DOM 實現(xiàn)、XMLHttpRequest 和其它底層特性,但不再依賴瀏覽器。
你要使用 platform-server 模塊而不是 platform-browser 模塊來編譯這個客戶端應用,并且在一個 Web 服務器上運行這個 Universal 應用。
服務器(下面的示例中使用的是 Node Express 服務器)會把客戶端對應用頁面的請求傳給 renderModuleFactory 函數(shù)。
renderModuleFactory 函數(shù)接受一個模板 HTML 頁面(通常是 index.html)、一個包含組件的 Angular 模塊和一個用于決定該顯示哪些組件的路由作為輸入。
該路由從客戶端的請求中傳給服務器。 每次請求都會給出所請求路由的一個適當?shù)囊晥D。
renderModuleFactory 在模板中的 <app> 標記中渲染出哪個視圖,并為客戶端創(chuàng)建一個完成的 HTML 頁面。
最后,服務器就會把渲染好的頁面返回給客戶端。
為什么要服務端渲染
三個主要原因:
- 幫助網(wǎng)絡爬蟲(SEO)
- 提升在手機和低功耗設備上的性能
- 迅速顯示出第一個頁面
幫助網(wǎng)絡爬蟲(SEO)
Google、Bing、百度、Facebook、Twitter 和其它搜索引擎或社交媒體網(wǎng)站都依賴網(wǎng)絡爬蟲去索引你的應用內(nèi)容,并且讓它的內(nèi)容可以通過網(wǎng)絡搜索到。
這些網(wǎng)絡爬蟲可能不會像人類那樣導航到你的具有高度交互性的 Angular 應用,并為其建立索引。
Angular Universal 可以為你生成應用的靜態(tài)版本,它易搜索、可鏈接,瀏覽時也不必借助 JavaScript。它也讓站點可以被預覽,因為每個 URL 返回的都是一個完全渲染好的頁面。
啟用網(wǎng)絡爬蟲通常被稱為搜索引擎優(yōu)化 (SEO)。
提升手機和低功耗設備上的性能
有些設備不支持 JavaScript 或 JavaScript 執(zhí)行得很差,導致用戶體驗不可接受。 對于這些情況,你可能會需要該應用的服務端渲染、無 JavaScript 的版本。 雖然有一些限制,不過這個版本可能是那些完全沒辦法使用該應用的人的唯一選擇。
快速顯示首頁
快速顯示首頁對于吸引用戶是至關重要的。
如果頁面加載超過了三秒中,那么 53% 的移動網(wǎng)站會被放棄。 你的應用需要啟動的更快一點,以便在用戶決定做別的事情之前吸引他們的注意力。
使用 Angular Universal,你可以為應用生成“著陸頁”,它們看起來就和完整的應用一樣。 這些著陸頁是純 HTML,并且即使 JavaScript 被禁用了也能顯示。 這些頁面不會處理瀏覽器事件,不過它們可以用 routerLink 在這個網(wǎng)站中導航。
在實踐中,你可能要使用一個著陸頁的靜態(tài)版本來保持用戶的注意力。 同時,你也會在幕后加載完整的 Angular 應用。 用戶會認為著陸頁幾乎是立即出現(xiàn)的,而當完整的應用加載完之后,又可以獲得完全的交互體驗。
示例解析
下面將基于我在GitHub上的示例項目 angular-universal-starter 來進行講解。
這個項目與第一篇的示例項目一樣,都是基于 Angular CLI進行開發(fā)構建的,因此它們的區(qū)別只在于服務端渲染所需的那些配置上。
安裝工具
在開始之前,下列包是必須安裝的(示例項目均已配置好,只需 npm install 即可):
@angular/platform-server- Universal 的服務端元件。@nguniversal/module-map-ngfactory-loader- 用于處理服務端渲染環(huán)境下的惰性加載。@nguniversal/express-engine- Universal 應用的 Express 引擎。ts-loader- 用于對服務端應用進行轉譯。express- Node Express 服務器
使用下列命令安裝它們:
npm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader @nguniversal/express-engine express
項目配置
配置工作有:
- 創(chuàng)建服務端應用模塊:src/app/app.server.module.ts
- 修改客戶端應用模塊:src/app/app.module.ts
- 創(chuàng)建服務端應用的引導程序文件:src/main.server.ts
- 修改客戶端應用的引導程序文件:src/main.ts
- 創(chuàng)建 TypeScript 的服務端配置:src/tsconfig.server.json
- 修改 @angular/cli 的配置文件:.angular-cli.json
- 創(chuàng)建 Node Express 的服務程序:server.ts
- 創(chuàng)建服務端預渲染的程序:prerender.ts
- 創(chuàng)建 Webpack 的服務端配置:webpack.server.config.js
1、創(chuàng)建服務端應用模塊:src/app/app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';
import { AppBrowserModule } from './app.module';
import { AppComponent } from './app.component';
// 可以注冊那些在 Universal 環(huán)境下運行應用時特有的服務提供商
@NgModule({
imports: [
AppBrowserModule, // 客戶端應用的 AppModule
ServerModule, // 服務端的 Angular 模塊
ModuleMapLoaderModule, // 用于實現(xiàn)服務端的路由的惰性加載
ServerTransferStateModule, // 在服務端導入,用于實現(xiàn)將狀態(tài)從服務器傳輸?shù)娇蛻舳?
],
bootstrap: [AppComponent],
})
export class AppServerModule {
}
服務端應用模塊(習慣上叫作 AppServerModule)是一個 Angular 模塊,它包裝了應用的根模塊 AppModule,以便 Universal 可以在你的應用和服務器之間進行協(xié)調(diào)。 AppServerModule 還會告訴 Angular 再把你的應用以 Universal 方式運行時,該如何引導它。
2、修改客戶端應用模塊:src/app/app.module.ts
@NgModule({
imports: [
AppRoutingModule,
BrowserModule.withServerTransition({appId: 'my-app'}),
TransferHttpCacheModule, // 用于實現(xiàn)服務器到客戶端的請求傳輸緩存,防止客戶端重復請求服務端已完成的請求
BrowserTransferStateModule, // 在客戶端導入,用于實現(xiàn)將狀態(tài)從服務器傳輸?shù)娇蛻舳?
HttpClientModule
],
declarations: [
AppComponent,
HomeComponent
],
providers: [],
bootstrap: [AppComponent]
})
export class AppBrowserModule {
constructor(@Inject(PLATFORM_ID) private platformId: Object,
@Inject(APP_ID) private appId: string) {
// 判斷運行環(huán)境為客戶端還是服務端
const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server';
console.log(`Running ${platform} with appId=${appId}`);
}
}
將 NgModule 的元數(shù)據(jù)中 BrowserModule 的導入改成 BrowserModule.withServerTransition({appId: 'my-app'}),Angular 會把 appId 值(它可以是任何字符串)添加到服務端渲染頁面的樣式名中,以便它們在客戶端應用啟動時可以被找到并移除。
此時,我們可以通過依賴注入(@Inject(PLATFORM_ID) 及 @Inject(APP_ID))取得關于當前平臺和 appId 的運行時信息:
constructor(@Inject(PLATFORM_ID) private platformId: Object,
@Inject(APP_ID) private appId: string) {
// 判斷運行環(huán)境為客戶端還是服務端
const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server';
console.log(`Running ${platform} with appId=${appId}`);
}
3、創(chuàng)建服務端應用的引導程序文件:src/main.server.ts
該文件導出服務端模塊:
export { AppServerModule } from './app/app.server.module';
4、修改客戶端應用的引導程序文件:src/main.ts
監(jiān)聽 DOMContentLoaded 事件,在發(fā)生 DOMContentLoaded 事件時運行我們的代碼,以使 TransferState 正常工作
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppBrowserModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
// 在 DOMContentLoaded 時運行我們的代碼,以使 TransferState 正常工作
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppBrowserModule);
});
5、創(chuàng)建 TypeScript 的服務端配置:src/tsconfig.server.json
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "commonjs",
"types": [
"node"
]
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app.server.module#AppServerModule"
}
}
與 tsconfig.app.json 的差異在于:
module 屬性必須是 commonjs,這樣它才能被 require() 方法導入你的服務端應用。
angularCompilerOptions 部分有一些面向 AOT 編譯器的選項:
- entryModule - 服務端應用的根模塊,其格式為 path/to/file#ClassName。
6、修改 @angular/cli 的配置文件:.angular-cli.json
在 apps 下添加:
{
"platform": "server",
"root": "src",
"outDir": "dist/server",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.server.ts",
"test": "test.ts",
"tsconfig": "tsconfig.server.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "",
"styles": [
"styles.scss"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
7、創(chuàng)建 Node Express 的服務程序:server.ts
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { enableProdMode } from '@angular/core';
import * as express from 'express';
import { join } from 'path';
import { readFileSync } from 'fs';
// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();
// Express server
const app = express();
const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist');
// Our index.html we'll use as our template
const template = 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} = require('./dist/server/main.bundle');
// Express Engine
import { ngExpressEngine } from '@nguniversal/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.set('view engine', 'html');
app.set('views', join(DIST_FOLDER, 'browser'));
/* - Example Express Rest API endpoints -
app.get('/api/**', (req, res) => { });
*/
// 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}`);
});
8、創(chuàng)建服務端預渲染的程序:prerender.ts
// Load zone.js for the server.
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { enableProdMode } from '@angular/core';
// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();
// Import module map for lazy loading
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';
import { renderModuleFactory } from '@angular/platform-server';
import { ROUTES } from './static.paths';
// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main.bundle');
const BROWSER_FOLDER = join(process.cwd(), 'browser');
// Load the index.html file containing referances to your application bundle.
const index = readFileSync(join('browser', 'index.html'), 'utf8');
let previousRender = Promise.resolve();
// Iterate each route path
ROUTES.forEach(route => {
const fullPath = join(BROWSER_FOLDER, route);
// Make sure the directory structure is there
if (!existsSync(fullPath)) {
mkdirSync(fullPath);
}
// Writes rendered HTML to index.html, replacing the file if it already exists.
previousRender = previousRender.then(_ => renderModuleFactory(AppServerModuleNgFactory, {
document: index,
url: route,
extraProviders: [
provideModuleMap(LAZY_MODULE_MAP)
]
})).then(html => writeFileSync(join(fullPath, 'index.html'), html));
});
9、創(chuàng)建 Webpack 的服務端配置:webpack.server.config.js
Universal 應用不需要任何額外的 Webpack 配置,Angular CLI 會幫我們處理它們。但是由于本例子的 Node Express 的服務程序是 TypeScript 應用(server.ts及prerender.ts),所以要使用 Webpack 來轉譯它。這里不討論 Webpack 的配置,需要了解的移步 Webpack官網(wǎng)
// Work around for https://github.com/angular/angular-cli/issues/7200
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
server: './server.ts', // This is our Express server for Dynamic universal
prerender: './prerender.ts' // This is an example of Static prerendering (generative)
},
target: 'node',
resolve: {extensions: ['.ts', '.js']},
externals: [/(node_modules|main\..*\.js)/,], // Make sure we include all node_modules etc
output: {
path: path.join(__dirname, 'dist'), // Puts the output at the root of the dist folder
filename: '[name].js'
},
module: {
rules: [
{test: /\.ts$/, loader: 'ts-loader'}
]
},
plugins: [
new webpack.ContextReplacementPlugin(
/(.+)?angular(\\|\/)core(.+)?/, // fixes WARNING Critical dependency: the request of a dependency is an expression
path.join(__dirname, 'src'), // location of your src
{} // a map of your routes
),
new webpack.ContextReplacementPlugin(
/(.+)?express(\\|\/)(.+)?/, // fixes WARNING Critical dependency: the request of a dependency is an expression
path.join(__dirname, 'src'),
{}
)
]
};
測試配置
通過上面的配置,我們就制作完成一個可在服務端渲染的 Angular Universal 應用。
在 package.json 的 scripts 區(qū)配置 build 和 serve 有關的命令:
{
"scripts": {
"ng": "ng",
"start": "ng serve -o",
"ssr": "npm run build:ssr && npm run serve:ssr",
"prerender": "npm run build:prerender && npm run serve:prerender",
"build": "ng build",
"build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
"build:prerender": "npm run build:client-and-server-bundles && npm run webpack:server && npm run generate:prerender",
"build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
"generate:prerender": "cd dist && node prerender",
"webpack:server": "webpack --config webpack.server.config.js --progress --colors",
"serve:prerender": "cd dist/browser && http-server",
"serve:ssr": "node dist/server"
}
}
開發(fā)只需運行 npm run start執(zhí)行 npm run ssr 編譯應用程序,并啟動一個Node Express來為應用程序提供服務 http://localhost:4000
dist目錄:

執(zhí)行npm run prerender - 編譯應用程序并預渲染應用程序文件,啟動一個演示http服務器,以便您可以查看它 http://localhost:8080
注意: 要將靜態(tài)網(wǎng)站部署到靜態(tài)托管平臺,您必須部署dist/browser文件夾, 而不是dist文件夾
dist目錄:

根據(jù)項目實際的路由信息并在根目錄的 static.paths.ts 中配置,提供給 prerender.ts 解析使用。
export const ROUTES = [ '/', '/lazy' ];
因此,從dist目錄可以看到,服務端預渲染會根據(jù)配置好的路由在 browser 生成對應的靜態(tài)index.html。如 / 對應 /index.html,/lazy 對應 /lazy/index.html。
服務器到客戶端的狀態(tài)傳輸
在前面的介紹中,我們在 app.server.module.ts 中導入了 ServerTransferStateModule,在 app.module.ts 中導入了 BrowserTransferStateModule 和 TransferHttpCacheModule。
這三個模塊都與服務器到客戶端的狀態(tài)傳輸有關:
ServerTransferStateModule:在服務端導入,用于實現(xiàn)將狀態(tài)從服務器傳輸?shù)娇蛻舳?/li>BrowserTransferStateModule:在客戶端導入,用于實現(xiàn)將狀態(tài)從服務器傳輸?shù)娇蛻舳?/li>TransferHttpCacheModule:用于實現(xiàn)服務器到客戶端的請求傳輸緩存,防止客戶端重復請求服務端已完成的請求
使用這幾個模塊,可以解決 http請求在服務端和客戶端分別請求一次 的問題。
比如在 home.component.ts 中有如下代碼:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit, OnDestroy {
constructor(public http: HttpClient) {
}
ngOnInit() {
this.poiSearch(this.keyword, '北京市').subscribe((data: any) => {
console.log(data);
});
}
ngOnDestroy() {
}
poiSearch(text: string, city?: string): Observable<any> {
return this.http.get(encodeURI(`http://restapi.amap.com/v3/place/text?keywords=${text}&city=${city}&offset=20&key=55f909211b9950837fba2c71d0488db9&extensions=all`));
}
}
代碼運行之后,
服務端請求并打?。?/p>

客戶端再一次請求并打?。?/p>

方法1:使用 TransferHttpCacheModule
使用 TransferHttpCacheModule 很簡單,代碼不需要改動。在 app.module.ts 中導入之后,Angular自動會將服務端請求緩存到客戶端,換句話說就是服務端請求到數(shù)據(jù)會自動傳輸?shù)娇蛻舳耍蛻舳私邮盏綌?shù)據(jù)之后就不會再發(fā)送請求了。
方法2:使用 BrowserTransferStateModule
該方法稍微復雜一些,需要改動一些代碼。
調(diào)整 home.component.ts 代碼如下:
import { Component, OnDestroy, OnInit } from '@angular/core';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
const KFCLIST_KEY = makeStateKey('kfcList');
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit, OnDestroy {
constructor(public http: HttpClient,
private state: TransferState) {
}
ngOnInit() {
// 采用一個標記來區(qū)分服務端是否已經(jīng)拿到了數(shù)據(jù),如果沒拿到數(shù)據(jù)就在客戶端請求,如果已經(jīng)拿到數(shù)據(jù)就不發(fā)請求
const kfcList:any[] = this.state.get(KFCLIST_KEY, null as any);
if (!this.kfcList) {
this.poiSearch(this.keyword, '北京市').subscribe((data: any) => {
console.log(data);
this.state.set(KFCLIST_KEY, data as any); // 存儲數(shù)據(jù)
});
}
}
ngOnDestroy() {
if (typeof window === 'object') {
this.state.set(KFCLIST_KEY, null as any); // 刪除數(shù)據(jù)
}
}
poiSearch(text: string, city?: string): Observable<any> {
return this.http.get(encodeURI(`http://restapi.amap.com/v3/place/text?keywords=${text}&city=${city}&offset=20&key=55f909211b9950837fba2c71d0488db9&extensions=all`));
}
}
- 使用
const KFCLIST_KEY = makeStateKey('kfcList')創(chuàng)建儲存?zhèn)鬏敂?shù)據(jù)的 StateKey - 在
HomeComponent的構造函數(shù)中注入TransferState - 在
ngOnInit中根據(jù)this.state.get(KFCLIST_KEY, null as any)判斷數(shù)據(jù)是否存在(不管是服務端還是客戶端),存在就不再請求,不存在則請求數(shù)據(jù)并通過this.state.set(KFCLIST_KEY, data as any)存儲傳輸數(shù)據(jù) - 在
ngOnDestroy中根據(jù)當前是否客戶端來決定是否將存儲的數(shù)據(jù)進行刪除
以上就是本文的全部內(nèi)容,希望對大家的學習有所幫助,也希望大家多多支持腳本之家。
相關文章
AngularJS iframe跨域打開內(nèi)容時報錯誤的解決辦法
這篇文章主要介紹了AngularJS iframe跨域打開內(nèi)容時報錯誤的解決辦法,需要的朋友可以參考下2015-01-01
如何利用@angular/cli V6.0直接開發(fā)PWA應用詳解
這篇文章主要給大家介紹了如何利用@angular/cli V6.0直接開發(fā)PWA應用的相關資料,文中通過示例代碼介紹的非常詳細,對大家學習或者使用@angular/cli V6.0具有一定的參考學習價值,需要的朋友可以參考下2018-05-05

