Angular開發實踐(六):服務端渲染
Angular在服務端渲染方面提供一套前後端同構解決方案,它就是 ofollow,noindex">Angular Universal (統一平臺),一項在服務端執行 Angular 應用的技術。
標準的 Angular 應用會執行在瀏覽器中,它會在 DOM 中渲染頁面,以響應使用者的操作。
而 Angular Universal 會在服務端通過一個被稱為服務端渲染(server-side rendering - SSR)的過程生成靜態的應用頁面。
它可以生成這些頁面,並在瀏覽器請求時直接用它們給出響應。 它也可以把頁面預先生成為 HTML 檔案,然後把它們作為靜態檔案供服務端使用。
工作原理
要製作一個 Universal 應用,就要安裝 platform-server
包。 platform-server 包提供了服務端的 DOM 實現、XMLHttpRequest 和其它底層特性,但不再依賴瀏覽器。
你要使用 platform-server
模組而不是 platform-browser
模組來編譯這個客戶端應用,並且在一個 Web 伺服器上執行這個 Universal 應用。
伺服器(下面的示例中使用的是 Node Express 伺服器)會把客戶端對應用頁面的請求傳給 renderModuleFactory
函式。
renderModuleFactory 函式接受一個模板 HTML 頁面(通常是 index.html)、一個包含元件的 Angular 模組和一個用於決定該顯示哪些元件的路由作為輸入。
該路由從客戶端的請求中傳給伺服器。 每次請求都會給出所請求路由的一個適當的檢視。
renderModuleFactory 在模板中的 <app>
標記中渲染出哪個檢視,併為客戶端建立一個完成的 HTML 頁面。
最後,伺服器就會把渲染好的頁面返回給客戶端。
為什麼要服務端渲染
三個主要原因:
-
幫助網路爬蟲(SEO)
-
提升在手機和低功耗裝置上的效能
-
迅速顯示出第首頁
幫助網路爬蟲(SEO)
Google、Bing、百度、Facebook、Twitter 和其它搜尋引擎或社交媒體網站都依賴網路爬蟲去索引你的應用內容,並且讓它的內容可以通過網路搜尋到。
這些網路爬蟲可能不會像人類那樣導航到你的具有高度互動性的 Angular 應用,併為其建立索引。
Angular Universal 可以為你生成應用的靜態版本,它易搜尋、可連結,瀏覽時也不必藉助 JavaScript。它也讓站點可以被預覽,因為每個 URL 返回的都是一個完全渲染好的頁面。
啟用網路爬蟲通常被稱為搜尋引擎優化 (SEO)。
提升手機和低功耗裝置上的效能
有些裝置不支援 JavaScript 或 JavaScript 執行得很差,導致使用者體驗不可接受。 對於這些情況,你可能會需要該應用的服務端渲染、無 JavaScript 的版本。 雖然有一些限制,不過這個版本可能是那些完全沒辦法使用該應用的人的唯一選擇。
快速顯示首頁
快速顯示首頁對於吸引使用者是至關重要的。
如果頁面載入超過了三秒中,那麼 53% 的移動網站會被放棄。 你的應用需要啟動的更快一點,以便在使用者決定做別的事情之前吸引他們的注意力。
使用 Angular Universal,你可以為應用生成“著陸頁”,它們看起來就和完整的應用一樣。 這些著陸頁是純 HTML,並且即使 JavaScript 被禁用了也能顯示。 這些頁面不會處理瀏覽器事件,不過它們可以用 routerLink 在這個網站中導航。
在實踐中,你可能要使用一個著陸頁的靜態版本來保持使用者的注意力。 同時,你也會在幕後載入完整的 Angular 應用。 使用者會認為著陸頁幾乎是立即出現的,而當完整的應用載入完之後,又可以獲得完全的互動體驗。
示例解析
下面將基於我在GitHub上的示例專案 angular-universal-starter 來進行講解。
這個專案與第一篇的示例專案一樣,都是基於 Angular CLI進行開發構建的,因此它們的區別只在於服務端渲染所需的那些配置上。
安裝工具
在開始之前,下列包是必須安裝的(示例專案均已配置好,只需 npm install
即可):
-
@angular/platform-server
- Universal 的服務端元件。 -
@nguniversal/module-map-ngfactory-loader
- 用於處理服務端渲染環境下的惰性載入。 -
@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 複製程式碼
專案配置
配置工作有:
src/app/app.server.module.ts src/app/app.module.ts src/main.server.ts src/main.ts src/tsconfig.server.json .angular-cli.json server.ts prerender.ts webpack.server.config.js
1、建立服務端應用模組: 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 環境下執行應用時特有的服務提供商 @NgModule({ imports: [ AppBrowserModule, // 客戶端應用的 AppModule ServerModule, // 服務端的 Angular 模組 ModuleMapLoaderModule, // 用於實現服務端的路由的惰性載入 ServerTransferStateModule, // 在服務端匯入,用於實現將狀態從伺服器傳輸到客戶端 ], bootstrap: [AppComponent], }) export class AppServerModule { } 複製程式碼
服務端應用模組(習慣上叫作 AppServerModule)是一個 Angular 模組,它包裝了應用的根模組 AppModule,以便 Universal 可以在你的應用和伺服器之間進行協調。 AppServerModule 還會告訴 Angular 再把你的應用以 Universal 方式執行時,該如何引導它。
2、修改客戶端應用模組: src/app/app.module.ts
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser'; import { HttpClientModule } from '@angular/common/http'; import { APP_ID, Inject, NgModule, PLATFORM_ID } from '@angular/core'; import { AppComponent } from './app.component'; import { HomeComponent } from './home/home.component'; import { TransferHttpCacheModule } from '@nguniversal/common'; import { isPlatformBrowser } from '@angular/common'; import { AppRoutingModule } from './app.routes'; @NgModule({ imports: [ AppRoutingModule, BrowserModule.withServerTransition({appId: 'my-app'}), TransferHttpCacheModule, // 用於實現伺服器到客戶端的請求傳輸快取,防止客戶端重複請求服務端已完成的請求 BrowserTransferStateModule, // 在客戶端匯入,用於實現將狀態從伺服器傳輸到客戶端 HttpClientModule ], declarations: [ AppComponent, HomeComponent ], providers: [], bootstrap: [AppComponent] }) export class AppBrowserModule { constructor(@Inject(PLATFORM_ID) private platformId: Object, @Inject(APP_ID) private appId: string) { // 判斷執行環境為客戶端還是服務端 const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server'; console.log(`Running ${platform} with appId=${appId}`); } } 複製程式碼
將 NgModule
的元資料中 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) { // 判斷執行環境為客戶端還是服務端 const platform = isPlatformBrowser(platformId) ? 'in the browser' : 'on the server'; console.log(`Running ${platform} with appId=${appId}`); } 複製程式碼
3、建立服務端應用的引導程式檔案: src/main.server.ts
該檔案匯出服務端模組:
export { AppServerModule } from './app/app.server.module'; 複製程式碼
4、修改客戶端應用的載入程式檔案: src/main.ts
監聽 DOMContentLoaded 事件,在發生 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、建立 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、建立 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}`); }); 複製程式碼
Universal 模板引擎
這個檔案中最重要的部分是 ngExpressEngine 函式:
app.engine('html', ngExpressEngine({ bootstrap: AppServerModuleNgFactory, providers: [ provideModuleMap(LAZY_MODULE_MAP) ] })); 複製程式碼
ngExpressEngine 是對 Universal 的 renderModuleFactory 函式的封裝。它會把客戶端請求轉換成服務端渲染的 HTML 頁面。 如果你使用不同於Node的服務端技術,你需要在該服務端的模板引擎中呼叫這個函式。
-
第一個引數是你以前寫過的 AppServerModule。 它是 Universal 服務端渲染器和你的應用之間的橋樑。
-
第二個引數是 extraProviders。它是在這個伺服器上執行時才需要的一些可選的 Angular 依賴注入提供商。當你的應用需要那些只有當執行在伺服器例項中才需要的資訊時,就要提供 extraProviders 引數。
ngExpressEngine 函式返回了一個會解析成渲染好的頁面的承諾(Promise)。
接下來你的引擎要決定拿這個頁面做點什麼。 現在這個引擎的回撥函式中,把渲染好的頁面返回給了 Web 伺服器,然後伺服器通過 HTTP 響應把它轉發給了客戶端。
8、建立服務端預渲染的程式: 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、建立 Webpack 的服務端配置: webpack.server.config.js
Universal 應用不需要任何額外的 Webpack 配置,Angular CLI 會幫我們處理它們。但是由於本例子的 Node Express 的服務程式是 TypeScript 應用(server.ts及prerender.ts),所以要使用 Webpack 來轉譯它。這裡不討論 Webpack 的配置,需要了解的移步Webpack官網
// 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 區配置 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" } } 複製程式碼
開發只需執行 npm run start
執行 npm run ssr
編譯應用程式,並啟動一個Node Express來為應用程式提供服務 http://localhost:4000
dist目錄:

執行npm run prerender - 編譯應用程式並預渲染應用程式檔案,啟動一個演示http伺服器,以便您可以檢視它 http://localhost:8080
注意:要將靜態網站部署到靜態託管平臺,您必須部署dist/browser資料夾, 而不是dist資料夾
dist目錄:

根據專案實際的路由資訊並在根目錄的 static.paths.ts
中配置,提供給 prerender.ts 解析使用。
export const ROUTES = [ '/', '/lazy' ]; 複製程式碼
因此,從dist目錄可以看到,服務端預渲染會根據配置好的路由在 browser 生成對應的靜態index.html。如 /
對應 /index.html
, /lazy
對應 /lazy/index.html
。
服務端的模組懶載入
在前面的介紹中,我們在 app.server.module.ts
中匯入了 ModuleMapLoaderModule ,在 app.module.ts
。
ModuleMapLoaderModule
模組可以使得懶載入的模組也可以在服務端進行渲染,而你要做也只是在 app.server.module.ts
中匯入。
服務端到客戶端的狀態傳輸
在前面的介紹中,我們在 app.server.module.ts
中匯入了 ServerTransferStateModule
,在 app.module.ts
中匯入了 BrowserTransferStateModule
和 TransferHttpCacheModule 。
這三個模組都與服務端到客戶端的狀態傳輸有關:
ServerTransferStateModule BrowserTransferStateModule TransferHttpCacheModule
使用這幾個模組,可以解決 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`)); } } 複製程式碼
程式碼執行之後,
服務端請求並列印:

