我們都曾經歷過這樣的下午:一個看似邏輯嚴密的模塊,在實際運行時卻表現得像個失控的野獸。我的故事,就從一個本應“智能”處理登錄和 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 的真正含義。
- 普通函數的執行流:return 是出口
一個普通的同步函數,它的執行流是線性的。return 關鍵字是它的唯一“出口”。一旦執行到 return,函數立即結束並返回一個值。如果沒有 return,它會執行到最後一行,然後默默地返回 undefined。 - 異步的世界:Promise 是承諾
異步操作(如網絡請求)無法立即返回值。於是 Promise 誕生了,它是一個“承諾”,承諾在未來某個時刻會給你一個結果。這個承諾只有兩種狀態:
Fulfilled (or Resolved):已成功。承諾兑現了,並帶回一個成功的值。
Rejected:已失敗。承諾被打破了,並帶回一個失敗的原因(通常是一個 Error 對象)。 - 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去做了分析,希望對自己有幫助。