【前端】优雅地处理重试逻辑

2,782 阅读3分钟

一、不那么优雅的逻辑

「重试」是前端开发中一个非常常见的场景,但因为 Promise 的异步回调特性,导致我们在处理重试逻辑的时候很容易就陷入到了「回调地狱」当中,比如这种递归和 Promise 结合写的重试函数:

// 工具函数,用于延迟一段时间
const sleep = (second: number = 1000) => {
  return new Promise((resolve) => {
    setTimeout(resolve, second);
  });
};

// 工具函数,用于递归重试函数
const retry = <T>(
  promise: () => Promise<T>,
  resolve: (value: unknown) => void,
  reject: (reason?: any) => void,
  retryTimes: number,
  retryMaxTimes: number
) => {
  sleep().then(() => {
    promise()
      .then((res: any) => {
        resolve(res);
      })
      .catch((err: any) => {
        if (retryTimes >= retryMaxTimes) {
          reject(err);
          return;
        }
        retry(promise, resolve, reject, retryTimes + 1, retryMaxTimes);
      });
  });
};

// 重试函数
export const retryRequest = <T>(
  promise: () => Promise<T>,
  retryMaxTimes: number
) => {
  return new Promise((resolve, reject) => {
    let count = 1;
    promise()
      .then((res) => {
        resolve(res);
      })
      .catch((err) => {
        if (count >= retryMaxTimes) {
          reject(err);
          return;
        }
        retry(promise, resolve, reject, count + 1, retryMaxTimes);
      });
  });
};

而使用 asyncawait 的话,情况也好不了太多,我们还是要用递归,而且还要在 await 函数外面包一层「try - catch」也不太直观:

// 工具函数,用于延迟一段时间
const sleep = (second: number = 1000) => {
  return new Promise((resolve) => {
    setTimeout(resolve, second);
  });
};

// 工具函数,用于递归重试函数
const retry = async <T>(
  promise: () => Promise<T>,
  retryTimes: number,
  retryMaxTimes: number
) => {
  await sleep();

  try {
    let result = await promise();
    return result;
  } catch (err) {
    if (retryTimes >= retryMaxTimes) {
      throw err;
    }
    retry(promise(), retryTimes + 1, retryMaxTimes);
  }
};

// 重试函数
export const retryRequest = async <T>(
  promise: () => Promise<T>,
  retryMaxTimes: number
) => {
  let count = 1;
  try {
    let result = await promise();
    return result;
  } catch (err) {
    if (count >= retryMaxTimes) {
      throw err;
    }
    retry(promise(), count + 1, retryMaxTimes);
  }
};

二、优雅的逻辑

那么怎么样优雅的写重试逻辑呢?结合第一部分中那些不那么优雅的逻辑,我们可以知道,如果想要提升可读性,我们得做如下三件事:

1、消灭回调 2、消灭 try - catch 这种异常处理逻辑 3、消灭递归逻辑

2-1、回调以及异常处理

为了消灭回调函数,我们还是选择了 asyncawait 语法,而对于异常处理,我们则封装了一个函数,把异常处理逻辑拉平,就像下面这样:

const awaitErrorWrap = async <T, U = any>(
  promise: Promise<T>
): Promise<[U | null, T | null]> => {
  try {
    const data = await promise;
    return [null, data];
  } catch (err: any) {
    return [err, null];
  }
};

这样我们就可以像 go 语言一样,不用包裹 try - catch,直接拿到异常了,就像下面这样:

const [err, data] = await awaitErrorWrap(promise);

2-2、将递归函数改成循环

我们将原有的递归函数改成一个 for 循环,这样逻辑就更清晰了:

const retryRequest = async <T>(
  promise: () => Promise<T>,
  retryTimes: number = 3
) => {
  let output: [any, T | null] = [null, null];

  for (let a = 0; a < retryTimes; a++) {
    output = await awaitErrorWrap(promise());

    if (output[1]) {
      break;
    }
  }

  return output;
};

2-3、优雅的重试逻辑

那么我们将上面的代码组合起来,就得出了下面这个很简洁明了且优雅的重试逻辑了:

// 工具函数,用于延迟一段时间
const sleep = (time: number) => {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
};

// 工具函数,用于包裹 try - catch 逻辑
const awaitErrorWrap = async <T, U = any>(
  promise: Promise<T>
): Promise<[U | null, T | null]> => {
  try {
    const data = await promise;
    return [null, data];
  } catch (err: any) {
    return [err, null];
  }
};

// 重试函数
export const retryRequest = async <T>(
  promise: () => Promise<T>,
  retryTimes: number = 3,
  retryInterval: number = 500
) => {
  let output: [any, T | null] = [null, null];

  for (let a = 0; a < retryTimes; a++) {
    output = await awaitErrorWrap(promise());

    if (output[1]) {
      break;
    }

    console.log(`retry ${a + 1} times, error: ${output[0]}`);
    await sleep(retryInterval);
  }

  return output;
};

我们可以这样使用它:

import { retryRequest } from "xxxx";
import axios from "axios";

const request = (url: string) => {
  return axios.get(url);
};

const [err, data] = await retryRequest(request("https://request_url"), 3, 500);

2-4、更进一步?写个装饰器吧

当然写到这里肯定有朋友会说:“每次在 Promise 外面包一层这个函数也不够优雅!”

没问题,我们可以更进一步,写一个 typescript 的装饰器:

export const retryDecorator = (
  retryTimes: number = 3,
  retryInterval: number = 500
): MethodDecorator => {
  return (_: any, __: string | symbol, descriptor: any) => {
    const fn = descriptor.value;
    descriptor.value = async function (...args: any[]) {
      // 这里的 retryRequest 就是刚才的重试函数
      return retryRequest(fn.apply(this, args), retryTimes, retryInterval);
    };
  };
};

这样我们就可以直接这样使用这个装饰器了:

import { retryRequest } from "xxxx";
import axios from "axios";

class RequestClass {
  @retryDecorator(3, 500)
  static async getUrl(url: string) {
    return axios.get(url);
  }
}

const [err, data] = await RequestClass.getUrl("https://request_url");

2-5、再优化一下?

评论区有朋友认为这样把异常直接作为变量抛出来不符合直觉,没问题,我们换一个能正常用 try catch 的版本:

// 重试函数
export const retryRequest = async <T>(
  promise: () => Promise<T>,
  retryTimes: number = 3,
  retryInterval: number = 500
) => {
  let output: [any, T | null] = [null, null];

  for (let a = 0; a < retryTimes; a++) {
    output = await awaitErrorWrap(promise());

    if (output[1]) {
      break;
    }

    console.log(`retry ${a + 1} times, error: ${output[0]}`);
    await sleep(retryInterval);
  }

  if (output[0]) {
    throw output[0];
  }

  return output[1];
};

我们可以正常按照 try catch 的方式使用它:

import { retryRequest } from "xxxx";
import axios from "axios";

const request = (url: string) => {
  return axios.get(url);
};

try {
  const res = await retryRequest(request("https://request_url"), 3, 500);
} catch (err) {
  console.error(res);
}