动态

详情 返回 返回

從一個請求封裝的“死循環”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去做了分析,希望對自己有幫助。

user avatar toopoo 头像 cyzf 头像 linlinma 头像 freeman_tian 头像 qingzhan 头像 razyliang 头像 longlong688 头像 inslog 头像 anchen_5c17815319fb5 头像 banana_god 头像 huichangkudelingdai 头像 u_17443142 头像
点赞 195 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.