本文介绍 Angular Universal(统一平台),一项在服务端运行 Angular 应用的技术,即服务器端渲染。
如下图 package.json 里定义的依赖 @nguniversal/express-engine 所示:
标准的 Angular 应用会运行在浏览器中,它会在 DOM 中渲染页面,以响应用户的操作。 而Angular Universal 会在服务端运行,生成一些静态的应用页面,稍后再通过客户端进行启动。 这意味着该应用的渲染通常会更快,让用户可以在应用变得完全可交互之前,先查看应用的布局。
服务器端渲染返回的 HTML 源代码,虽然没有加载对应的 .js 文件,无法响应用户输入,但是可以给用户一个直观完整的页面布局。
Node.js Express Web 服务器则会根据客户端的请求,利用 Universal 编译 HTML 页面。
要创建服务端应用模块 app.server.module.ts,请运行以下 CLI 命令:
ng add @nguniversal/express-engine
该命令会自动生成如下绿色高亮所示的文件:
要使用 Universal 在本地系统中渲染你的应用,请使用如下命令:
npm run dev:ssr
这个 serve-ssr 定义在 Angular.json 里:
而 server target 定义如下:
通过 routerLinks 导航时能正常工作,因为它们使用的是原生的链接标签(<a>).
不支持除了点击 routerLink 以外的任何用户事件。你必须等待完整的客户端应用启动并运行,或者使用 preboot 之类的库来缓冲这些事件,这样你就可以在客户端脚本加载完毕后重放这些事件。
Angular Universal 可以为你生成应用的静态版本,它易搜索、可链接,浏览时也不必借助 JavaScript。 它也让站点可以被预览,因为每个 URL 返回的都是一个完全渲染好的页面。
使用 Angular Universal,你可以为应用生成“着陆页”,它们看起来就和完整的应用一样。 这些着陆页是纯 HTML,并且即使 JavaScript 被禁用了也能显示。 这些页面不会处理浏览器事件,不过它们可以用 routerLink 在这个网站中导航。
在实践中,你可能要使用一个着陆页的静态版本来保持用户的注意力。 同时,你也会在幕后加载完整的 Angular 应用。 用户会觉得着陆页几乎是立即出现的,而当完整的应用加载完之后,又可以获得完整的交互体验。
Universal Web 服务器使用 Universal 模板引擎渲染出的静态 HTML 来响应对应用页面的请求。 服务器接收并响应来自客户端(通常是浏览器)的 HTTP 请求,并回复静态文件,如脚本、CSS 和图片。 它可以直接响应数据请求,也可以作为独立数据服务器的代理进行响应。
任何一种 Web 服务器技术都可以作为 Universal 应用的服务器,只要它能调用 Universal 的 renderModule() 函数。 这里所讨论的这些原则和决策点也适用于任何 Web 服务器技术。
Universal 应用使用 platform-server 包(而不是 platform-browser),它提供了 DOM 的服务端实现、XMLHttpRequest 以及其它不依赖浏览器的底层特性。
服务器(这个例子中使用的是 Node.js Express 服务器)会把客户端对应用页面的请求传给 NgUniversal 的 ngExpressEngine。在内部实现上,它会调用 Universal 的 renderModule() 函数,它还提供了缓存等有用的工具函数。
关于具体的调试步骤,参考我这些文章:
使用浏览器 API
由于 Universal 应用并没有运行在浏览器中,因此该服务器上可能会缺少浏览器的某些 API 和其它能力。
比如,服务端应用不能引用浏览器独有的全局对象,比如 window、document、navigator 或 location。
Angular 提供了一些这些对象的可注入的抽象层,比如 Location 或 DOCUMENT,它可以作为你所调用的 API 的等效替身。 如果 Angular 没有提供它,你也可以写一个自己的抽象层,当在浏览器中运行时,就把它委托给浏览器 API,当它在服务器中运行时,就提供一个符合要求的代用实现(也叫垫片 - shimming)。
同样,由于没有鼠标或键盘事件,因此 Universal 应用也不能依赖于用户点击某个按钮来显示每个组件。 Universal 应用必须仅仅根据客户端过来的请求决定要渲染的内容。 把该应用做成可路由的,就是一种好方案。
Universal 模板引擎
server.ts 的核心逻辑如下代码所示:
const server = express();
const distFolder = join(process.cwd(), 'dist/mystore/browser');
const indexHtml = existsSync(join(distFolder, 'index.original.html'))
? 'index.original.html'
: 'index';
server.set('trust proxy', 'loopback');
server.engine(
'html',
ngExpressEngine({
bootstrap: AppServerModule,
})
);
其中 ngExpressEngine 的来源:
const ngExpressEngine = NgExpressEngineDecorator.get(engine, { timeout: 90000, concurrency: 1,
forcedSsrTimeout:90000,
maxRenderTime:100000,
cache: true, cacheSize: 10,
renderingStrategyResolver: (req) => RenderingStrategy.DEFAULT});
- 1
- 2
- 3
- 4
- 5
ngExpressEngine() 是对 Universal 的 renderModule() 函数的封装。它会把客户端请求转换成服务端渲染的 HTML 页面。它接受一个具有下列属性的对象,类型为 NgSetupOptions:
bootstrap:在服务器上渲染时用于引导应用程序的根 NgModule 或 NgModule 工厂。对于 SAP Commerce Cloud 应用,它是 AppServerModule。它是 Universal 服务端渲染器和 Angular 应用之间的桥梁。
ngExpressEngine() 函数返回了一个会解析成渲染好的页面的承诺(Promise)。 接下来你的引擎要决定拿这个页面做点什么。 在这个引擎的 Promise 回调函数中,把渲染好的页面返回给了 Web 服务器,然后服务器通过 HTTP 响应把它转发给了客户端。
过滤请求的 URL
Web 服务器必须把对应用页面的请求和其它类型的请求区分开。
这可不像拦截对根路径 / 的请求那么简单。 浏览器可以请求应用中的任何一个路由地址,比如 /dashboard、/heroes 或 /detail:12。 事实上,如果应用只会通过服务器渲染,那么应用中点击的任何一个链接都会发到服务器,就像导航时的地址会发到路由器一样。
幸运的是,应用的路由具有一些共同特征:它们的 URL 一般不带文件扩展名。 (数据请求也可能缺少扩展名,但是它们很容易识别出来,因为它们总是以 /api 开头,所有的静态资源的请求都会带有一个扩展名,比如 main.js 或 /node_modules/zone.js/dist/zone.js)。
由于使用了路由,所以我们可以轻松的识别出这三种类型的请求,并分别处理它们。
- 数据请求:请求的 URL 用 /api 开头
- 应用导航:请求的 URL 不带扩展名
- 静态资源:所有其它请求。
Node.js Express 服务器是一系列中间件构成的管道,它会挨个对 URL 请求进行过滤和处理。 你可以调用 app.get() 来配置 Express 服务器的管道,就像下面这个数据请求一样:
// TODO: implement data requests securely
server.get('/api/**', (req, res) => {
res.status(404).send('data requests are not yet supported');
});
- 1
- 2
- 3
- 4
上述代码的含义是,当前 SSR 服务器,不支持处理数据请求。
下列代码会过滤出不带扩展名的 URL,并把它们当做导航请求进行处理。
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] });
});
- 1
- 2
- 3
- 4
安全的提供静态文件
单独的 server.use() 会处理所有其它 URL,比如对 JavaScript 、图片和样式表等静态资源的请求。
要保证客户端只能下载那些允许他们访问的文件,你应该把所有面向客户端的资源文件都放在 /dist 目录下,并且只允许客户端请求来自 /dist 目录下的文件。
const distFolder = join(process.cwd(), 'dist/mystore/browser');
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y'
}));
- 1
- 2
- 3
- 4
- 5
更多Jerry的原创文章,尽在:“汪子熙”: