基于 Observable 构建前端防腐策略 发布后,有一些读者留言对文章中使用 Observable 构建防腐层的典型应用感到困惑,觉得代码中的例子过于简单,不少可以通过 Promise 解决,引入 RxJS Observable 会提高而非降低复杂度。

这种顾虑是完全有道理的,在 RxJS 中可以由 Promise 操作符来替代的场景还有很多,事实上,所有能由 Observable 实现的场景理论上都可以 Promise 来实现,毕竟 RxJS 是基于 JavaScript 构建,整个 Observable 的核心实现也不过只有 100 行 代码。

然而前文的例子只是用来说明防腐层的场景,而并非复杂到一定要使用防腐层的情况。实际业务中的场景不可能只有 2 个接口,3 个组件这样简单。在复杂的业务场景下,基于 Observable 构建的防腐层可以提升我们的代码开发效率,更好的抽象和封装底层接口。以下举几个项目中防腐层的实战场景 ,每个场景均附加了在线示例。

接口稳定提升

将低成功率接口组装抽象为高成功率接口
在线示例:https://stackblitz.com/edit/rxjs-stable-improvement
操作符:retry / retryWhen / delay

有些时候后端接口的成功率较低,但是前端为了保证视图层稳定,需要对这些接口的成功率进行增强。这里,我们使用 Promise 模拟一个成功率只有 50% 的接口,代码如下:

  1. // 成功率 50% 的接口
  2. function unstableAPI(): Promise<boolean> {
  3. return new Promise((resolve, reject) => {
  4. if (Math.random() < 0.5) {
  5. resolve(true);
  6. } else {
  7. reject('error');
  8. }
  9. });
  10. }

通过 RxJS 的 retry 操作符,我们可以很容易将 50% 成功率的接口组装为成功率 99.9% 的接口,即每次当接口失败时,自动重试最多 10 次。

  1. function stabilizedAPI(): Promise<boolean> {
  2. return lastValueFrom(
  3. from(defer(() => unstableAPI())).pipe(retry(10))
  4. );
  5. }

Observable 前端防腐层项目实战 - 图1
实际的业务中,以上代码会导致代码短时间内多次重试,可能会导致接口雪崩。在 RxJS 中我们可以轻松实现错误回退机制,以花费更多时间的代价来获得更大的成功几率。

我们将 stabilizedAPI 的代码修改为以下代码,当发生错误时,等待 1s 后重新发起请求,最多发送 10 次。更完善的 RxJS 退避策略可以参考 Power of RxJS when using exponential backoff 一文。

  1. function stabilizedAPI(): Promise<boolean> {
  2. return lastValueFrom(
  3. from(defer(() => unstableAPI())).pipe(
  4. retryWhen((errors) => errors.pipe(delay(1000), take(10)))
  5. )
  6. );
  7. }

Observable 前端防腐层项目实战 - 图2

接口时序调整

为启动屏目单独提供加载接口
在线示例:https://stackblitz.com/edit/rxjs-minimal-response-time
操作符:forkJoin / delay

绝大部分的前端应用都会有启动屏幕,启动屏幕中可能包含广告、加载动画或者应用 logo 信息等内容,应用启动屏幕的展示时间通常由以下两个因素决定:

  1. 网络加载耗时 networkDelay: 应用加载所需的关键数据接口,例如用户个人信息的最长返回时间
  2. 页面最小展示时间 minimalDelay: 启动屏幕包含的有效信息需要有一个最短展示时间,防止屏幕闪烁

启动屏幕的展示时间应当由以下逻辑计算:当网络加载耗时小于页面最小展示时间时,将以页面最小展示时间为准,当大于页面最小展示时间时,将网络加载耗时为准。简化公式为:

启动屏幕展示时间 = Max(关键接口加载时间,最短加载时间)

我们使用 Promise 来模拟关键数据返回,其中网络接口延时由 setTimeout 来模拟

  1. function initData(): Promise<{ name: string }> {
  2. return new Promise((resolve) => {
  3. const networkDelay = Math.random() * 3000;
  4. setTimeout(() => {
  5. resolve({ name: 'lucy' });
  6. }, networkDelay);
  7. });
  8. }

通过 forkJoin delay 等 operator,我们可以组装出给启动屏幕使用的最短返回时间接口

  1. // 初始化数据,返回时间必定大于 minimalDelay ms
  2. function initDataWithMinimalDelay(minimalDelay: number): Promise<{ name: string }> {
  3. return lastValueFrom(
  4. forkJoin([
  5. from(defer(() => initData())),
  6. of(true).pipe(delay(minimalDelay)),
  7. ]).pipe(map(([data]) => data))
  8. );
  9. }

在以上代码中,当 networkDelay 调用时间小于 minimalDelay 时,将以 minimalDelay 为准,当大于 minimalDelay 时,将以 networkDelay 为准。
Observable 前端防腐层项目实战 - 图3

接口择优使用

自动选择较快的接口使用
在线示例:https://stackblitz.com/edit/rxjs-race-query
操作符:raceWith

