基于 Angular Universal 引擎进行服务器端渲染的前端应用 State Transfer 故障排查案例

简介: 基于 Angular Universal 引擎进行服务器端渲染的前端应用 State Transfer 故障排查案例

笔者之前这篇掘金文章一个 SAP 开发工程师的 2022 年终总结:四十不惑 提到,我目前的团队,负责开发一款基于 Angular 框架的电商 Storefront 应用。


这个 Storefront 是一个开源的、基于 Angular 和 Bootstrap 并为 SAP Commerce Cloud 构建的 Angular 应用程序。


dadfbda2f2832e7077f1c3aec6b47a18_format,png.png


图1:Spartacus Storefront 的 home page


我们都知道,在电商领域里,搜索引擎优化 (Search Engine Optimization,SEO) 对任何一个 Storefront 来说都是至关重要的,它可以使电商网站更容易被搜索引擎检索到。


然而,迄今为止,许多搜索引擎的爬虫在解析和索引网站内容时,还没有办法完全解析 Angular 这种单页面应用(SPA-Single Page Application) 在浏览器端渲染的 HTML 内容。因此在电商领域,使用 Angular + Universal 引擎来开启应用的服务器端渲染,几乎成了一种标配,我们团队负责开发的 Spartacus 也不例外。


最近我在工作中处理了几例客户反馈的关于 Angular 应用在服务器端渲染下的 State Transfer 故障的处理,特将其中之一摘录出来供广大 Angular 开发同仁参考。


什么是 Angular Universal

Angular Universal 是 Angular 的服务端渲染(Server-Side Rendering,SSR)解决方案。


传统的 Angular 应用都是单页应用(SPA),所有的视图渲染都在客户端完成。当用户访问一个 SPA 网站时,服务器只会发送一个包含整个应用代码的 JavaScript 文件,然后在用户的浏览器中运行这个 JavaScript 文件来生成网页内容。这就意味着,用户在访问网页的初期可能会遇到一个空白页面,需要等待 JavaScript 文件下载、解析和运行完成后才能看到完整的网页内容。


相比之下,服务端渲染的应用,在服务器上进行渲染,完成网页静态内容 HTML 的生成工作 ,然后将这个 HTML 发送给用户。这样,用户在访问网页的初期就能看到完整的网页内容,不需要等待 JavaScript 文件下载、解析和运行。这种方式可以提高首屏加载速度,改善用户体验,同时对于搜索引擎优化 SEO 也更友好。


Angular Universal 就是 Angular 提供的一种服务端渲染解决方案。它通过在服务器上运行 Angular 应用来生成静态 HTML,然后将这个 HTML 发送给用户。当用户在浏览器中接收到这个 HTML 后,Angular 会接管网页,将其升级为一个完整的 SPA。下图是 Angular Universal 官方文档的截图:


6e7f2786408922a8c5cd0f2e0e0a8432_format,png.png


图2:Angular Universal 官方文档


下图是 Spartacus 应用没有开启服务器端渲染的效果,在 Chrome 开发者工具 Network 标签页里,我们能观察到,cx-storefront 这个元素里只有 loading… 这个占位符。


8e150a96e0e9be34e815fb2102e053e7_format,png.png


图3:CSR(Client Side Render)模式下的 Spartacus 首页渲染请求


再来比较 Spartacus 开启了服务器端渲染之后的效果。显然,下图绿色高亮区域里的 HTML 内容,就是在服务器端完成渲染并返回到客户端的静态内容。


f4171fcb035f77dee4d5688ebd2e99ca_format,png.png


图4:SSR(Server Side Render)模式下的 Spartacus 首页渲染请求


Spartacus 服务器端渲染的入口逻辑定义在 server.ts 文件内:

