Promise.race():日常开发中如何用它提前终止多余异步请求

401 阅读7分钟

1、前言

说起Promise.race(),想必大部分读者对它的定义都不会陌生(以下定义源自MDN):
Promise.race() 静态方法接受一个 promise 可迭代对象作为输入,并返回一个 Promise。这个返回的 promise 会随着第一个 promise 的敲定而敲定。

笔者在最近的一次做需求中就遇到这么一个场景: 某个页面是一个h5列表页,点击列表项后会跳转到详情页,业务需求是用户点击列表项后请求推荐接口,等返回到h5列表页后刷出给用户推荐的列表项。

乍一看这个业务需求很简单,无非是点击时发起推荐接口请求,然后将请求结果保存到列表页的状态中,然后监听列表页可见时就进行推荐结果的刷新动画处理。

但是存在一个问题:由于推荐接口调用了大模型相关的能力,然后结果返回可能会很慢,甚至可能达到惊人的3~5s,如果此时用户在详情页停留时长较短,直接返回到h5列表页,那么此时推荐接口可能返回结果,也可能还没返回结果。

这种case处理的方法有两种:
1、当接口返回时,直接进行推荐结果的刷新动画处理,或者说将推荐接口的请求放在返回到h5列表页的时机。但是这种处理方式会带来新的问题,前面说到,推荐接口可能会很慢,因此用户在返回到h5列表页时可能会莫名其妙地在某一时机看到刷新动画,或者用户又会点击跳转到其它列表项详情页(可能会导致多次推荐接口请求,要对这些请求结果进行处理,哪个会触发刷新动画,哪个结果又要丢弃,防止触发错误的刷新动画)。
2、监听列表页可见时,判断此时的推荐请求结果是否已存在,若已存在则触发刷新动画,若不存在则丢弃未返回的推荐请求。

找产品确认后(当然第2种处理方式也更符合逻辑一点),采取了第2种处理方式。丢弃未返回的推荐请求这个目的很容易让我们想到利用Promise.race提前终止多余异步请求。

在开发中复杂列表或图表,加上各种条件、筛选是个常见的需求,接口响应速度通常比较慢。

一般筛选条件触发刷新的操作都会加上防抖,避免短时间多次重复请求,极端情况下防抖并非万无一失的策略。我们来模拟一下:

防抖等待时间为300毫秒,用户第一次改变条件,第一个请求正常发起,较为耗时需要1秒;300毫秒时用户第二次改变条件,没有触发防抖机制,正常发起请求,耗时较短200毫秒响应结果,此时页面上显示为第二次改变的条件和第二次请求的数据,正常;1秒后第一次请求完成响应,此时页面上图表数据为第一次请求的内容,但是条件仍为第二次的,异常。

理想情况下,当第二次请求发起后第一次请求结果将不渲染。

这种情况和我们的诉求相同:需要提前终止多余异步请求,借助Promise.race实现。

Promise.race接受一个promise数组作为参数,包装一个新的promise实例,新的promise实例将返回数组中最快结束的结果。我们可以利用Promise.race的竞速机制防止promise进入then回调,变相实现提前终止异步请求。

实现思路:将正常的promise和一个“假”promisePromise.race包装,并利用闭包保存 “假” promise中的reject方法。调用reject后,整个promise进入失败阶段,被终止。

2、不可信的定时器

前面说到,笔者的需求类似于请求的超时处理,因此自然很容易想到用定时器作为我们的“假”promise和请求进行竞速。思路代码如下:

// 下面这些状态用于处理需求逻辑
  const lastRecommendConfig = useRef<IRecommendConfig | null>(null); // 上次的推荐请求结果
  
  
// 监听h5列表页返回可见时的处理
  useEffect(() => {
    const pageAppearCallback = () => {
      if (document.visibilityState == 'visible') {
        if (!lastRecommendConfig.current) return;
        // 触发推荐动画
        handleRecommendAnimation();
        // 插入完数据后,清空配置,防止重复触发
        lastRecommendConfig.current = null;
      }
    }
    document.addEventListener('visibilitychange', pageAppearCallback)
    return () => {
      document.removeEventListener('visibilitychange', pageAppearCallback);
    };
  }, []);
  
  
