題目
非常好。我們剛剛看到了回調函數在處理多個異步操作時會變得多么混亂(回調地獄)。為了解決這個問題,現代 JavaScript 提供了一個更強大、更優雅的工具:Promise。
Promise,正如其名,是一個“承諾”。它代表一個尚未完成但最終會完成(或失敗)的異步操作的結果。你可以把它想象成一張“憑證”,你拿著這張憑證,未來可以憑它兌換最終的數據,或者得到一個失敗的通知。
練習 04: Promise - 告別回調地獄
這次,我們重構上一個練習的 fetchUserData
函數。它不再接收回調函數作為參數,而是返回一個 Promise。
🎯 學習目標:
- 理解 Promise 的概念和它的三種狀態:
pending
(進行中)、fulfilled
(已成功)、rejected
(已失敗)。 - 學會使用
new Promise((resolve, reject) => { ... })
來創建一個 Promise 對象,并封裝一個異步操作。 - 學會使用
resolve
函數來表示承諾成功兌現,并傳遞結果。 - 學會使用
reject
函數來表示承諾未能兌現,并傳遞原因。 - 學會使用
.then()
方法來處理成功的結果,使用.catch()
方法來捕獲和處理錯誤。
🛠? 任務:
- 重新實現
fetchUserData
函數,它現在只接收一個參數userId
。 - 這個函數必須返回一個
new Promise
對象。 - 在 Promise 的構造函數內部(我們稱之為 “executor”),執行我們的異步邏輯:
- 錯誤處理: 首先檢查
userId
。如果它是一個小于或等于 0 的無效值,應立刻調用reject
函數,并傳入一個錯誤消息字符串,例如"Invalid User ID"
。 - 異步模擬: 使用
setTimeout
模擬一個 2 秒的網絡延遲。 - 成功處理: 在 2 秒鐘后,
setTimeout
的回調函數應該調用resolve
函數,并將模擬的用戶數據對象作為參數傳給它。
- 錯誤處理: 首先檢查
- 觀察并理解如何使用
.then()
和.catch()
來消費這個返回 Promise 的函數。
📋 初始代碼:
創建新文件 04-promises.js
,并復制以下代碼。你的任務是補全 fetchUserData
函數的內部邏輯。
console.log("程序開始...");/*** 使用 Promise 模擬從服務器獲取用戶數據。* @param {number} userId - 要獲取的用戶的ID。* @returns {Promise<object>} 一個 Promise 對象,成功時會 resolve 用戶對象,失敗時會 reject 錯誤信息。*/
function fetchUserData(userId) {return new Promise((resolve, reject) => {// --- 在這里編寫你的代碼 ---// 1. 檢查 userId 是否有效,如果無效,調用 reject。// 2. 使用 setTimeout 模擬網絡延遲。// 3. 在 setTimeout 的回調中,調用 resolve 并傳入用戶數據。});
}// --- 如何使用這個返回 Promise 的新函數 ---// 1. 模擬一次成功的調用
console.log("發起第一次調用 (userId: 123)...");
fetchUserData(123).then((user) => {// 當 Promise 成功時 (resolve被調用),這部分代碼會執行console.log("成功獲取到用戶(123):", user);}).catch((error) => {// 當 Promise 失敗時 (reject被調用),這部分代碼會執行console.error("獲取用戶(123)失敗:", error);});// 2. 模擬一次失敗的調用
console.log("發起第二次調用 (userId: -1)...");
fetchUserData(-1).then((user) => {console.log("成功獲取到用戶(-1):", user);}).catch((error) => {console.error("獲取用戶(-1)失敗:", error);});console.log("Promise 已發出,代碼繼續執行,等待結果...");
? 預期輸出:
觀察輸出的順序非常重要!
程序開始...
發起第一次調用 (userId: 123)...
發起第二次調用 (userId: -1)...
Promise 已發出,代碼繼續執行,等待結果...
獲取用戶(-1)失敗: Invalid User ID // 這條會很快出現,因為它沒有進入setTimeout
// ...等待大約2秒...
成功獲取到用戶(123): { id: 123, name: 'John Doe', email: 'john.doe@example.com' } // 這條在2秒后出現
這個練習將清晰地展示 Promise 如何將“結果”與“處理結果的邏輯”分離開來,并提供了統一的錯誤處理機制,這正是它比回調函數更優秀的地方。開始吧!
答案
當然,我們來揭曉答案,并深入解析 Promise 的工作機制。這是異步編程從入門到熟練的關鍵一步。
參考實現 (04-promises.js
)
console.log("程序開始...");/*** 使用 Promise 模擬從服務器獲取用戶數據。* @param {number} userId - 要獲取的用戶的ID。* @returns {Promise<object>} 一個 Promise 對象,成功時會 resolve 用戶對象,失敗時會 reject 錯誤信息。*/
function fetchUserData(userId) {// 返回一個新的 Promise 實例return new Promise((resolve, reject) => {// 1. 檢查 userId 是否有效。這是同步代碼,會立刻執行。if (userId <= 0) {// 如果無效,我們立刻調用 reject 來表示 Promise 失敗。// Promise 的狀態從 pending 變為 rejected。reject("Invalid User ID");return; // 調用 reject 后最好 return,以防止后續代碼意外執行。}// 2. 使用 setTimeout 模擬異步操作setTimeout(() => {// 這部分代碼會在 2 秒后執行// 模擬成功獲取數據const user = {id: userId,name: 'John Doe',email: 'john.doe@example.com'};// 調用 resolve 表示 Promise 成功完成。// Promise 的狀態從 pending 變為 fulfilled。// user 對象會作為成功的結果被傳遞出去。resolve(user);}, 2000);});
}// --- 如何使用這個返回 Promise 的新函數 ---// 1. 模擬一次成功的調用
console.log("發起第一次調用 (userId: 123)...");
fetchUserData(123).then((user) => {console.log("成功獲取到用戶(123):", user);}).catch((error) => {console.error("獲取用戶(123)失敗:", error);});// 2. 模擬一次失敗的調用
console.log("發起第二次調用 (userId: -1)...");
fetchUserData(-1).then((user) => {console.log("成功獲取到用戶(-1):", user);}).catch((error) => {console.error("獲取用戶(-1)失敗:", error);});console.log("Promise 已發出,代碼繼續執行,等待結果...");
代碼解析:Promise 的生命周期
讓我們分別追蹤“成功”和“失敗”這兩次調用的完整旅程。
A. 成功的旅程 (fetchUserData(123)
)
- 創建: 調用
fetchUserData(123)
,一個新的 Promise 對象被立刻創建并返回。此時,它的內部狀態是pending
(進行中)。 - 執行: Promise 構造函數里的代碼開始執行。
userId
(123) > 0,所以if
判斷不成立。 - 等待:
setTimeout
被設置。程序繼續向下執行,打印出 “Promise 已發出…”。主線程現在空閑了,等待 2 秒。 - 成功 (Fulfill): 2 秒后,
setTimeout
的回調觸發。user
對象被創建,然后resolve(user)
被調用。 - 狀態變更:
resolve()
的調用,使 Promise 的狀態從pending
變為fulfilled
(已成功)。user
對象作為成功的結果被“封裝”起來。 - 觸發
.then
: Promise 狀態的改變會自動觸發鏈接在它上面的.then()
方法。user
對象被作為參數傳遞給.then()
內部的那個箭頭函數。 - 結果: 控制臺打印出
成功獲取到用戶(123): ...
。
B. 失敗的旅程 (fetchUserData(-1)
)
- 創建: 調用
fetchUserData(-1)
,另一個新的pending
狀態的 Promise 對象被創建并返回。 - 執行: Promise 構造函數里的代碼開始執行。
userId
(-1) <= 0,if
判斷成立。 - 失敗 (Reject):
reject("Invalid User ID")
被立刻調用。 - 狀態變更:
reject()
的調用,使 Promise 的狀態從pending
變為rejected
(已失敗)。錯誤消息"Invalid User ID"
作為失敗的原因被“封裝”起來。 - 觸發
.catch
: Promise 狀態的改變會自動觸發鏈接在它上面的.catch()
方法(它會跳過所有.then
)。錯誤消息被作為參數傳遞給.catch()
內部的箭頭函數。 - 結果: 控制臺打印出
獲取用戶(-1)失敗: Invalid User ID
。因為這個過程是同步的(沒有setTimeout
),所以這個結果會很快出現。
Promise 解決了什么?
現在我們可以回答,為什么 Promise 比回調函數好。
-
統一的接口: 無論成功還是失敗,你都與同一個 Promise 對象打交道,使用
.then()
和.catch()
這兩個標準方法來處理,代碼結構清晰。 -
避免回調地獄: Promise 的核心優勢在于鏈式調用 (Chaining)。如果我們需要在獲取用戶后,再獲取他的文章,代碼會是這樣:
fetchUserData(123).then(user => {// user 獲取成功,現在發起獲取文章的請求// 假設 fetchUserPosts 也返回一個 Promisereturn fetchUserPosts(user.id); }).then(posts => {// posts 獲取成功,現在可以處理文章了console.log("用戶的文章:", posts);}).catch(error => {// 任何一步失敗,都會被這一個 .catch 捕獲!console.error("在處理過程中發生錯誤:", error);});
看到了嗎?沒有層層嵌套,而是一個扁平、線性的鏈條,非常易于閱讀和維護。一個
.catch
就能處理整個鏈條中任何環節的錯誤,這是巨大的優勢。
雖然 Promise 已經非常強大,但 ES2017 (ES8) 引入了 async/await
語法,它讓我們能以一種更像同步代碼的、更直觀的方式來使用 Promise。這被稱為異步編程的“終極形態”,也是我們下一個練習的主題。
準備好后,告訴我,我們就可以開始學習 async/await
了。