import { APP_BASE_HREF } from '@angular/common';
import { ngExpressEngine as engine } from '@nguniversal/express-engine';
import {
  defaultSsrOptimizationOptions,
  NgExpressEngineDecorator,
  SsrOptimizationOptions,
} from '@spartacus/setup/ssr';
import { Express } from 'express';
import { existsSync } from 'fs';
import { join } from 'path';
import 'zone.js/node';
import { AppServerModule } from './src/main.server';
const express = require('express');
const ssrOptions: SsrOptimizationOptions = {
  timeout: Number(
    process.env['SSR_TIMEOUT'] ?? defaultSsrOptimizationOptions.timeout
  ),
};
const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions);
export function app() {
  const server: Express = express();
  const distFolder = join(process.cwd(), 'dist/storefrontapp');
  const indexHtml = existsSync(join(distFolder, 'index.original.html'))
    ? 'index.original.html'
    : 'index';
  server.set('trust proxy', 'loopback');
  server.engine(
    'html',
    ngExpressEngine({
      bootstrap: AppServerModule,
    })
  );
  server.set('view engine', 'html');
  server.set('views', distFolder);
  server.get(
    '*.*',
    express.static(distFolder, {
      maxAge: '1y',
    })
  );
  server.get('*', (req, res) => {
    res.render(indexHtml, {
      req,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
    });
  });
  return server;
}
function run() {
  const port = process.env['PORT'] || 4000;
  // Start up the Node server
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule && mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
  run();
}
export * from './src/main.server';


其中下图第 62 行高亮的代码块,就是 Angular Universal 引擎在服务器端渲染 HTML 页面的入口和核心。


4d0da161244afa808394f0d6a42f8b27_format,png.png


图5:Spartacus 调用 Angular Universal 引擎在服务器端渲染的入口代码


为什么 Angular 服务器端渲染应用需要 State Transfer

要回答这个问题,我们首先要弄清楚什么是 State Transfer.


在 Angular Universal 中,State Transfer 主要是指在服务器端渲染完成后,将服务器端的状态传递给客户端的过程。这样可以避免客户端重新获取和计算已经在服务器端获取和计算过的数据,从而提高应用的性能。


具体来说,State Transfer 是通过 TransferState 服务来实现的。TransferState 服务提供了一种在服务器端和客户端之间共享状态的方式。在服务器端,你可以将一些数据存储到 TransferState 中,然后在客户端,你可以从 TransferState 中取出这些数据。


举个例子,假设你的 Angular 应用需要从服务器获取一些数据然后显示在视图中。在没有使用 Angular Universal 的情况下,当用户打开网页时,浏览器首先需要下载和运行 JavaScript 代码,然后 JavaScript 代码会向服务器发送请求获取数据,最后再将数据显示在视图中。这个过程可能会比较慢,因为需要等待 JavaScript 代码下载和运行,以及等待服务器响应数据请求。


但是,如果你使用了 Angular Universal 和 TransferState 服务,那么这个过程就会快很多。当服务器接收到用户的请求时,它会运行 Angular 应用,并向服务器发送数据请求,然后将获取的数据存储到 TransferState 中并生成视图,最后将视图和 TransferState 一起发送给客户端。当客户端接收到服务器的响应时,它不需要再向服务器发送数据请求,而是直接从 TransferState 中取出数据,然后将数据显示在视图中。这样就大大减少了首次加载页面的时间。


以上就是 Angular Universal 中的 State Transfer 工作的概要介绍。下面我们看看这个机制在 Spartacus 工作中的实际例子。


以 Spartacus product category 页面为例,相对 url 为:


/electronics-spa/en/USD/Open-Catalogue/Cameras/Digital-Cameras/c/575


当在 CSR 模式下渲染时,返回请求页面的 Size 连 1KB 都不到,原因之前已经说了,cx-storefront 元素内只有一个 loading... 的占位符,其内容是当 Angular 客户端 Bootstrap 之后,在浏览器里完成填充的。


09ee3a3f3f3779e8737681fec6241d89_format,png.png


图6:Spartacus 产品 category 页面在 CSR 模式下的返回结果


再看相同的页面在 Spartacus 开启了服务器端渲染后的行为。整个请求的 size 达到了 288 kb.


cf50dc24dc0c4d7110a6bf4c1290780b_format,png.png


图7:Spartacus 产品 category 页面在 SSR 模式下的返回结果


究其原因,本章节介绍的 State Transfer 就贡献了很大一部分的数据规模。


我们在 SSR 模式下服务器返回给浏览器的 HTML response 里,根据关键字 app-state 进行搜索,找到一个 id 为 spartacus-app-state 的 script 标签元素。


e69955eb3139b64bd5c9a3926dd1c802_format,png.png


图8:Spartacus 服务器端渲染后 HTML 里包含的 State Transfer 数据


这个 script 元素的类型为 application/json,里面包含的值就是 Angular 应用在服务器端渲染时,调用的 AJAX 请求从 API 服务器获取的业务数据,通过 State Transfer 将这些数据序列化成 JSON 格式。


我们随便在 UI 上找一些产品业务数据,都可以在 spartacus-app-state 这个 script 元素里找到对应的 State 数据。下图是一个例子:


ed2652b80869631c1da73543226ec816_format,png.png


图9:Spartacus CSR 从 spartacus-app-state script 元素中提取 state 数据


实际业务中的故障

尽管产品 Category 页面从服务器端返回的结果,从上图9能看出,已经在 spartacus-app-state 这个 script 元素里,包含了所有的产品业务数据,但是当 Angular 应用在客户端 bootstrap 并重新渲染时,我们仍然能够在 Chrome 开发者工具的 Network 面板里,观察到一个重复的 product search API 请求:


823cc60cf363c351ec3036bb6bd021ec_format,png.png


图10:在 Angular 客户端不必要的 Product search API 请求


显然这个请求是毫无必要,应该避免的:


cf138ca44b03dea12848f87733aa583a_format,png.png


我们在调试器里观察一下客户端发起这个请求的上下文:


76df2165daaadb780be425465a87347a_format,png.png


发现是在 ProductSearchService 这个 Service 类里发起的请求。


于是,我们可以通过扩展这个 Service 类的方式,来修复这个故障。


我们编写下面的 TypeScript 代码:


export class CustomProductSearchService extends ProductSearchService {
  transferState = inject(TransferState, { optional: true });
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  isHydrated = false;
  results$ = new Subject<ProductSearchPage>();
  override search(query: string | undefined, searchConfig?: SearchConfig) {
    if (this.isBrowser && !this.isHydrated) {
      const state = this.transferState?.get(CX_KEY, {} as StateWithProduct)!;
      const results = state[PRODUCT_FEATURE].search.results;
      this.results$.next(results);
      this.isHydrated = true;
      return;
    }
    super.search(query, searchConfig);
  }
  override getResults() {
    return merge(super.getResults(), this.results$);
  }
}
const CX_KEY = makeStateKey<StateWithProduct>('cx-state');

d265ca7ea06068ac899e7e465676344a_format,png.png

  图13:修复客户端渲染发出多余 API 请求的实现代码

1.这个故障修复的思路是,首先在 Angular 中扩展了 Spartacus 标准的ProductSearchService 服务类,然后重载(override)其 search 方法。


2.

  transferState = inject(TransferState, { optional: true });

这一行注入了一个名为 TransferState 的服务,用于在服务器端渲染(SSR)和浏览器之间传递状态。TransferState 是 Angular Universal 的一部分,{ optional: true } 参数的意思是,如果无法找到 TransferState 服务,也不会报错。


3.

  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

这一行检测当前代码是否在浏览器环境中运行。PLATFORM_ID 是 Angular 提供的一个令牌,它在运行时会被替换为一个特定平台的 ID,isPlatformBrowser 是一个函数,如果当前 Angular 应用运行在浏览器环境里,那么这个函数会返回 true。


4.

  isHydrated = false;

这一行声明了一个布尔类型的标志位 isHydrated,初始化为 false。这个标志位用来追踪是否已经从 TransferState 中恢复了状态。


5.

  results$ = new Subject<ProductSearchPage>();

这一行创建了一个新的 RxJS Subject,Subject 是 RxJS 中的一种特殊类型的 Observable,它可以发出新的值,并将这些值推送给所有订阅者。


