服务端渲染
Angular Universal:服务端渲染
本指南讲的是Angular Universal(统一平台)
,一项在服务端运行 Angular 应用的技术。
标准的 Angular 应用会运行在浏览器
中,它会在 DOM 中渲染页面,以响应用户的操作。
而Angular Universal
会在服务端
通过一个名叫服务端渲染(server-side rendering - SSR)的过程
生成静态
的应用页面。
它可以生成这些页面,并在浏览器请求时直接用它们给出响应。 也可以把页面预先生成为 HTML 文件,然后把它们作为静态文件供服务器使用。
本指南讲的是一个 Universal 的范例应用,它启动得和在服务端渲染好的页面一样快。 稍后,浏览器就会下载完整的客户端版本,并在代码加载完之后自动切换过去。
你可以下载最终的范例代码,并将其运行在一个 Node.js® Express 服务器中。
为何需要 Universal
有三个主要的理由来为你的应用创建一个 Universal 版本。
- 帮助网络爬虫(SEO)
帮助网络爬虫
Google、Bing、Facebook、Twitter 和其它社交媒体网站都依赖网络爬虫去索引你的应用内容,并且让它的内容可以通过网络搜索到。
这些网络爬虫可能不会像人类那样导航到你的具有高度交互性的 Angular 应用,并为其建立索引。
Angular Universal 可以为你生成应用的静态版本,它易搜索、可链接,浏览时也不必借助 JavaScript。 它也让站点可以被预览,因为每个 URL 返回的都是一个完全渲染好的页面。
启用对网络爬虫的支持通常也称作搜索引擎优化 (SEO)。
提升手机和低功耗设备上的性能
有些设备不支持 JavaScript 或 JavaScript 执行得很差,导致用户体验不可接受。 对于这些情况,你可能会需要该应用的服务端渲染的、无 JavaScript 的版本。 虽然有一些限制,不过这个版本可能是那些完全没办法使用该应用的人的唯一选择。
快速显示第一页
快速显示第一页对于吸引用户是至关重要的。
如果页面加载超过了三秒钟,那么 53% 的移动网站会被放弃。 你的应用要启动得更快一点,以便在用户决定做别的事情之前吸引他们的注意力。
使用 Angular Universal,你可以
为应用生成“着陆页”,它们看起来就和完整的应用一样。 这些着陆页是纯 HTML,并且即使 JavaScript 被禁用了也能显示。 这些页面不会处理浏览器事件,不过它们可以
用 routerLink在这个网站中导航。
在实践中,你可能要使用一个着陆页的静态版本来保持用户的注意力。 同时,你也会在幕后加载完整的 Angular 应用,就像稍后解释的那样。 用户会觉得着陆页几乎是立即出现的,而当完整的应用加载完之后,又可以获得完整的交互体验。
工作原理
要制作一个 Universal 应用,就要安装 platform-server
包。 platform-server
包提供了服务端的 DOM 实现、XMLHttpRequest
和其它底层特性,但不再依赖浏览器。
你要使用 platform-server
模块而不是 platform-browser
模块来编译这个客户端应用,并且在一个 Web 服务器上运行这个 Universal 应用。
服务器(这个例子中使用的是 Node Express 服务器)会把客户端对应用页面的请求传给 renderModuleFactory
函数。
renderModuleFactory
函数接受一个模板
HTML 页面(通常是 index.html
)、一个包含组件的 Angular 模块
和一个用于决定该显示哪些组件的路由
作为输入。
该路由从客户端的请求中传给服务器。 每次请求都会给出所请求路由的一个适当的视图。
renderModuleFactory 在模板中的 <app> 标记中渲染出哪个视图,并为客户端创建一个完成的 HTML 页面。
最后,服务器就会把渲染好的页面返回给客户端。
使用浏览器 API
由于 Universal 的 platform-server
应用并没有运行在浏览器中,因此那些与浏览器 API 有关的工作都没法在这个服务器中使用。
你不能引用浏览器独有的原生对象,比如 window
、document
、navigator
或 location
。 如果你在服务端渲染的页面中不需要它们,就可以使用条件逻辑跳过它们。
另一种方式是查找一个可注入的 Angular 对所需对象的抽象服务,比如 Location
或 Document
,它可能作为你调用的指定 API 的等价替身。 如果 Angular 没有提供它,你也可以写一个自己的抽象层,当在浏览器中运行时,就把它委托给浏览器 API,挡在服务器中运行时,就提供一个符合要求的代用实现。
由于没有鼠标或键盘事件,因此 Universal 应用也不能依赖于用户点击某个按钮来显示每个组件。 Universal 应用应该仅仅根据客户端过来的请求决定要渲染的内容。 把该应用做成可路由的,就是一种好方案。
由于服务端渲染页面的用户只能点击链接,所以你应该尽快让它切换到真正的客户端应用,以提供正常的交互体验。
例子
《英雄指南》教程是本章所讲的 Universal 范例的基础。
除了下面讲的少量修改之外,应用中的核心文件几乎不用动。 你只需要添加一些额外的文件来支持使用 Universal 进行构建和提供服务。
在这个例子中,Angular CLI 会使用 AOT (预先) 编译器对该应用的 Universal 版本进行编译和打包。 Node.js® 的 Express Web 服务器会把客户端请求转换成由 Universal 渲染出的页面。
你将会创建:
- 一个服务端的 app 模块
app.server.module.ts
当做完这些后,文件夹的结构是这样的:
content_copysrc/
index.html 应用的宿主页
main.ts 客户端应用的引导程序
main.server.ts * 服务端应用的引导程序
tsconfig.app.json TypeScript 的客户端配置
tsconfig.server.json * TypeScript 的服务端配置
tsconfig.spec.json TypeScript 的测试配置
style.css 应用的样式表
app/ ... 应用代码
app.server.module.ts * 服务端的应用模块
server.ts * Express 的服务程序
tsconfig.json TypeScript 的客户端配置
package.json npm 配置
webpack.server.config.js * Webpack 的服务端配置
那些标有 *
的文件都是新的,而不是来自原来的范例应用。 本文稍后的部分会涉及它们。
准备工作
下载《英雄指南》项目,并为它安装依赖。
安装工具
在开始之前,要安装下列包。
@angular/platform-server
- Universal 的服务端元件。
使用下列命令安装它们:
content_copynpm install --save @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader @nguniversal/express-engine
修改客户端应用
Universal 应用可以扮演一个动态的、内容丰富的 “封面页”,以吸引用户。 它能让应用看上去几乎是立即呈现出来的。
同时,浏览器会在后台下载客户端应用的脚本。 一旦加载完毕,Angular 就会从静态的服务端渲染的页面无缝转换成动态渲染的可交互的客户端应用。
你要对应用代码做少量修改,以支持服务端渲染,并无缝转换成客户端应用。
根模块 AppModule
打开 src/app/app.module.ts
文件,并在 NgModule
的元数据中找到对 BrowserModule
的导入。 把该导入改成这样:
src/app/app.module.ts (withServerTransition)
content_copyBrowserModule.withServerTransition{ appId: 'tour-of-heroes' }),
Angular 会把 appId
值(它可以是任何
字符串)添加到服务端渲染页面的样式名中,以便在客户端应用启动时可以找到并移除它们。
你可以通过依赖注入取得关于当前平台和 appId
的运行时信息。
src/app/app.module.ts (platform detection)
content_copyimport { PLATFORM_ID, APP_ID, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
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}`
}
Build Destination
A Universal app is distributed in two parts: the server-side code that serves up the initial application, and the client-side code that's loaded in dynamically.
The Angular CLI outputs the client-side code in the dist
directory by default, so you modify the outputPath
for the build
target in the angular.json
to keep the client-side build
outputs separate from the server-side code. The client-side build
output will be served by the Express server.
content_copy...
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/browser",
...
}
}
...
在 HTTP 中使用绝对地址
教程中的 HeroService
和 HeroSearchService
都委托了 Angular 的 HttpClient
模块来获取应用数据。 那些服务都把请求发送到了相对
URL,比如 api/heroes
。
在 Universal 应用中,HTTP 的 URL 必须是绝对地址
(比如 https://my-server.com/api/heroes
), 只有这样,Universal 的 Web 服务器才能处理那些请求。
你还要修改这些需要发起请求的服务,当它运行在服务端时使用绝对地址,运行在浏览器中时用相对地址。
解决方案之一是通过 Angular 的 APP_BASE_HREF
令牌来提供服务器的源地址(origin),把它注入到服务中,并把这个源地址添加到所请求的 URL 之前。
先为 HeroService
的构造函数添加第二个 origin
参数,它是可选的,并通过 APP_BASE_HREF
令牌进行注入。
src/app/hero.service.ts (constructor with optional origin)
content_copyconstructor(
private http: HttpClient,
private messageService: MessageService,
@Optional() @Inject(APP_BASE_HREF) origin: string) {
this.heroesUrl = `${origin}${this.heroesUrl}`;
}
注意,这个构造函数是如何把这个 origin
(如果存在)添加到 heroesUrl
的前面的。
在浏览器版本中,你不用提供 APP_BASE_HREF
,因此 heroesUrl
仍然是相对的。
如果你在 index.html 中为了满足路由器的需求已经指定过了 <base href="/">,那就可以在浏览器中忽略 APP_BASE_HREF。参见教程的例子。
服务端代码
要想运行 Angular 的 Universal 应用,你需要一个服务器,用它来接受客户端的请求,并返回渲染好的页面。
服务端应用模块
服务端应用模块(习惯上叫作 AppServerModule
)是一个 Angular 模块,它包装了应用的根模块 AppModule
,以便 Universal 可以在你的应用和服务器之间进行协调。 AppServerModule
还会告诉 Angular 在把你的应用以 Universal 方式运行时,该如何引导它。
在 src/app/
目录下创建 app.server.module.ts
文件,代码如下:
src/app/app.server.module.ts
content_copyimport { NgModule } from '@angular/core';import { ServerModule } from '@angular/platform-server';import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; import { AppModule } from './app.module';import { AppComponent } from './app.component'; @NgModule{ imports: [ AppModule, ServerModule, ModuleMapLoaderModule ], providers: [ // Add universal-only providers here ], bootstrap: [ AppComponent ],})export class AppServerModule {}
注意它首先导入了客户端应用的 AppModule
和来自 Angular Universal 的 ServerModule
、ModuleMapLoaderModule
。
ModuleMapLoaderModule
是一个服务端模块,用于实现路由的惰性加载。
这里还可以注册那些在 Universal 环境下运行应用时特有的服务提供商。
应用服务器的入口点
Angular CLI 使用 AppServerModule
来构建服务端发布包。
在 src/
目录下创建一个 main.server.ts
文件,并导出 AppServerModule
:
src/main.server.ts
content_copyexport { AppServerModule } from './app/app.server.module';
稍后,这个 main.server.ts
文件将会被引用,以便往 Angular CLI 的配置中添加一个名为 server
的目标(target)。
Universal Web 服务器
Universal
Web 服务器负责响应对本应用的页面
请求。它使用的是由 Universal
模板引擎渲染出的 HTML。
它接受并响应来自客户端(通常是浏览器)的 HTTP 请求。 它还会提供那些静态文件,比如脚本、css 和图片。 它还可以响应数据请求,可能直接响应也可能将其代理到一个独立的数据服务器。
本文中的范例 Web 服务器基于常见的 Express 框架。
任何
Web 服务器技术都可以作为 Universal 应用的服务器,只要它能调用 Universal 的 renderModuleFactory
即可。 下面讨论的这些原则和决策要点适用于你选择的任何
Web 服务器技术。
在根目录下创建 server.ts
文件,并添加下列代码:
server.ts
content_copy// These are important and needed before anything elseimport 'zone.js/dist/zone-node';import 'reflect-metadata'; import { enableProdMode } from '@angular/core'; import * as express from 'express';import { join } from 'path'; // Faster server renders w/ Prod mode (dev mode never needed)enableProdMode( // Express serverconst app = express( const PORT = process.env.PORT || 4000;const DIST_FOLDER = join(process.cwd(), 'dist' // * NOTE :: leave this as require() since this file is built Dynamically from webpackconst { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/server/main' // Express Engineimport { ngExpressEngine } from '@nguniversal/express-engine';// Import module map for lazy loadingimport { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader'; app.engine('html', ngExpressEngine{ bootstrap: AppServerModuleNgFactory, providers: [ provideModuleMap(LAZY_MODULE_MAP) ]}) app.set('view engine', 'html'app.set('views', join(DIST_FOLDER, 'browser') // TODO: implement data requests securelyapp.get('/api/*', (req, res) => { res.status(404).send('data requests are not supported'} // Server static files from /browserapp.get('*.*', express.static(join(DIST_FOLDER, 'browser')) // All regular routes use the Universal engineapp.get('*', (req, res) => { res.render('index', { req }} // Start up the Node serverapp.listen(PORT, () => { console.log(`Node server listening on http://localhost:${PORT}`}
这个范例服务器是不安全的!
如果你要把它作为正式的 Angular 应用服务器,别忘了添加中间件来对用户进行认证和授权。
Universal 模板引擎
这个文件中最重要的部分是 ngExpressEngine
函数:
server.ts
content_copyapp.engine('html', ngExpressEngine{
bootstrap: AppServerModuleNgFactory,
providers: [
provideModuleMap(LAZY_MODULE_MAP)
]
})
ngExpressEngine
是对 Universal 的 renderModuleFactory
函数的封装。它会把客户端请求转换成服务端渲染的 HTML 页面。 你还要在某个适用于你服务端技术栈的模板引擎
中调用这个函数。
第一个参数是你以前写过的 AppServerModule
。 它是 Universal 服务端渲染器和你的应用之间的桥梁。
第二个参数是 extraProviders
。它是在这个服务器上运行时才需要的一些可选的 Angular 依赖注入提供商。
当你的应用需要那些只有当运行在服务器实例中才需要的信息时,就要提供 extraProviders
参数。
这里所需的信息就是正在运行的服务器的源地址,它通过 APP_BASE_HREF
令牌提供,以便应用可以 计算出 HTTP URL 的绝对地址。
ngExpressEngine
函数返回了一个会解析成渲染好的页面的承诺(Promise)
。
接下来你的引擎要决定拿这个页面做点什么。 现在这个引擎
的回调函数中,把渲染好的页面返回给了 Web 服务器,然后服务器通过 HTTP 响应把它转发给了客户端。
这个包装器对于隐藏 renderModuleFactory
的复杂性非常有帮助。 在 Universal 代码库中还有更多针对其它后端技术的包装器。
过滤请求的 URL
Web 服务器必须把对应用页面的请求
和其它类型的请求区分开。
这可不像拦截对根路径 /
的请求那么简单。 浏览器可以请求应用中的任何一个
路由地址,比如 /dashboard
、/heroes
或 /detail:12
。 事实上,如果应用只
会通过服务器渲染,那么应用中点击的任何一个
链接都会发到服务器,就像导航时的地址会发到路由器一样。
幸运的是,应用的路由具有一些共同特征:它们的 URL 一般不带文件扩展名。
数据请求也可能不带扩展名,不过他们很容易识别出来,因为它们总是用 /api
开头。
所有静态资源请求都具有一个扩展名(比如 main.js
或 /node_modules/zone.js/dist/zone.js
)。
所以,我们很容易识别出这三种类型的请求,并用不同的方式处理它们。
- 数据请求 - 请求的 URL 用
/api
开头
Express 服务器是一系列中间件构成的管道,它会挨个对 URL 请求进行过滤和处理。
你通过通过调用 app.get()
来配置 Express 服务器的管道,就像下面这个数据请求一样:
server.ts (data URL)
content_copy// TODO: implement data requests securely
app.get('/api/*', (req, res) => {
res.status(404).send('data requests are not supported'
}
这个范例服务器并没有处理数据请求。
本教程的“内存 Web API” 模块(一个演示及开发工具)拦截了所有 HTTP 调用,并且模拟了远端数据服务器的行为。 在实践中,你应该移除这个模块,并且在服务器上注册你的 Web API 中间件。
Universal HTTP 请求具有不同的安全需求
从浏览器的应用中发起 HTTP 请求和从服务器的 Universal 应用中发起请求是不一样的。
当浏览器发起 HTTP 请求时,服务器可以假设存在 Cookie、XSRF 头等等。
比如,浏览器会自动发送当前用户的认证 Cookie。 但 Angular Universal 不能把这些凭证发送给独立的数据服务器。 如果你的服务器要处理 HTTP 请求,你就得自行添加安全装置。
下列代码会过滤出不带扩展名的 URL,并把它们当做导航请求进行处理。
server.ts (navigation)
content_copy// All regular routes use the Universal engine
app.get('*', (req, res) => {
res.render('index', { req }
}
安全的提供静态文件
单独的 app.use()
会处理所有其它 URL,比如对 JavaScript 、图片和样式表等静态资源的请求。
要保证客户端只能下载那些允许
他们访问的文件,你应该把所有面向客户端的资源文件都放在 /dist
目录下,并且只允许
客户端请求来自 /dist
目录下的文件。
下列 Express 代码会把剩下的所有请求都路由到 /dist
目录下,如果文件未找到,就会返回 404 - NOT FOUND
。
server.ts (static files)
content_copy// Server static files from /browser
app.get('*.*', express.static(join(DIST_FOLDER, 'browser'))
配置 Universal
这个服务端应用需要自己的构建配置。
Universal 的 TypeScript 配置
在项目的根目录下创建一个 tsconfig.server.json
文件来配置 TypeScript 和这个 Universal 应用的 AOT 编译选项。
src/tsconfig.server.json
content_copy{ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", "baseUrl": "./", "module": "commonjs", "types": [] }, "exclude": [ "test.ts", "**/*.spec.ts" ], "angularCompilerOptions": { "entryModule": "app/app.server.module#AppServerModule" }}
这个配置扩展了根目录下的 tsconfig.json
文件,注意它们在某些设置上的差异。
module
属性必须是commonjs
,这样它才能被require()
进你的服务端应用。
Universal 的 Webpack 配置
Universal 应用不需要任何额外的 Webpack 配置,CLI 会帮你处理它们,但是由于这个服务器是 TypeScript 应用,所以你要使用 Webpack 来转译它。
在项目的根目录下创建一个 webpack.server.config.js
文件,代码如下:
webpack.server.config.js
content_copyconst path = require('path'const webpack = require('webpack' module.exports = { entry: { server: './server.ts' }, resolve: { extensions: ['.js', '.ts'] }, target: 'node', mode: 'none', // this makes sure we include node_modules and other 3rd party libraries externals: [/node_modules/], output: { path: path.join(__dirname, 'dist'), filename: '[name].js' }, module: { rules: [{ test: /\.ts$/, loader: 'ts-loader' }] }, plugins: [ // Temporary Fix for issue: https://github.com/angular/angular/issues/11580 // for 'WARNING Critical dependency: the request of a dependency is an expression' new webpack.ContextReplacementPlugin( /(.+)?angular(\\|\/)core(.+)?/, path.join(__dirname, 'src'), // location of your src {} // a map of your routes ), new webpack.ContextReplacementPlugin( /(.+)?express(\\|\/)(.+)?/, path.join(__dirname, 'src'), {} ) ]};
Webpack
配置超出了本文的讨论范围。
Angular CLI 配置
CLI 提供了针对不同目标
的构建器。常见的目标
,如 build
和 serve
都已经存在于 angular.json
的配置中了。要指定一个服务端渲染的构建,请在 architect
配置对象中添加一个 server
目标
。
outputPath
表明要把构建结果放到哪里。
content_copy"architect": { ... "server": { "builder": "@angular-devkit/build-angular:server", "options": { "outputPath": "dist/server", "main": "src/main.server.ts", "tsConfig": "src/tsconfig.server.json" } } ...}
使用 Universal 构建和运行
现在,你已经创建了 TypeScript 和 Webpack 的配置文件并配置好了 Angular CLI,可以构建并运行这个 Universal 应用了。
First add the build
and serve
commands to the scripts
section of the package.json
:
content_copy"scripts": {
...
"build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
"serve:ssr": "node dist/server",
"build:client-and-server-bundles": "ng build --prod && ng run angular.io-example:server",
"webpack:server": "webpack --config webpack.server.config.js --progress --colors"
...
}
构建
在命令行提示中输入
content_copynpm run build:ssr
Angular CLI 就会把这个 Universal 应用编译进两个目录:browser
和 server
。 Webpack 会把 server.ts
文件转译成 JavaScript。
启动服务器
构建完应用之后,启动服务器。
content_copynpm run serve:ssr
在控制台窗口中应该看到
content_copyNode server listening on http://localhost:4000
Universal 实战
打开浏览器,访问 http://localhost:4000/。 你会看到熟悉的《英雄指南》仪表盘页。
通过 routerLink
能进行正常导航。 你可以从仪表盘导航到英雄列表页,还可以返回。 你可以在仪表盘页点击一个英雄,以显示他的详情页。
但是点击、鼠标移动和键盘操作都不行。
- 点击英雄列表页中的英雄没反应。
除了点击 RouterLink
之外的用户事件都不支持。用户必须等待完整的客户端应用就绪。
直到你编译出客户端应用,并把它们的输出移到 dist/
目录下,这个客户端应用才会就绪。 你自己稍微花点时间完成这步就可以了。
限流
在开发机上,从服务端渲染应用到客户端应用的转换完成的太快了。 你可以模拟一个慢速网络,来把这个转换过程看得更清楚一点,以便更好地欣赏在性能低、网络烂的设备上 Universal 应用的启动速度优势。
打开 Chrome 开发工具,并打开 Network 页。 在菜单栏的最右侧找到 Network Throttling(网络限流) 下拉框。
随便试一个 “3G” 速度。 服务端渲染的应用将会立即启动,不过加载完整的客户端应用可能要花几秒钟。
小结
本文为你演示了如何把一个现有 Angular 应用转换成支持服务端渲染的 Universal 应用。 还解释了为何要这么做的一些关键原因。
- 帮助网络爬虫(SEO)
Angular Universal 可以大幅提升应用的启动性能观感。 在越是慢速的网络下,使用 Universal 来为用户展现首屏就越能体现出更大的优势。