Angular在服務端渲染方面提供一套先後端同構解決方案,它就是 Angular Universal(統一平臺),一項在服務端運行 Angular 應用的技術。javascript
標準的 Angular 應用會執行在瀏覽器中,它會在 DOM 中渲染頁面,以響應用戶的操做。css
而 Angular Universal 會在服務端經過一個被稱爲服務端渲染(server-side rendering - SSR)的過程生成靜態的應用頁面。html
它能夠生成這些頁面,並在瀏覽器請求時直接用它們給出響應。 它也能夠把頁面預先生成爲 HTML 文件,而後把它們做爲靜態文件供服務端使用。java
要製做一個 Universal 應用,就要安裝 platform-server
包。 platform-server 包提供了服務端的 DOM 實現、XMLHttpRequest 和其它底層特性,但再也不依賴瀏覽器。node
你要使用 platform-server
模塊而不是 platform-browser
模塊來編譯這個客戶端應用,而且在一個 Web 服務器上運行這個 Universal 應用。webpack
服務器(下面的示例中使用的是 Node Express 服務器)會把客戶端對應用頁面的請求傳給 renderModuleFactory
函數。git
renderModuleFactory 函數接受一個模板 HTML 頁面(一般是 index.html)、一個包含組件的 Angular 模塊和一個用於決定該顯示哪些組件的路由做爲輸入。github
該路由從客戶端的請求中傳給服務器。 每次請求都會給出所請求路由的一個適當的視圖。web
renderModuleFactory 在模板中的 <app>
標記中渲染出哪一個視圖,併爲客戶端建立一個完成的 HTML 頁面。typescript
最後,服務器就會把渲染好的頁面返回給客戶端。
三個主要緣由:
幫助網絡爬蟲(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
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 方式運行時,該如何引導它。
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}`); }
src/main.server.ts
該文件導出服務端模塊:
export { AppServerModule } from './app/app.server.module';
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); });
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 編譯器的選項:
.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" } }
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}`); });
這個文件中最重要的部分是 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 響應把它轉發給了客戶端。
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)); });
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目錄:
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`)); } }
代碼運行以後,
服務端請求並打印:
客戶端再一次請求並打印:
TransferHttpCacheModule
使用 TransferHttpCacheModule
很簡單,代碼不須要改動。在 app.module.ts
中導入以後,Angular自動會將服務端請求緩存到客戶端,換句話說就是服務端請求到數據會自動傳輸到客戶端,客戶端接收到數據以後就不會再發送請求了。
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')
建立儲存傳輸數據的 StateKeyHomeComponent
的構造函數中注入 TransferState
ngOnInit
中根據 this.state.get(KFCLIST_KEY, null as any)
判斷數據是否存在(不論是服務端仍是客戶端),存在就再也不請求,不存在則請求數據並經過 this.state.set(KFCLIST_KEY, data as any)
存儲傳輸數據ngOnDestroy
中根據當前是否客戶端來決定是否將存儲的數據進行刪除最後,咱們分別經過這三個緣由來進行對比:
幫助網絡爬蟲(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'); }