// 点击列表项时触发的方法(请求推荐)
  const handleRecommend = () => {
    // 用一个异步操作模拟我们的请求,返回一个 Promise
    const asyncOperation = (): Promise<IRecommendConfigRes> => {
      return new Promise<IRecommendConfigRes>((resolve) => {
        setTimeout(() => {
          // resolve('请求有效');
          resolve(recommendConfigRes);
        }, 300);
      });
    };
    // 创建一个超时 Promise
    const timeout = (): Promise<Error> => {
      return new Promise<Error>((resolve, reject) => {
        setTimeout(() => {
          reject(new Error('请求超时'));
        }, 500);
      });
    };
    // 使用 Promise.race() 处理异步操作和超时,超时的请求会被丢弃。
    Promise.race<IRecommendConfigRes | Error>([asyncOperation(), timeout()])
      .then((res) => {
        // error说明请求超时
        if (res instanceof Error) throw res;
        // 请求成功
        lastRecommendConfig.current = res;
      })
      .catch(() => {
      });
  };

在上面的代码中,我们将推荐请求返回时间与写死的500毫秒作比较,若超时则丢弃该请求结果。诚然这种处理方式对于推荐接口较快+用户迅速返回h5列表页的这种情况有效。但是前面说到推荐接口的返回时间可能是3~5秒(上面代码中的asyncOperation的时间改长一点就出异常了),因此用定时器进行比较并不能彻底解决我们的问题。需要想一个更完备的方法,实现真正的列表页可见时,判断此时的推荐请求结果是否已存在,若已存在则触发刷新动画,若不存在则丢弃未返回的推荐请求。

3、灵活运用Promise

上面用定时器不能解决我们的超时处理问题是因为我们需要的超时判断不是和多少毫秒进行比较然后判断,而是在返回到h5列表页这一时机时进行判断,判断此时的推荐请求是否已经返回结果。 因此考虑到,我们用于竞争的“假”promise能否在返回到h5列表页这一时机时返回结果,和推荐请求的promise进行Promise.race(),哪个结果快就用哪个的,然后就可以进行判断了。思路代码如下,主要引入了raceRecommendPromiseRef用于记录我们用于竞争的“假”promise,该“假”promise在页面可见时进行resolve

// 下面这些状态用于处理需求逻辑
const lastRecommendConfig = useRef<IRecommendConfig | null>(null); // 上次的推荐请求结果
const raceRecommendPromiseRef = useRef<(() => void) | null>(null); // 用于推荐请求的超时处理


// 监听h5列表页返回可见时的处理
  useEffect(() => {
    const pageAppearCallback = () => {
      if (document.visibilityState == 'visible') {
        // “假”`promise`在页面可见时进行 resolve 调用
        if (raceRecommendPromiseRef.current) {
          // raceRecommendPromiseRef.current?.('请求无效');
          raceRecommendPromiseRef.current?.();
        }
        if (!lastRecommendConfig.current) return;
        // 触发推荐动画
        handleRecommendAnimation();
        // 插入完数据后,清空配置,防止重复触发
        raceRecommendPromiseRef.current = null;
      }
    }
    document.addEventListener('visibilitychange', pageAppearCallback)
    return () => {
      document.removeEventListener('visibilitychange', pageAppearCallback);
    };
  }, []);
  
  
  // 点击列表项时触发的方法(请求推荐)
  const handleRecommend = () => {
    // 用一个异步操作模拟我们的请求,返回一个 Promise
    const asyncOperation = (): Promise<IRecommendConfigRes> => {
      return new Promise<IRecommendConfigRes>((resolve) => {
        setTimeout(() => {
          // resolve('请求有效');
          resolve(recommendConfigRes);
        }, 300);
      });
    };
    // 创建一个超时 Promise
    const timeout = (): Promise<void> => {
      return new Promise<void>((resolve) => {
        // 存储 resolve 函数,用于后续可能的调用,调用时会抛出空结果
        raceRecommendPromiseRef.current = resolve;
      });
    };
    // 使用 Promise.race() 处理异步操作和超时,超时的请求会被丢弃。
    Promise.race([asyncOperation(), timeout()])
      .then((res) => {
        // alert(res); // res可能是请求迅速返回resolve的结果,也可能是回到页面时才resolve的结果
        if (!res) return; // 没有数据,说明请求超时,直接返回
        // 请求成功
        lastRecommendConfig.current = res;
      })
      .catch(() => {
      });
  };

此时发现,无论asyncOperation的时间改成多少都不会导致异常结果,只不过要么触发推荐动画要么不触发而已,符合预期。

搞定,下班!