有时相同的数据可以从后端多个接口中获取,我们使用 Promise 模拟快慢两个接口

  1. // 快速接口
  2. function fastAPI(): Promise<string> {
  3. return new Promise((resolve) => {
  4. setTimeout(() => {
  5. resolve('fast data');
  6. }, 1000);
  7. });
  8. }
  9. // 慢速接口
  10. function slowAPI(): Promise<string> {
  11. return new Promise((resolve) => {
  12. setTimeout(() => {
  13. resolve('slow data');
  14. }, 3000);
  15. });
  16. }

在实际的使用中,我们无法提前知晓接口的网络情况,通过 raceWith 操作符,我们可以对任意个接口进行封装,自动获取其中最快的那个

  1. function getFasterOne(): Promise<string> {
  2. return lastValueFrom(
  3. from(defer(() => fastAPI())).pipe(raceWith(from(defer(() => slowAPI()))))
  4. );
  5. }

Observable 前端防腐层项目实战 - 图4

接口竞态处理

Observable 防腐层自带竞态处理功能
在线示例:https://stackblitz.com/edit/rxjs-race-condition
操作符:exhaustMap / switchMap / concatMap

接口的请求结果返回的顺序不能保证一致,这就要求我们在业务中需要对接口的竞态问题进行处理。Dan Abramov 在 useEffect 完整指南 使用了布尔值来对数据进行处理。但是如果你使用 Observable 构建了防腐层,就会有更简单的方法来处理竞态问题。

我们使用 randomuser.me 的服务与 fromFetch operator 构建一个简单的数据层

  1. function getData() {
  2. return fromFetch('https://api.randomuser.me/?page=1&results=10').pipe(
  3. map((data) => data.json())
  4. );
  5. }

以第一次请求为准

由于防腐层 Observable 的特性,使用 Observable 与 exhaustMap 结合就可以获得与 flag 标注相同的效果,即当前一次请求未返回时,下一次请求会被直接抛弃。

  1. fromEvent(document.getElementById('button'), 'click')
  2. .pipe(exhaustMap(() => getData()));

Observable 前端防腐层项目实战 - 图5

以最后一次请求为准

我们也可以选择以最后一次请求为基准,将之前所有的请求都抛弃,在组件内直接使用 switchMap operator 来保证请求顺序与返回数据一致,fromFetch 中内置了 AbortController 可以将过期但仍未返回的接口置为 canceled 状态。

  1. fromEvent(document.getElementById('button'), 'click')
  2. .pipe(switchMap(() => getData()));

Observable 前端防腐层项目实战 - 图6
Observable 前端防腐层项目实战 - 图7

所有请求排队处理

将所有发出的请求排队处理,不丢弃任何一次请求,当上一次请求未返回时,下一次请求进入队列排队。

  1. fromEvent(document.getElementById('button'), 'click')
  2. .pipe(concatMap(() => getData()));

Observable 前端防腐层项目实战 - 图8

高阶数据组装

将高阶数据请求抽象为单个接口
在线示例:https://stackblitz.com/edit/rxjs-high-order-query
操作符:mergeMap / map / forkJoin

有些时候需要二次请求才能获得视图层的数据,例如下图中的数据可能由 getList 与 getStatus 两个接口才能完整获取。当我们需要同步渲染这些数据时,在防腐层中抽象出 getListWithStatus 会是更好的选择。
Observable 前端防腐层项目实战 - 图9
我们使用 Promise 模拟出两个接口的内容

  1. // 模拟获取列表数据的接口
  2. function getList(): Promise<
  3. Array<{
  4. name: string;
  5. id: string;
  6. }>
  7. > {
  8. return new Promise((resolve) => {
  9. resolve([
  10. {
  11. name: 'John Brown',
  12. id: '1',
  13. },
  14. {
  15. name: 'Jim Green',
  16. id: '2',
  17. }
  18. ]);
  19. });
  20. }
  21. // 模拟获取状态接口
  22. function getStatus(id: string) {
  23. return new Promise((resolve) => {
  24. if (id === '2') {
  25. resolve('old');
  26. } else {
  27. resolve('young');
  28. }
  29. });
  30. }

通过 mergeMap 与 forkJoin,我们可以将高阶的请求直接打平为一阶数组,获得含有 status 列表数据的接口抽象

  1. // 抽象后含有 status 的列表数据
  2. function getListWithStatus() {
  3. const getList$ = from(defer(() => getList()));
  4. const getStatus$ = (id: string) => from(defer(() => from(getStatus(id))));
  5. const data$ = getList$.pipe(
  6. mergeMap((list) => {
  7. const queryList = list.map((item) =>
  8. getStatus$(item.id).pipe(map((status) => ({ ...item, status })))
  9. );
  10. return forkJoin(queryList);
  11. })
  12. );
  13. return lastValueFrom(data$);
  14. }

调用 getListWithStatus 返回的数据为

  1. [
  2. {
  3. "name": "John Brown",
  4. "id": "1",
  5. "status": "young"
  6. },
  7. {
  8. "name": "Jim Green",
  9. "id": "2",
  10. "status": "old"
  11. }
  12. ]

总结

Observable 的思想更广泛的应用在于响应式编程,但是其在防腐层构建上同样可以发挥很大作用,本文给出了 实际项目中的一些相对复杂的例子,通过 Observable 防腐层的引入可以使用较少代码来实现上述复杂功能。
复杂业务的有效设计对于简单场景来说很可能是过度设计。不建议读者在没有场景的时候强行引入 Observable,工程领域实践中没有银弹,感谢大家的阅读。