从一个请求封装的“死循环”Bug,我学到了什么?—— 深入剖析 async/await 与错误处理 前言:那个让我头疼的下午

我们都曾经历过这样的下午:一个看似逻辑严密的模块,在实际运行时却表现得像个失控的野兽。我的故事,就从一个本应“智能”处理登录和 Token 刷新的 ajax 请求封装函数开始。
我希望它能在接口返回 400(需要登录)或 4_01(Token 失效)时,自动完成登录或刷新 Token,然后再重新发起刚才失败的请求。然而,它却在某些情况下陷入了可怕的无限循环,疯狂轰炸着我的服务器。
起初,我以为是并发请求导致的“竞态条件”,于是我尝试引入“锁”(isLogging 标志位)来防止重复登录。但这就像给一个漏水的桶加盖子,不仅没解决根本问题,还引入了更复杂的队列管理和状态重置问题。
经过一番折腾和反思,我才发现,我掉进了一个由 async/await 和错误处理不当共同挖下的“陷阱”。这篇文章,就是我从那个“陷阱”里爬出来后,写下的踩坑复盘和知识总结

一、案发现场:我的“智能”请求封装(错误版本)
让我们先来看看那个最初导致问题的代码简化版。注意 imLogin 函数,这是问题的核心所在。

// 【错误的示范代码】

// 是否正在登录中
let isLogging = false;

async function imLogin() {
  console.log("尝试进行登录...");
  try {
    const res = await api.postImLogin(); // 假设这个API调用可能成功也可能失败
    if (res.code === 0) {
      console.log("登录成功!");
      uni.setStorageSync("imLoginInfo", res.data);
      // 登录成功后,应该重置锁
      isLogging = false;
    } else {
      // !!!问题的根源在这里 !!!
      // 登录失败了,我只是打印了日志,但没有做任何“失败”的表示
      console.error("登录接口返回错误,但程序仍在继续...");
      isLogging = false;
    }
  } catch (err) {
    // 网络错误等,也只是打印了日志
    console.error("登录请求本身失败了", err);
    isLogging = false;
  }
}

function request(options) {
  return new Promise(async (resolve, reject) => {
    const res = await uni.request(options); // 伪代码,模拟一次请求

    if (res.data.code === 200) {
      resolve(res.data.data);
    } else if (res.data.code === 400) { // 需要重新登录
      if (isLogging) {
        // ...请求入队逻辑...
        return;
      }
      isLogging = true;
      
      // 调用登录函数
      await imLogin();
      
      // 重新发起请求
      // 不论 imLogin 成功还是失败,代码都走到了这里!
      resolve(request(options)); 
    }
  });
}

预期的行为:当 request 遇到 400 错误,调用 imLogin。如果 imLogin 成功,则重新 request。如果 imLogin 失败,则流程应该终止,并告知上层调用者“登录失败”。
实际的行为:当 imLogin 内部的 API 调用失败时,它只是打印了一条错误日志,然后悄无声息地结束了。这导致 request 函数认为 await imLogin() 已经“完成”,于是它继续执行 resolve(request(options)),从而再次发起请求,再次遇到 400,再次调用 imLogin…… 死循环诞生了。

