Angular在服務端渲染方面提供一套前後端同構解決方案,它就是 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)
提昇在手機和低功耗設備上的效能
迅速顯示出第首頁
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
创建 TypeScript 的服务端配置:src/tsconfig.server.json
修改 @angular/cli 的配置文件:.angular-cli.json
创建 Node Express 的服务程序:server.ts
创建服务端预渲染的程序:prerender.ts
创建 Webpack 的服务端配置: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 编译器的选项:
entryModule - 服务端应用的根模块,其格式为 path/to/file#ClassName。
.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')
创建储存传输数据的 StateKey
在 HomeComponent
的构造函数中注入 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'); }
解决应用程序在服务器上运行XHR请求,并在客户端再次运行的问题
使用从服务器传输到客户端的缓存(TransferState)
清楚了解与DOM相关的属性和属性之间的差异
尽量让指令无状态。对于有状态指令,您可能需要提供一个属性,以反映相应属性的初始字符串值,例如img标签中的url。对于我们的native元素,src属性被反映为元素类型HTMLImageElement的src属性
相关推荐:
以上是Angular開發實踐(六):服務端渲染的詳細內容。更多資訊請關注PHP中文網其他相關文章!