6.重载标准服务类的 search 方法。

  override search(query: string | undefined, searchConfig?: SearchConfig) {

这一行是 search 方法的声明,这是一个覆写了父类中的同名方法的方法。这个方法接受一个查询字符串和一个可选的搜索配置对象作为参数。


7.

    if (this.isBrowser && !this.isHydrated) {

这一行检查当前代码是否在浏览器环境中运行,并且还没有从 TransferState 中恢复状态。


8.

      const state = this.transferState?.get(CX_KEY, {} as StateWithProduct)!;

这一行从 TransferState 中获取状态。CX_KEY 是状态的键,如果在 TransferState 中找不到这个键,就会返回一个空对象。


9.

      const results = state[PRODUCT_FEATURE].search.results;

这一行从恢复的状态中获取搜索结果。

this.results$.next(results);

这确保了初始页面加载时,无需再次请求数据,直接使用服务器端渲染的数据。否则,在其他情况下,会调用父类 ProductSearchService 的 search 方法执行产品搜索。


最后,CX_KEY 是一个用于标识状态转移的键,它在服务器端和客户端之间共享,以确保状态正确转移和匹配。这个键由 makeStateKey 方法创建,用于唯一标识特定的状态。在服务器端渲染过程中,该键用于查找和提取状态,然后在客户端渲染时将其应用。


当首次加载页面时,CustomProductSearchService 的 search 方法会在服务器端执行,从服务器端渲染的状态中提取产品搜索结果。


这些搜索结果将被发送到 results$ Subject 中。


当页面在客户端加载完成后,CustomProductSearchService 的 getResults 方法被调用,合并了服务器端渲染的结果和客户端请求的结果,以确保搜索结果的一致性。


这样,用户在浏览器中浏览页面时,无需再次请求数据,而是直接使用服务器端渲染的结果。


这段代码的核心思想是通过状态转移机制,在服务器端渲染的情况下尽早提供数据,以加速页面加载并提高用户体验。在客户端渲染时,保持状态的一致性,以确保用户获得一致的数据。这对于需要 SEO 支持的 Angular 应用非常重要,因为它确保了搜索引擎爬虫能够获取完整的页面内容。


总结

本文首先介绍了电商 Web 应用开发领域引入 Angular Universal 实现服务器端渲染的必要性,接着介绍了 State Transfer 这种避免客户端渲染时重复调用 AJAX 从服务器获取业务数据的一种行业最佳实践,最后通过实际项目中一个 State Transfer 实现出现故障的案例,介绍了此类故障的分析和解决问题的详细思路,希望对广大 Angular 开发同仁有所借鉴作用。

相关文章
|
2天前
|
XML 前端开发 JavaScript
前端技术的演变与实战应用
前端技术的演变与实战应用
|
5天前
|
前端开发 测试技术 开发工具
探索前端框架React Hooks的优势与应用
本文将深入探讨前端框架React Hooks的优势与应用。通过分析React Hooks的特性以及实际应用案例,帮助读者更好地理解和运用这一现代化的前端开发工具。
|
19天前
|
前端开发 JavaScript 关系型数据库
从前端到后端:构建现代化Web应用的技术探索
在当今互联网时代,Web应用的开发已成为了各行各业不可或缺的一部分。从前端到后端,这篇文章将带你深入探索如何构建现代化的Web应用。我们将介绍多种技术,包括前端开发、后端开发以及各种编程语言(如Java、Python、C、PHP、Go)和数据库,帮助你了解如何利用这些技术构建出高效、安全和可扩展的Web应用。
|
20天前
|
Web App开发 前端开发 JavaScript
前端应用实现 image lazy loading 的原理介绍
前端应用实现 image lazy loading 的原理介绍
29 0
|
24天前
|
前端开发 编解码 数据格式
浅谈响应式编程在企业级前端应用 UI 开发中的实践
浅谈响应式编程在企业级前端应用 UI 开发中的实践
20 0
浅谈响应式编程在企业级前端应用 UI 开发中的实践
|
1月前
|
机器学习/深度学习 人工智能 前端开发
未来趋势:人工智能在前端开发中的应用
随着人工智能技术的快速发展,前端开发领域也迎来了新的变革。本文将深入探讨人工智能在前端开发中的应用现状,并展望未来的发展趋势,带领读者一窥未来前端开发的可能面貌。
|
1月前
|
JavaScript 前端开发 Java
纯前端JS实现人脸识别眨眨眼张张嘴案例
纯前端JS实现人脸识别眨眨眼张张嘴案例
52 0
|
5天前
|
缓存 安全 JavaScript
前端安全:Vue应用中防范XSS和CSRF攻击
【4月更文挑战第23天】本文探讨了在Vue应用中防范XSS和CSRF攻击的重要性。XSS攻击通过注入恶意脚本威胁用户数据,而CSRF则利用用户身份发起非授权请求。防范措施包括:对输入内容转义、使用CSP、选择安全的库;采用Anti-CSRF令牌、同源策略和POST请求对抗CSRF;并实施代码审查、更新依赖及教育团队成员。通过这些实践,可提升Vue应用的安全性,抵御潜在攻击。
|
2天前
|
前端开发 JavaScript Go
构建高性能Web应用:优化前端资源加载
在构建现代Web应用时,优化前端资源加载是至关重要的一步。本文将介绍一些提升Web应用性能的关键策略,包括减少HTTP请求、压缩和合并资源、使用CDN加速、以及异步加载技术等。通过实施这些优化策略,开发人员可以显著提升网站的加载速度和用户体验。
|
2天前
|
前端开发 JavaScript Java
前端与后端:构建现代Web应用的双翼
前端与后端:构建现代Web应用的双翼

热门文章

最新文章