预期的行为:当 request 遇到 400 错误,调用 imLogin。如果 imLogin 成功,则重新 request。如果 imLogin 失败,则流程应该终止,并告知上层调用者“登录失败”。
实际的行为:当 imLogin 内部的 API 调用失败时,它只是打印了一条错误日志,然后悄无声息地结束了。这导致 request 函数认为 await imLogin() 已经“完成”,于是它继续执行 resolve(request(options)),从而再次发起请求,再次遇到 400,再次调用 imLogin…… 死循环诞生了。
二、拨开迷雾:回到JS基础之巅
要理解为什么会这样,我们需要放下复杂的业务逻辑,回到 JavaScript 最基础的几个概念:函数执行流、Promise、以及 async/await 的真正含义。

  1. 普通函数的执行流:return 是出口
    一个普通的同步函数,它的执行流是线性的。return 关键字是它的唯一“出口”。一旦执行到 return,函数立即结束并返回一个值。如果没有 return,它会执行到最后一行,然后默默地返回 undefined。
  2. 异步的世界:Promise 是承诺
    异步操作(如网络请求)无法立即返回值。于是 Promise 诞生了,它是一个“承诺”,承诺在未来某个时刻会给你一个结果。这个承诺只有两种状态:
    Fulfilled (or Resolved):已成功。承诺兑现了,并带回一个成功的值。
    Rejected:已失败。承诺被打破了,并带回一个失败的原因(通常是一个 Error 对象)。
  3. async/await:让承诺更优雅的“语法糖”
    async/await 并没有发明新的东西,它只是让操作 Promise 变得像写同步代码一样自然。但这个“糖衣”之下,有两条至关重要的规则:
    async 关键字:一旦给函数加上 async,它的返回值就必定是一个 Promise。
    如果函数内部 return 了一个值(如 return a),那么这个 async 函数返回的 Promise 会 resolve(a)。
    如果函数内部 throw 了一个错误(如 throw new Error('失败')),那么这个 async 函数返回的 Promise 就会 reject(new Error('失败'))。
    关键点:如果 async 函数执行完毕,但既没有 return 也没有 throw,它会返回一个 resolve(undefined) 的 Promise。它被视为成功了!
    await 关键字:await 后面通常跟着一个 Promise。它会“暂停”当前 async 函数的执行,等待 Promise 的结果。
    如果 Promise resolve(value),await 就会把 value “解包”出来,作为表达式的结果,然后函数继续执行。
    关键点:如果 Promise reject(error),await 就会把 error 作为异常抛出 (throw)。这和同步代码里的 throw 效果一模一样!
    三、真相大白:await 等到的“假成功”
    现在,我们用这套理论来重新审视那段错误的代码:
    我们的 imLogin 是一个 async 函数。
    当它内部的登录 API 失败时,它进入了 else 或 catch 块。
    在这些块里,我们只用了 console.error(),完全没有 throw任何东西。
    因此,imLogin 函数从头到尾执行完毕,没有抛出任何异常。
    根据 async 函数的规则,它返回了一个成功的、resolve(undefined) 的 Promise。
    在 request 函数里,await imLogin() 等到了这个“假成功”的 Promise。
    根据 await 的规则,它没有抛出任何异常,代码继续往下执行。
    resolve(request(options)) 被无情地调用,死循环的齿轮开始转动。
    四、最终的救赎:用 try/catch 和 throw 构建健壮流程
    问题的根源找到了,解决方案也就水落石出:我们必须在异步操作失败时,通过 throw 将失败的信号(即一个 rejected 的 Promise)正确地传递出去,并在调用处用 try/catch 捕获这个信号。
    下面是改造后的、健壮可靠的代码:
import { imBaseUrl, imApiPath } from '@/sheep/config'
import ImChatApi from '@/sheep/api/escort/im.js'

// ... 其他变量 ...
let loginRequestList = [];
let isLogging = false;

// 【核心改造点 1】: imLogin 在失败时必须 throw Error
const imLogin = async () => {
  isLogging = true; // 锁应该在函数开始时设置
  try {
    const imRes = await ImChatApi.postImLogin();
    if (imRes.code === 0) {
      console.log("IM 登录成功!");
      uni.setStorageSync("imLoginInfo", imRes.data);
      
      isLogging = false; // 成功后解锁
      
      // 执行队列中的请求
      loginRequestList.forEach(cb => cb());
      loginRequestList = [];
      
      // 函数正常结束,隐式返回一个 resolved Promise
    } else {
      // 业务失败,必须抛出异常来通知调用者
      throw new Error(`IM 登录接口返回错误: ${imRes.message || '未知错误'}`);
    }
  } catch (error) {
    // 网络错误或业务错误都会在这里被捕获
    isLogging = false; // 失败后也要解锁
    loginRequestList = []; // 登录失败,队列中的请求也应被清空和拒绝
    console.error("IM 登录最终失败", error);
    // 将错误继续向上抛出,这样 await imLogin() 才能捕获到
    throw error;
  }
}

// 【核心改造点 2】: request 函数使用 try/catch 来处理 await 的失败
const request = (options) => {
  return new Promise(function (resolve, reject) {
    uni.request({
      // ... request 配置 ...
      async success(res) {
        if (res.data.code == 200) {
          return resolve(res.data.data);
        } else if (res.data.code == 400) {
          if (isLogging) {
            loginRequestList.push(() => resolve(request(options)));
            return;
          }
          
          try {
            // 用 try 来“监视” await 的行为
            await imLogin();
            // 只有 imLogin 成功,才会执行到这里
            resolve(request(options));
          } catch (loginError) {
            // 如果 imLogin 抛出异常,await 会把它传到这里
            // 登录流程最终失败,拒绝当前请求
            reject(loginError);
          }
        }
        // ... 其他 code 处理 ...
      },
      fail(error) {
        reject(error);
      }
    });
  });
}

现在,整个流程如丝般顺滑:
imLogin 失败时 throw 一个错误。
async imLogin 函数返回一个 rejected 的 Promise。
await imLogin() 捕获到这个 rejected Promise,并将其作为异常抛出。
try...catch 块捕获了这个异常。
代码进入 catch 块,执行 reject(loginError),将整个 request 的 Promise 置为失败状态,流程被正确地中断。
死循环被终结,上层业务代码也能接收到登录失败的明确信号。

这次踩坑,我自己结合ai去做了分析,希望对自己有帮助。

你可能感兴趣的:(从一个请求封装的“死循环”Bug,我学到了什么?—— 深入剖析 async/await 与错误处理 前言:那个让我头疼的下午)