不要再這樣編寫 Async/Await
最開始接觸 async/await 時,很多人都會發(fā)出“終于有這個功能了!”的感嘆。它的語法清晰、可讀性強(qiáng),用起來直觀又順手。
然而,用得越久,就會發(fā)現(xiàn)一些常見的“坑”時常在各種項目里出現(xiàn):有些是代碼審查時發(fā)現(xiàn)的,有些是和同事討論時暴露的問題。這些都說明異步編程本質(zhì)上并不簡單。
下文就結(jié)合實際經(jīng)驗,列出了一些常見的異步陷阱,以及更高級的用法與思考方式,讓代碼更健壯,也更易維護(hù)。
從回調(diào)地獄到 async/await
還記得當(dāng)初的回調(diào)地獄嗎?JavaScript 進(jìn)化到現(xiàn)在,已經(jīng)讓我們避免了深層嵌套的回調(diào)結(jié)構(gòu)。
但功能變強(qiáng)大了,責(zé)任也跟著變大。下面是 async/await 中常見的三大“致命罪狀”。
1. 同步式的瀑布請求
糟糕示例:順序等待
在代碼審查里經(jīng)??吹竭@樣的場景:本來可以并發(fā)執(zhí)行的請求,卻被一個接一個地串行處理。
// ?? 不推薦
async function loadDashboard() {
const user = await fetchUser();
const posts = await fetchPosts();
const notifications = await fetchNotifications();
}
如果每個請求都要 200ms,這里就總共要 600ms,給用戶的體驗自然不佳。
改進(jìn):并發(fā)執(zhí)行
對于相互獨立的操作,應(yīng)該使用并發(fā)來節(jié)省時間:
// ? 建議用 Promise.all
async function loadDashboard() {
const [user, posts, notifications] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchNotifications()
]);
}
同樣是獲取用戶、帖子和通知,響應(yīng)速度立刻加快三倍左右。不過,并發(fā)并非萬能。
- 依賴關(guān)系:如果一個請求需要另一個請求返回的數(shù)據(jù),就必須順序執(zhí)行。
- 競態(tài)條件:如果多個請求會同時修改某個共享資源,可能導(dǎo)致數(shù)據(jù)不一致。
- 資源限制:并發(fā)過多會給服務(wù)器或瀏覽器帶來壓力。
舉例:危險的并發(fā)
// ?? 并行可能導(dǎo)致競態(tài)問題
await Promise.all([
updateUserProfile(userId, { name: 'New Name' }),
updateUserProfile(userId, { email: 'new@email.com' })
]);
// ? 先后執(zhí)行以防數(shù)據(jù)沖突
const user = await updateUserProfile(userId, { name: 'New Name' });
await updateUserProfile(userId, { email: 'new@email.com' });
如果并行更新同一個用戶資料,服務(wù)器可能會出現(xiàn)覆蓋數(shù)據(jù)的情況。必須根據(jù)業(yè)務(wù)邏輯判斷能否并發(fā)執(zhí)行。
2. 隱形錯誤:如何平衡異常處理
常見誤區(qū):把錯誤直接“吞掉”
不少人喜歡在 catch 塊里寫個簡單的 console.error(error),然后返回 null,讓外層調(diào)用時貌似一切正常。
// ?? 隱形錯誤
async function fetchData() {
try {
const response = await fetch('/api/data');
return await response.json();
} catch (error) {
console.error(error);
return null; // ?? 難以排查的隱患
}
}
看似“處理”了錯誤,但實際上把錯誤原因都藏起來了。網(wǎng)絡(luò)斷了?JSON 解析失敗?服務(wù)器返回 500?外部代碼只能拿到 null
,毫無頭緒。
更好的做法:區(qū)分場景處理
返回空值并非一直不對。如果它只是一個不關(guān)鍵的功能,比如推薦列表或活動通知,給用戶一個空狀態(tài)也許是更友好的方式。但如果是核心數(shù)據(jù),就應(yīng)該拋出異常或者做更明確的錯誤處理,讓上層邏輯感知到問題。
高級開發(fā)者通常會這樣寫:
// ? 有針對性的錯誤處理
async function fetchData(options = {}) {
const { isCritical = true } = options;
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Data fetch failed:', error);
if (isCritical) {
// 對關(guān)鍵數(shù)據(jù)拋出自定義錯誤,交由更高層級處理
throw new ApiError('Failed to fetch critical data', { cause: error });
} else {
// 非關(guān)鍵數(shù)據(jù),可返回一個降級的默認(rèn)值
return { type: 'fallback', data: [] };
}
}
}
// 用法示例:
// 關(guān)鍵數(shù)據(jù)(出錯時會拋異常)
const userData = await fetchData({ isCritical: true });
// 非關(guān)鍵通知數(shù)據(jù)(出錯時返回空列表)
const notifications = await fetchData({ isCritical: false });
這樣就能同時兼顧穩(wěn)定性和可維護(hù)性。關(guān)鍵數(shù)據(jù)絕不能“悄悄失敗”,而次要功能可以“優(yōu)雅退化”。
3. 內(nèi)存泄漏的陷阱和現(xiàn)代化的清理方式
典型誤區(qū):無休止的輪詢
假設(shè)寫了一個定時輪詢,幾秒鐘拉取一次數(shù)據(jù):
// ?? 隱形的內(nèi)存殺手
async function startPolling() {
setInterval(async () => {
const data = await fetchData();
updateUI(data);
}, 5000);
}
表面看上去沒什么問題,但這樣會導(dǎo)致:
- 組件或頁面卸載后依然在輪詢
- 如果 fetchData() 執(zhí)行得很慢,可能會同時發(fā)起多次請求
- 更新 UI 時,目標(biāo) DOM 甚至可能已經(jīng)被移除
改進(jìn):AbortController + 輪詢管理
下面這個示例借助 AbortController 實現(xiàn)了更安全的輪詢:
class PollingManager {
constructor(options = {}) {
this.controller = new AbortController();
this.interval = options.interval || 5000;
}
async start() {
while (!this.controller.signal.aborted) {
try {
const response = await fetch('/api/data', {
signal: this.controller.signal
});
const data = await response.json();
updateUI(data);
// 等待下一次輪詢
await new Promise(resolve => setTimeout(resolve, this.interval));
} catch (error) {
if (error.name === 'AbortError') {
console.log('Polling stopped');
return;
}
console.error('Polling error:', error);
}
}
}
stop() {
this.controller.abort();
}
}
// 在 React 組件中使用
function DataComponent() {
useEffect(() => {
const poller = new PollingManager({ interval: 5000 });
poller.start();
// 組件卸載時停止輪詢
return () => poller.stop();
}, []);
return <div>Data: {/* ... */}</div>;
}
通過使用 AbortController,可以在需要時終止請求并及時釋放資源,更好地控制組件的生命周期和內(nèi)存占用。
高級開發(fā)者的工具箱
1. 重試(Retry)模式
網(wǎng)絡(luò)環(huán)境不穩(wěn)定或第三方服務(wù)時好時壞的情況下,只嘗試一次就放棄不是好辦法??梢约由现卦嚭屯吮懿呗裕?/p>
async function fetchWithRetry(url, options = {}) {
const { maxRetries = 3, backoff = 1000 } = options;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetch(url);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// 退避延遲,指數(shù)增長
await new Promise(r => setTimeout(r, backoff * Math.pow(2, attempt)));
}
}
}
除了基本的指數(shù)退避,還可以考慮:
- 避免過度重試導(dǎo)致資源浪費
- 區(qū)分哪些錯誤類型才需要重試
- 使用斷路器(Circuit Breaker)模式保護(hù)系統(tǒng)
- 加入隨機(jī)抖動(Jitter)防止大量請求同時重試
2. 資源管理
啟動異步操作簡單,關(guān)鍵是如何“優(yōu)雅地”停止它們。通過統(tǒng)一的 ResourceManager
或類似模式,可以集中處理一些關(guān)閉連接、清理定時器、取消任務(wù)等邏輯:
class ResourceManager {
cleanup() {
// 在這里集中處理各種操作的終止和釋放
}
}
真實場景中的模式
1. 統(tǒng)一的加載狀態(tài)管理
不要在每個組件都寫一堆 “正在加載”、“錯誤” 判斷??梢猿橄蟪鲆粋€自定義 Hook 或者統(tǒng)一的加載管理邏輯:
const { data, loading, error } = useAsyncData(() => fetchUserData());
這樣可以:
- 保持加載和錯誤狀態(tài)處理的一致性
- 便于集中管理和優(yōu)化
- 在組件卸載時自動清理
2. 數(shù)據(jù)同步器(Data Synchronizer)
對于實時性要求高的應(yīng)用,與其一個個寫請求,不如建立一個數(shù)據(jù)同步管理器,統(tǒng)一處理輪詢/訂閱/數(shù)據(jù)合并等邏輯:
const syncManager = new DataSyncManager({
onSync: (data) => updateUI(data),
onError: (error) => showError(error),
syncInterval: 5000
});
幾條核心原則
并發(fā)原則
- 讓互不依賴的操作同時執(zhí)行
- 小心競態(tài)和依賴順序
- 不要為了并發(fā)而并發(fā),要考慮業(yè)務(wù)邏輯
彈性原則(Resilience)
- 及時重試和錯誤處理
- 做好網(wǎng)絡(luò)或服務(wù)不可靠的準(zhǔn)備
- 避免一個錯誤拖垮整個系統(tǒng)
資源管理原則
- 主動清理異步操作
- 終止不再需要的請求
- 防止內(nèi)存泄漏
用戶體驗原則
- 有意義的加載提示和錯誤信息
- 保證核心功能的可用性
- 在可能的情況下提供取消或中斷操作
常見問題
什么時候用 Promise.all,什么時候用 Promise.allSettled?
- Promise.all 適合所有請求都必須成功的場景。
- Promise.allSettled 允許部分失敗,適合容忍部分請求出錯的需求。
- Promise.race 則常用于超時等情況。
在 React 中如何優(yōu)雅地清理?
- 善用 useEffect 的返回值進(jìn)行清理。
- 使用 AbortController 終止 HTTP 請求。
- 組件卸載時,保證所有定時器、訂閱或輪詢都能停止。
展望
- 檢查代碼:查找本可并發(fā)卻寫成串行的請求;檢查錯誤處理是否含糊不清;關(guān)注異步操作的清理是否充分。
- 改進(jìn)模式:引入更健壯的錯誤處理,增加重試邏輯,優(yōu)化資源釋放。
- 考慮擴(kuò)展性:當(dāng)用戶量或請求量激增時,如何保證依舊能流暢運(yùn)行?如果某些服務(wù)變慢甚至掛掉,該如何部分降級?
為什么要在意這些細(xì)節(jié)
或許有人會說,“我的小項目沒這么復(fù)雜,用不著搞這些”。但真正的好代碼是能經(jīng)得住放大和演進(jìn)的。
- 可擴(kuò)展性:面對更多用戶和請求,系統(tǒng)能否穩(wěn)健地運(yùn)行
- 用戶體驗:高并發(fā)、良好錯誤處理能讓應(yīng)用體驗更流暢
- 開發(fā)者體驗:清晰的異步邏輯有助于日后維護(hù)和團(tuán)隊協(xié)作
- 資源利用:合理的并發(fā)和清理機(jī)制能節(jié)約服務(wù)器和客戶端資源
這些并不是紙上談兵,而是大量實戰(zhàn)總結(jié)出來的硬道理。隨著項目的規(guī)模和復(fù)雜度不斷提升,這些異步編程模式會是你寫出高質(zhì)量前端代碼的核心基石。