Skeleton Design 理念在 Angular 应用开发中的具体应用一例

发布时间 2023-12-02 15:58:26作者: JerryWang_汪子熙

Skeleton Design 这个概念,想必前端开发人员比较熟悉,而普通的 App 用户,可能甚至都没有听说过,但实际上我们每个人,每天几乎都会受益于这个设计理念。

Skeleton Design 在一些国内技术社区的技术博客里,通常被称为骨架屏设计,是一种用户界面设计策略。骨架屏设计不是一种具体的基于某种编程语言的技术,而是一种设计理念,旨在改善用户体验。通用的骨架屏设计,在页面内容完全加载前,会显示一个包含页面主要布局和元素位置的空白版本。这种方式能够让用户在等待页面加载的过程中,有一个直观的感知,知道接下来会出现什么内容,减少用户的等待焦虑感。

我目前工作在一个使用 Angular 开发的电商 Storefront 开源项目上,项目的 Github 地址如下,项目代号为 Spartacus.

Spartacus B2B 功能模块里,正常的 Cost Centers 列表显示如下:

在这些 Cost Center 的数据从后台取回来之前,页面显示是这样的:

大家注意到上图一行行灰色的横条吗?这就是一种典型的 Skeleton Design 理念在 Angular 应用里的实现。这种灰色的横条显示,主要目标就是提供一个视觉提示,让用户知道他们可以期待什么类型的信息。

如上图所示,骨架屏通常使用灰色调的占位符来表示正在加载的元素,这种设计方式提供了一种平滑的用户体验,使用户觉得网页的加载更快,更流畅。

试想一下,在用户网络接入速度比较慢,或者后端 API 响应速度比较慢的情况下,如果没有 Skeleton Design 这种设计,那么用户需要在页面加载完成后才能看到完整的内容。特别是在笔者负责开发的 Spartacus 这种电商应用领域内,超过3秒的页面等待就很可能会导致用户的不耐烦,甚至导致他们关闭网站。通过使用骨架屏设计,开发者可以立即显示一个页面的概要,把用户在等待时的注意力分散开,从而提高用户的参与度和用户体验。

本文余下部分,介绍 Spartacus 骨架屏设计的详细技术实现。

从 Spartacus 源代码的 list.service.ts 的实现源代码能看出,幽灵数据就是一个 length 属性值为10的空数组。

  /**
   * The ghost data contains an empty list of objects that is used in the UI
   * to render the HTML elements.
   *
   * This list contains 10 items, so that the ghost will show 10 rows by default.
   */
  protected ghostData = { values: new Array(10) } as EntitiesModel<T>;

在 Chrome 开发者工具里,能观察到这些幽灵数据具有对应的 CSS class,这使得它们具有灰色矩形的视觉外观:

Cost Center 表格显示的数据最终通过 list.service.ts 从 SAP Commerce Cloud 后台取出,取数逻辑通过 Angular 响应式编程库 RxJS的 pipe 方法驱动:第 101 行 switchMap 操作符里的箭头函数,输入参数 pagination 包含了去 Commerce Cloud 取数据使用的分页设置,函数体 this.load 发送 HTTP 请求,消费 Commerce Cloud 的 OCC API.

而第 102 行的 startWith 操作符,语义上相当于给 pipe 驱动的 Observable 流赋上一个初始值,该初始值即为 length 属性为10的空数组。

  /**
   * Loads the data by delegating to the `load` method, which must be implemented
   * in specific implementations of this abstract class.
   *
   * The load method is streamed from the `pagination$` stream, which is initialized
   * with default pagination and structure drive properties.
   */
  getData(...args: any): Observable<EntitiesModel<T> | undefined> {
    return this.pagination$.pipe(
      // we merge any configured pagination from the table structure
      switchMap((pagination) =>
        this.getStructure().pipe(
          map((config) => ({ ...pagination, ...config.options?.pagination }))
        )
      ),
      switchMap((pagination) => this.load(pagination, ...args)),
      startWith(this.ghostData)
    );
  }

startWith 是一个非常有用的 RxJS 操作符,它的主要作用是在 Observable 序列开始之前插入一个指定的元素。这意味着,当你订阅一个 Observable 时,startWith 操作符会立即发出它的参数,然后继续发出 Observable 的值。

例如,我们有一个 Observable,它将发出一个数字序列:

const numbers$ = of(1, 2, 3, 4, 5);

我们可以使用 startWith 操作符在这个数字序列开始前插入一个数字 0:

const numbersWithZero$ = numbers$.pipe(startWith(0));

现在,当我们订阅 numbersWithZero$ 时,它会首先发出 0,然后发出 1, 2, 3, 4, 5。

这种功能在很多情况下都非常有用。例如,在 Angular 中,我们可能会用到 HttpClient 服务来从服务器获取数据。这个过程是异步的,所以我们会使用 RxJS 来处理它。但在数据到达之前,我们可能想要显示一些默认的数据或者加载指示器。这时,startWith 就能派上用场。

// 从服务器获取用户数据,但在数据到达之前,显示一个空的用户对象
const user$ = this.http.get<User>('api/users/1').pipe(
  startWith({id: null, name: 'Loading...'})
);

在这个例子中,user$ 是一个 Observable,它将发出从服务器获取的用户数据。但在数据到达之前,它会先发出一个包含 id: nullname: 'Loading...' 的对象。这样,我们就可以立即更新界面,显示一个加载指示器,然后当真实的数据到达时,再更新为真实的数据。

回到本文的例子,从运行时序来说,任何消费 getData 函数返回的 Observable 对象的 Angular UI 组件,都会先显示 startWith 设置的初始值,即幽灵数据。待从 Commerce Cloud 后台加载的真实数据返回给浏览器之后,组件自动刷新并显示这些真实的业务数据。

总结

本文首先介绍了 Skeleton Design 设计理念的引入初衷和旨在解决的用户体验,接着以 Spartacus Storefront B2B 页面为例,介绍了 Angular 应用里采用 Skeleton Design 改善页面加载用户体验的例子,希望对 Angular 同行有参考作用。