本文為 AJAX 基本介紹系列文,第 8 篇:
- 初學者指南:什麼是HTTP 方法?
- 網頁狀態碼(HTTP Status Code)指南:從分類到常見應用
- 新手指南:什麼是同步與非同步?
- 新手指南:什麼是 AJAX?
- 新手指南:什麼是 XMLHttpRequest?
- 初學者指南:了解 JavaScript 的 fetch 函數
- 深入理解Fetch 函數的第二個參數
- JavaScript Promise 是什麼?從 fetch 印出的 pending 開始理解 👈進度
- JavaScript 中的await 關鍵字:從fetch 到現代非同步處理
- 初學者指南:了解JavaScript 中的Promise 和await
- fetch 方法到底取得的東西是什麼?
- 新手指南:什麼是 JSON?
你可能已經照著範例用 fetch 拿過資料,也寫過 .then(),但心裡一直有幾個問號。
為什麼 fetch 不直接把資料給你,要多包一層?
.then() 裡面那個函式,到底是誰在什麼時候去執行它的?
為什麼有時候看到 .catch(),有時候又看到 .then() 裡面塞兩個參數?
這幾個問題的答案都指向同一個東西:Promise。
同步與非同步
在看 Promise 之前,要先分清楚同步(synchronous)與非同步(asynchronous)這兩種執行方式。
同步操作
同步操作指的是執行某個任務時,必須等這個任務完成,才能繼續執行後面的程式碼。
也就是說,任務是依序排隊執行的。
console.log("任務1");
console.log("任務2");
console.log("任務3");這三行會照著寫的順序印出「任務1」「任務2」「任務3」,上一行沒跑完,下一行不會開始。
非同步操作
非同步操作則是:某個任務啟動之後,不必等它完成,後面的程式碼就可以先跑。
console.log("任務1");
setTimeout(() => {
console.log("任務2");
}, 2000);
console.log("任務3");這段程式碼印出來的順序是「任務1」「任務3」「任務2」。
setTimeout 是一個非同步函式,執行到它的時候,JavaScript 引擎不會停下來等那兩秒,而是直接往下跑 console.log("任務3")。
兩秒之後,才輪到 setTimeout 裡面的函式執行,印出「任務2」。
為什麼需要非同步操作?
很多操作本質上就是要等的,如果每次都停下來等,整個網頁會卡住不能動。
- 網路請求:跟伺服器要資料可能要好幾秒,非同步讓你在等的同時繼續做別的事。
- 計時器:像
setTimeout和setInterval,指定時間到才執行,中間不擋住其他程式碼。 - 檔案操作:讀寫檔案需要時間,非同步讓你不必卡在那裡等。
回調地獄:Promise 出現之前的寫法
非同步很好用,但它帶來一個新問題:如果任務二必須等任務一跑完才能開始,你要怎麼表達這個先後關係?
在 Promise 出現之前,唯一的答案藏在一段你上一節就寫過的程式碼裡。
什麼是回調?
回頭看前面那個 setTimeout:
setTimeout(() => {
console.log("任務2");
}, 2000);這樣寫看起來像是「那段程式碼寫在 setTimeout 裡面」,不太容易看出發生了什麼事。
我們把那個函式先取個名字,拉到外面:
function task2() {
console.log("任務2");
}
setTimeout(task2, 2000);現在看最後一行。task2 後面沒有括號。
有沒有那對括號,差別很大:
task2()的意思是「現在就執行這個函式」。task2的意思是「這個函式本身」,跟數字5或字串"hello"一樣,就只是一個值。
所以 setTimeout(task2, 2000) 這一行並不會執行 task2,它做的事是把 task2 這個函式當成值交給 setTimeout,然後這一行就結束了。
兩秒之後,換 setTimeout 動手:它把手上保管的那個函式補上括號,替你執行 task2(),「任務2」這時候才被印出來。
所以函式最後確實有跑,只是執行它的人不是你,是 setTimeout,而且時間點也由 setTimeout 決定。
回到一開始的箭頭函式寫法,它做的是同一件事,只是省略了取名字這一步,直接把函式寫在參數的位置上。
像這樣交給別人保管、由別人決定什麼時候執行的函式,就叫做回調(callback)。
非同步操作幾乎都得這樣寫,因為它們的本質就是「現在還不能做,等一下才能做」。
你沒辦法在程式碼裡直接寫下那一行然後期待它等一下才跑,只能先包成函式交出去,讓負責等待的那一方在對的時機幫你執行。
為什麼每個任務都要多一個參數?
先只看一個任務。
假設你把任務一包成函式,然後想在它跑完之後接著做別的事:
❌ 直覺但行不通的寫法:
function firstTask() {
setTimeout(() => {
console.log("完成任務1");
}, 1000);
}
firstTask();
console.log("任務1 後面要做的事");執行結果是「任務1 後面要做的事」先印出來,一秒後才印「完成任務1」,順序整個反過來。
原因你已經知道了:setTimeout 是非同步的,firstTask() 這一行不會等,它啟動計時器之後立刻就結束了。
從外面看,firstTask() 執行完的那一刻,任務一其實還沒開始跑。
所以你不能站在外面等它,只能把要做的事送進去。
✅ 用回調的寫法:
function firstTask(callback) {
setTimeout(() => {
console.log("完成任務1");
callback();
}, 1000);
}
firstTask(() => {
console.log("任務1 後面要做的事");
});firstTask 多了一個參數 callback,這個參數就是「任務一做完之後要做什麼」。
firstTask 自己不知道那是什麼,也不需要知道,它只負責在對的時機執行它。
注意 callback() 這一行有括號。這裡就是真正執行的時刻——firstTask 現在扮演的角色,跟前面的 setTimeout 一模一樣:手上保管著一個函式,時間到了才補上括號執行。
最下面呼叫的時候,你傳進去的那個箭頭函式就是 callback 收到的東西。
順帶一提,這段程式碼裡有兩個回調:交給 setTimeout 的箭頭函式,以及交給 firstTask 的箭頭函式。它們是兩層不同的委託,只是剛好長得很像。
回調地獄長什麼樣子
一個任務長這樣還算清楚。
現在把三個任務串起來,每個都必須等前一個完成才能開始。
三個任務函式本身沒什麼好說的,就是同一段程式碼複製三份,只差印出來的字:
function firstTask(callback) {
setTimeout(() => {
console.log("完成任務1");
callback();
}, 1000);
}
function secondTask(callback) {
setTimeout(() => {
console.log("完成任務2");
callback();
}, 1000);
}
function thirdTask(callback) {
setTimeout(() => {
console.log("完成任務3");
callback();
}, 1000);
}麻煩的是怎麼把它們串起來。
先看一個比較囉唆、但很好讀的寫法:把每一步做完之後要做的事,各自取一個名字。
function runTask2() {
secondTask(runTask3);
}
function runTask3() {
thirdTask(done);
}
function done() {
console.log("所有任務完成");
}
firstTask(runTask2);這段程式碼要從最後一行開始讀,因為那才是起點,前面三個函式在被交出去之前都只是躺著不動。
firstTask(runTask2) 的意思是:跑任務一,做完之後跑 runTask2。
那 runTask2 又是什麼?往上看它的定義:跑任務二,做完之後跑 runTask3。
runTask3 呢?跑任務三,做完之後跑 done。
done 最單純,就是印出「所有任務完成」。
實際執行的時候,順序會像這樣:
每一步都是同一個動作重複三次:執行手上保管的回調,啟動下一個任務,再把下一步交出去。
注意這四個函式名字後面全都沒有括號,它們都是被交出去保管的回調。
問題是實務上沒有人會這樣寫。
runTask2、runTask3、done 這些名字本身沒有帶來任何資訊,取名字唯一的目的只是為了把函式擺到別的地方去。
既然名字沒用,那就不要取了,直接把函式寫在參數的位置上:
firstTask(() => {
secondTask(() => {
thirdTask(() => {
console.log("所有任務完成");
});
});
});這段跟上面那段做的事一模一樣,只是把 runTask2 的內容原地展開、塞進 firstTask( 和 ) 中間,runTask3 和 done 也照做。
原本三個平放的函式,現在變成一個包一個。
讀這種程式碼有個訣竅:只看每一層的第一行。
由上往下是 firstTask、secondTask、thirdTask、印出「所有任務完成」,這就是執行順序,中間的縮排可以先不管。
至於最後那三行 });,是由內往外收尾:最上面那個關掉 thirdTask 的呼叫,中間那個關掉 secondTask,最下面那個關掉 firstTask。
問題就在這裡:每多一個任務,就多一層縮排。
而且縮排增加的原因很無奈:你要在任務一做完之後跑任務二,但「任務一做完」這件事只有 firstTask 的回調知道,所以 secondTask 只能寫在裡面。同樣的道理,thirdTask 只能再往裡面塞一層。
三個原本平起平坐、只是有先後順序的步驟,就這樣被寫成了三層巢狀結構。
把實際發生的順序畫出來,它就是一條直線:零秒開始任務一,一秒開始任務二,兩秒開始任務三,三秒全部完成。
但寫成程式碼之後,這條直線變成了一座往右下走的樓梯。
這就是回調地獄真正麻煩的地方——你腦中想的是一連串先後發生的事,程式碼卻長成另一種形狀,兩者對不起來。
而這還是最順利的情況。如果每個任務都要處理失敗,你得在每一層各寫一次錯誤處理,因為外層的程式碼看不到內層發生了什麼事。
這種因為回調層層嵌套而失控的寫法,就叫做回調地獄(Callback Hell)。
延伸閱讀:回調地獄範例程式碼解析
Promise 是什麼?
先看一個畫面。
fetch 是 JavaScript 內建的函式,功能是跟指定的網址要資料。
它是非同步的,資料要等一小段時間才會回來,所以它正好可以拿來觀察非同步的行為。
現在直接把 fetch 回傳的東西印出來:
console.log(fetch("https://jsonplaceholder.typicode.com/users/1"));
// Promise { <pending> }資料明明還沒回來,fetch 卻已經回傳東西給你了。
而且那個東西不是資料,是一個叫做 Promise 的物件,後面還跟著 <pending>。
延伸閱讀:初學者指南:了解 JavaScript 的 fetch 函數
這就是 Promise:一個非同步操作立刻回傳給你的物件,代替那個還沒生出來的結果。
它身上有兩樣東西。
一個是結果欄位,現在還空著,等非同步操作跑完才會被填進去。
另一個是待辦清單,記錄著「結果填進來之後要做什麼」。你用 .then() 把函式放進這份清單,等結果到了,JavaScript 會自己把清單上的函式拿出來執行,並且把結果當成參數傳進去。
<pending> 就是在告訴你:結果欄位現在還是空的。
Promise 的三種狀態
pending 只是三種狀態的其中一種。
- Pending(等待中):結果欄位還空著,非同步操作還沒跑完。
- Fulfilled(已成功):操作成功了,結果欄位裡放著一個值。
- Rejected(已失敗):操作失敗了,結果欄位裡放著失敗的原因。
這裡有一件最容易被忽略、但後面所有行為都建立在它上面的事:狀態只會離開 Pending 一次,而且不會再變回去。
一個 Promise 從 Pending 變成 Fulfilled 之後,就永遠是 Fulfilled,不會再轉成 Rejected,也不會退回 Pending。
因為狀態只會二選一,所以掛在同一個 Promise 上的 .then() 和 .catch(),永遠只會有一邊被執行。
如何建立一個 Promise?
平常你比較常遇到的是別人給你的 Promise,例如 fetch() 回傳的那個。
但自己動手做一個,是理解它怎麼運作最快的方式。
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve("操作成功!");
} else {
reject("操作失敗!");
}
}, 2000);
});這段程式碼在做的事是:建立一個 Promise,並且用 setTimeout 假裝有一個要跑兩秒的非同步操作。
先看 new Promise() 的參數,它是一個函式。JavaScript 會在你建立 Promise 的當下馬上執行這個函式,所以你的非同步操作要寫在裡面。
再看這個函式自己的兩個參數 resolve 和 reject。
你可能會覺得奇怪:這兩個名字我從來沒有宣告過,為什麼可以直接拿來用?
因為它們不用你定義,是 JavaScript 準備好塞給你的兩個函式。
resolve 的作用是把狀態從 Pending 推到 Fulfilled,你傳給它的東西會變成結果欄位裡的值。
reject 則是把狀態推到 Rejected,你傳給它的東西會變成失敗的原因。
所以上面那段程式碼的意思是:兩秒後,如果 success 是 true 就呼叫 resolve,把這個 Promise 標記成功;否則呼叫 reject,標記失敗。
在你呼叫其中一個之前,這個 Promise 會一直停在 Pending。
用 .then() 拿到結果
Promise 建立好了,接下來的問題是:結果來了,你要在哪裡接?
答案就是 .then()。
你把「結果來的時候要做什麼」寫成一個函式交給它,等狀態變成 Fulfilled,JavaScript 會執行這個函式,並且把結果欄位裡的值當成參數傳進去。
myPromise.then((successMessage) => {
console.log(successMessage); // 印出 "操作成功!"
});這裡的 successMessage 就是前面呼叫 resolve("操作成功!") 時傳進去的那個字串。
參數名稱可以隨便取,重點是它接到的一定是 resolve 傳出來的東西。
處理失敗的情況
.then() 其實可以吃兩個參數。
promise.then(onFulfilled, onRejected);onFulfilled(可選):狀態變成 Fulfilled 時要執行的函式。onRejected(可選):狀態變成 Rejected 時要執行的函式。
所以要處理失敗,你有兩種寫法。
第一種是把失敗的處理函式塞進 .then() 的第二個參數:
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
reject("操作失敗!");
}, 1000);
});
myPromise.then(
(successMessage) => {
console.log(successMessage);
},
(errorMessage) => {
console.error(errorMessage); // 印出 "操作失敗!"
}
);第二種是用 .catch():
myPromise
.then((successMessage) => {
console.log(successMessage);
})
.catch((errorMessage) => {
console.error(errorMessage); // 印出 "操作失敗!"
});兩種寫法在這個例子裡結果一樣,但實務上大多會用 .catch()。
原因在下一節串起多個 .then() 的時候會看得很清楚:.catch() 可以一次接住整條鏈上的錯誤,而 .then() 的第二個參數只管得到它前面那一個。
錯誤處理
.then() 裡面丟出來的錯誤,也會被後面的 .catch() 接住。
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("操作成功!");
}, 1000);
});
myPromise
.then((successMessage) => {
console.log(successMessage);
throw new Error("出現錯誤");
})
.then((message) => {
console.log(message); // 不會執行
})
.catch((error) => {
console.error(error); // 印出 "出現錯誤"
});這個 Promise 本身是成功的,第一個 .then() 正常執行、印出「操作成功!」,然後在裡面丟出一個錯誤。
錯誤一旦丟出來,中間的 .then() 就會被整個跳過,直接交給最後的 .catch()。
Promise 如何解決回調地獄
回頭看前面那段三層巢狀的程式碼。
.then() 有一個關鍵特性:它會回傳一個新的 Promise。
既然回傳的還是 Promise,那就可以再接一個 .then(),接完再接一個。原本要靠巢狀表達的先後順序,就變成一條由上往下的直線。
❌ 用回調的寫法:
firstTask(() => {
secondTask(() => {
thirdTask(() => {
console.log("所有任務完成");
});
});
});任務之間的先後關係是用縮排層級表達的,多一個任務就多一層縮排。
✅ 用 Promise 的寫法:
const firstTask = new Promise((resolve) => {
setTimeout(() => {
resolve("完成任務1");
}, 1000);
});
const secondTask = (message) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(message + " -> 完成任務2");
}, 1000);
});
};
const thirdTask = (message) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(message + " -> 完成任務3");
}, 1000);
});
};
firstTask
.then((message) => {
console.log(message); // 印出 "完成任務1"
return secondTask(message);
})
.then((message) => {
console.log(message); // 印出 "完成任務1 -> 完成任務2"
return thirdTask(message);
})
.then((message) => {
console.log(message); // 印出 "完成任務1 -> 完成任務2 -> 完成任務3"
})
.catch((error) => {
console.error(error);
});任務之間的先後關係變成由上往下讀,多一個任務就多一層 .then(),縮排不會增加。
這裡有一個容易漏掉的細節:每個 .then() 裡面都要 return。
當你在 .then() 裡面回傳一個 Promise,JavaScript 會等那個 Promise 有結果之後,才把結果傳給下一個 .then()。如果忘了 return,下一個 .then() 拿到的就是 undefined,而且不會等。
錯誤處理也一起簡化了。整條鏈上任何一個環節出錯,都會直接跳到最後那個 .catch(),不用像回調那樣每一層都寫一次。
.finally():不管成功失敗都要做的事
有些事情不管結果如何都得做,例如把載入中的轉圈圈關掉。
如果只有 .then() 和 .catch(),你得在兩邊各寫一次。
.finally() 就是為了這種情況存在的,它在狀態變成 Fulfilled 或 Rejected 之後都會執行。
fetchUserData(1)
.then((userData) => {
console.log(userData.name);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
console.log("不管成功還是失敗,這行都會執行");
});跟前兩者不同的是,.finally() 的函式不接收任何參數。
因為它同時服務成功和失敗兩條路,沒辦法確定該給它值還是給它原因,乾脆兩個都不給。
實際應用:跟伺服器要資料
把前面的東西湊起來,看一個接近真實情況的例子。
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve({ id: userId, name: "John Doe" });
} else {
reject("無法取得使用者資料");
}
}, 3000);
});
}
fetchUserData(1)
.then((userData) => {
console.log(`使用者ID: ${userData.id}, 使用者名稱: ${userData.name}`);
})
.catch((error) => {
console.error(error);
});fetchUserData 這個函式本身不回傳資料,它回傳一個 Promise。
呼叫它的當下你什麼都拿不到,只拿到一張還在 Pending 的 Promise,三秒後 resolve 被呼叫,狀態變成 Fulfilled,.then() 才會拿到那個物件。
這也正是 fetch() 的運作方式。現在回頭看文章開頭那個 Promise { <pending> },應該就不奇怪了。
重點整理
回頭把整條線再走一次。
非同步操作沒辦法當場給你結果,所以在 Promise 出現之前,你只能把「結果來了要做什麼」包成函式交出去,讓別人在對的時機幫你執行,這就是回調。
一個任務這樣寫沒什麼問題,但只要任務之間有先後關係,下一步就得塞進上一步的回調裡。
三個任務疊成三層,程式碼的形狀跟實際發生的順序完全對不上,這就是回調地獄。
Promise 換了一個思路:非同步操作不再要求你先交出回調,而是立刻回傳一個物件給你。
這個物件身上有一個還空著的結果欄位,以及一份待辦清單,你想做的事用 .then() 放進清單,等結果填進來,JavaScript 會自己拿出來執行。
它的狀態一開始是 Pending,之後只會變成 Fulfilled 或 Rejected 其中一種,而且變了就不會再動。
正因為不可逆,掛在同一個 Promise 上的 .then() 和 .catch() 才永遠只有一邊會執行。
如果這個 Promise 是你用 new Promise() 自己建的,決定往哪邊走的是 resolve 和 reject——這兩個函式不用你定義,是 JavaScript 塞給你的,你呼叫哪一個就是哪一個。
拿結果的三個方法分工很清楚:.then() 接成功的值,.catch() 接失敗的原因,.finally() 兩種情況都會跑,也因為它同時服務兩邊,所以不收任何參數。
而真正把回調地獄解決掉的,是 .then() 會回傳一個新的 Promise 這件事。
回傳的還是 Promise,就還能再接一個 .then(),於是原本往右長的巢狀結構被攤平成一條由上往下的直線。
唯一要記得的是,在 .then() 裡面回傳 Promise 的時候別忘了寫 return,忘了的話下一個 .then() 拿到的會是 undefined,而且不會等。