客戶端再一次請求並列印:

方法1:使用 TransferHttpCacheModule
使用 TransferHttpCacheModule
很簡單,程式碼不需要改動。在 app.module.ts
中匯入之後,Angular自動會將服務端請求快取到客戶端,換句話說就是服務端請求到資料會自動傳輸到客戶端,客戶端接收到資料之後就不會再發送請求了。
方法2:使用 BrowserTransferStateModule
該方法稍微複雜一些,需要改動一些程式碼。
調整 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() { // 採用一個標記來區分服務端是否已經拿到了資料,如果沒拿到資料就在客戶端請求,如果已經拿到資料就不發請求 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); // 儲存資料 }); } } ngOnDestroy() { if (typeof window === 'object') { this.state.set(KFCLIST_KEY, null as any); // 刪除資料 } } 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')
建立儲存傳輸資料的 StateKey - 在
HomeComponent
的建構函式中注入TransferState
- 在
ngOnInit
中根據this.state.get(KFCLIST_KEY, null as any)
判斷資料是否存在(不管是服務端還是客戶端),存在就不再請求,不存在則請求資料並通過this.state.set(KFCLIST_KEY, data as any)
儲存傳輸資料 - 在
ngOnDestroy
中根據當前是否客戶端來決定是否將儲存的資料進行刪除
客戶端與服務端渲染對比
最後,我們分別通過這三個原因來進行對比:
-
幫助網路爬蟲(SEO)
-
提升在手機和低功耗裝置上的效能
-
迅速顯示出首頁
幫助網路爬蟲(SEO)
客戶端渲染:

服務端渲染:

從上面可以看到,服務端提前將資訊渲染到返回的頁面上,這樣網路爬蟲就能直接獲取到資訊了(網路爬蟲基本不會解析javascript的)。
提升在手機和低功耗裝置上的效能
這個原因通過上面就可以看出,對於一些低端的裝置,直接顯示頁面總比要解析javascript效能高的多。
迅速顯示出首頁
同樣在 Fast 3G 網路條件下進行測試
客戶端渲染:

服務端渲染:

牢記幾件事情
-
對於伺服器軟體包,您可能需要將第三方模組包含到
nodeExternals
白名單中 -
window
,document
,navigator
以及其它的瀏覽器型別 - 不存在於服務端 - 如果你直接使用,在服務端將無法正常工作。 以下幾種方法可以讓你的程式碼正常工作:- 可以通過
PLATFORM_ID
標記注入的Object
來檢查當前平臺是瀏覽器還是伺服器,然後使用瀏覽器端特有的型別
import { PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; constructor(@Inject(PLATFORM_ID) private platformId: Object) { ... } ngOnInit() { if (isPlatformBrowser(this.platformId)) { // 僅執行在瀏覽器端的程式碼 ... } if (isPlatformServer(this.platformId)) { // 僅執行在服務端的程式碼 ... } } 複製程式碼
-
儘量 限制 或 避免 使用
setTimeout
。它會減慢伺服器端的渲染過程。確保在元件的ngOnDestroy
中刪除它們 -
對於RxJs超時,請確保在成功時 取消 它們的流,因為它們也會降低渲染速度。
- 可以通過
-
不要直接操作nativeElement,使用Renderer2,從而可以跨平臺改變應用檢視。
constructor(element: ElementRef, renderer: Renderer2) { this.renderer.setStyle(element.nativeElement, 'font-size', 'x-large'); } 複製程式碼
- 解決應用程式在伺服器上執行XHR請求,並在客戶端再次執行的問題
- 使用從伺服器傳輸到客戶端的快取(TransferState)
- 清楚瞭解與DOM相關的屬性和屬性之間的差異
- 儘量讓指令無狀態。對於有狀態指令,您可能需要提供一個屬性,以反映相應屬性的初始字串值,例如img標籤中的url。對於我們的native元素,src屬性被反映為元素型別HTMLImageElement的src屬性
轉載請註明出處,謝謝!
