头图

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

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

图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 官方文档的截图:

图2:Angular Universal 官方文档

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

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

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

图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 页面的入口和核心。

图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 之后,在浏览器里完成填充的。

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

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

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

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

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

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

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

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

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

实际业务中的故障

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

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

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

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

发现是在 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');

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

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

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

  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

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

  isHydrated = false;

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

  results$ = new Subject<ProductSearchPage>();

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

  1. 重载标准服务类的 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 中找不到这个键,就会返回一个空对象。

      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 开发同仁有所借鉴作用。

本文参与了SegmentFault 思否写作挑战赛活动,欢迎正在阅读的你也加入。

注销
1k 声望1.6k 粉丝

invalid