Promise ช่วยลดความซับซ้อนของการคำนวณแบบเลื่อนเวลาและแบบอะซิงโครนัส Promise แสดงถึงการดำเนินการที่ยังไม่เสร็จสมบูรณ์
นักพัฒนาเว็บ เตรียมตัวให้พร้อมสำหรับช่วงเวลาสำคัญในประวัติศาสตร์ของการพัฒนาเว็บ
[เสียงรัวกลองเริ่มขึ้น]
Promise มีให้ใช้งานใน JavaScript แล้ว
[พลุระเบิด กระดาษกลิตเตอร์โปรยปรายลงมาจากด้านบน ฝูงชนส่งเสียงเชียร์]
ในขั้นตอนนี้ คุณจะอยู่ในหมวดหมู่ใดหมวดหมู่หนึ่งต่อไปนี้
- ผู้คนรอบตัวคุณกำลังส่งเสียงเชียร์ แต่คุณไม่แน่ใจว่าพวกเขากำลังตื่นเต้นเรื่องอะไร คุณอาจไม่แน่ใจด้วยซ้ำว่า "สัญญา" คืออะไร คุณอยากจะยักไหล่ แต่กระดาษแวววาวที่หนักอึ้งก็กดทับไหล่ของคุณอยู่ ถ้าเป็นเช่นนั้นก็ไม่ต้องกังวลไป เพราะฉันเองก็ใช้เวลานานกว่าจะเข้าใจว่าทำไมฉันจึงควรให้ความสำคัญกับเรื่องนี้ คุณอาจต้องการเริ่มจากจุดเริ่มต้น
- คุณชกอากาศ ถึงเวลาแล้วใช่ไหม คุณเคยใช้ Promise มาก่อน แต่ก็รู้สึกรำคาญที่การใช้งานทั้งหมดมี API ที่แตกต่างกันเล็กน้อย API สำหรับ JavaScript เวอร์ชันอย่างเป็นทางการคืออะไร คุณอาจต้องการเริ่มต้นด้วยคำศัพท์
- คุณรู้เรื่องนี้อยู่แล้วและหัวเราะเยาะคนที่ตื่นเต้นราวกับว่าเพิ่งรู้เรื่องนี้ ใช้เวลาสักครู่เพื่อดื่มด่ำกับความเหนือชั้นของคุณ แล้วตรงไปที่เอกสารอ้างอิง API
การรองรับเบราว์เซอร์และ Polyfill
หากต้องการทำให้เบราว์เซอร์ที่ไม่มีการใช้งาน Promise อย่างสมบูรณ์เป็นไปตามข้อกำหนด หรือเพิ่ม Promise ลงในเบราว์เซอร์อื่นๆ และ Node.js โปรดดู Polyfill (2k gzipped)
ทำไมถึงตื่นเต้นกันขนาดนี้
JavaScript เป็นแบบเทรดเดียว ซึ่งหมายความว่าสคริปต์ 2 บิตจะทำงานพร้อมกันไม่ได้ แต่ต้องทำงานทีละรายการ ในเบราว์เซอร์ JavaScript จะแชร์เทรดกับสิ่งอื่นๆ อีกมากมายซึ่งแตกต่างกันไปในแต่ละเบราว์เซอร์ แต่โดยปกติแล้ว JavaScript จะอยู่ในคิวเดียวกับการวาด การอัปเดตสไตล์ และการจัดการการดำเนินการของผู้ใช้ (เช่น การไฮไลต์ข้อความและการโต้ตอบกับการควบคุมแบบฟอร์ม) กิจกรรมในสิ่งใดสิ่งหนึ่งเหล่านี้จะทำให้สิ่งอื่นๆ ล่าช้า
ในฐานะมนุษย์ คุณสามารถทำหลายอย่างพร้อมกันได้ คุณพิมพ์ด้วยหลายนิ้วได้ คุณขับรถและสนทนาไปพร้อมกันได้ ฟังก์ชันการบล็อกเดียวที่เราต้องจัดการคือการจาม ซึ่งกิจกรรมปัจจุบันทั้งหมดต้องถูกระงับไว้ตลอดระยะเวลาของการจาม ซึ่งน่ารำคาญมาก โดยเฉพาะเมื่อคุณกำลังขับรถและพยายามสนทนาอยู่ คุณคงไม่อยากเขียนโค้ดที่ทำให้จามหรอก
คุณอาจเคยใช้เหตุการณ์และ Callback เพื่อแก้ปัญหานี้ นี่คือกิจกรรมต่างๆ
var img1 = document.querySelector('.img-1');
img1.addEventListener('load', function() {
// woo yey image loaded
});
img1.addEventListener('error', function() {
// argh everything's broken
});
This isn't sneezy at all. เราจะได้รับรูปภาพ เพิ่ม Listener 2-3 รายการ จากนั้น JavaScript จะหยุดทำงานจนกว่าจะมีการเรียกใช้ Listener รายการใดรายการหนึ่ง
แต่ในตัวอย่างข้างต้น เหตุการณ์อาจเกิดขึ้นก่อนที่เราจะเริ่มฟังเหตุการณ์ ดังนั้นเราจึงต้องแก้ไขปัญหานี้โดยใช้พร็อพเพอร์ตี้ "complete" ของรูปภาพ ดังนี้
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
วิธีนี้จะไม่ตรวจจับรูปภาพที่เกิดข้อผิดพลาดก่อนที่เราจะมีโอกาสตรวจจับ เนื่องจาก DOM ไม่ได้ให้วิธีในการดำเนินการดังกล่าว นอกจากนี้ รูปภาพนี้กำลังโหลดอยู่ สิ่งต่างๆ จะซับซ้อนยิ่งขึ้นหากเราต้องการทราบว่าชุดรูปภาพโหลดเสร็จเมื่อใด
เหตุการณ์อาจไม่ใช่วิธีที่ดีที่สุดเสมอไป
เหตุการณ์เหมาะสำหรับสิ่งที่เกิดขึ้นได้หลายครั้งในออบเจ็กต์เดียวกัน เช่น keyup, touchstart เป็นต้น เมื่อใช้เหตุการณ์เหล่านั้น คุณจะไม่สนใจสิ่งที่เกิดขึ้นก่อนที่จะแนบ Listener แต่เมื่อพูดถึงความสำเร็จ/ความล้มเหลวแบบไม่พร้อมกัน คุณควรใช้โค้ดลักษณะนี้
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
ซึ่งเป็นสิ่งที่ Promise ทำ แต่มีการตั้งชื่อที่ดีกว่า หากองค์ประกอบรูปภาพ HTML มีเมธอด "ready" ที่แสดงผล Promise เราจะทำสิ่งนี้ได้
img1.ready()
.then(function() {
// loaded
}, function() {
// failed
});
// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
// all loaded
}, function() {
// one or more failed
});
โดยพื้นฐานแล้ว Promise จะคล้ายกับ Listener เหตุการณ์เล็กน้อย ยกเว้นในกรณีต่อไปนี้
- Promise จะสำเร็จหรือล้มเหลวได้เพียงครั้งเดียวเท่านั้น โดยจะสำเร็จหรือล้มเหลว 2 ครั้งไม่ได้ และจะเปลี่ยนจากสำเร็จเป็นล้มเหลวหรือในทางกลับกันไม่ได้
- หากสัญญาสำเร็จหรือล้มเหลว และคุณเพิ่ม Callback สำเร็จ/ไม่สำเร็จในภายหลัง ระบบจะเรียกใช้ Callback ที่ถูกต้อง แม้ว่าเหตุการณ์จะเกิดขึ้นก่อนหน้านี้ก็ตาม
ซึ่งมีประโยชน์อย่างยิ่งสำหรับความสำเร็จ/ความล้มเหลวแบบไม่พร้อมกัน เนื่องจากคุณไม่ค่อยสนใจเวลาที่แน่นอนที่บางสิ่งพร้อมใช้งาน แต่สนใจการตอบสนองต่อผลลัพธ์มากกว่า
คำศัพท์เกี่ยวกับสัญญา
Domenic Denicola พิสูจน์อักษรฉบับร่างแรกของบทความนี้และให้คะแนน "F" สำหรับคำศัพท์ เขาลงโทษกักบริเวณฉัน บังคับให้ฉันคัดลอกหนังสือ States and Fates 100 ครั้ง และเขียนจดหมายแสดงความกังวลถึงพ่อแม่ของฉัน ถึงอย่างนั้น ฉันก็ยังสับสนกับคำศัพท์หลายคำอยู่ แต่พื้นฐานมีดังนี้
สัญญาอาจเป็นดังนี้
- fulfilled - การดำเนินการที่เกี่ยวข้องกับ Promise สำเร็จ
- rejected - การดำเนินการที่เกี่ยวข้องกับ Promise ไม่สำเร็จ
- pending - ยังไม่ได้ดำเนินการตามคำสั่งซื้อหรือปฏิเสธ
- settled - ดำเนินการตามคำขอหรือปฏิเสธแล้ว
ข้อกำหนดยังใช้คำว่า thenable เพื่ออธิบายออบเจ็กต์ที่คล้ายกับ Promise เนื่องจากมีเมธอด then คำนี้ทำให้ฉันนึกถึงอดีตผู้จัดการทีมฟุตบอลทีมชาติอังกฤษอย่าง Terry Venables ฉันจึงจะใช้คำนี้ให้น้อยที่สุด
Promise มาถึง JavaScript แล้ว
Promise มีมานานแล้วในรูปแบบของไลบรารี เช่น
Promise ข้างต้นและ Promise ของ JavaScript มีลักษณะการทำงานร่วมกันที่ได้มาตรฐานซึ่งเรียกว่า Promises/A+ หากคุณเป็นผู้ใช้ jQuery จะมีสิ่งที่คล้ายกันที่เรียกว่า Deferred อย่างไรก็ตาม Deferred ไม่เป็นไปตาม Promise/A+ ซึ่งทำให้แตกต่างกันเล็กน้อยและมีประโยชน์น้อยกว่า ดังนั้นโปรดระวัง jQuery ยังมีประเภท Promise ด้วย แต่นี่เป็นเพียงชุดย่อยของ Deferred และมีปัญหาเดียวกัน
แม้ว่าการใช้งาน Promise จะเป็นไปตามลักษณะการทำงานที่เป็นมาตรฐาน แต่ API โดยรวมจะแตกต่างกัน JavaScript Promise มี API คล้ายกับ RSVP.js วิธีสร้างสัญญา
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…
if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
เครื่องมือสร้าง Promise จะรับอาร์กิวเมนต์ 1 รายการ ซึ่งเป็น Callback ที่มีพารามิเตอร์ 2 รายการ ได้แก่ resolve และ reject ดำเนินการบางอย่างภายใน Callback ซึ่งอาจเป็นแบบไม่พร้อมกัน จากนั้นเรียกใช้ resolve หากทุกอย่างทำงานได้ตามปกติ หรือเรียกใช้ reject หากไม่เป็นเช่นนั้น
เช่นเดียวกับ throw ใน JavaScript แบบเก่าทั่วไป การปฏิเสธด้วยออบเจ็กต์ Error เป็นเรื่องปกติ แต่ไม่จำเป็น ประโยชน์ของออบเจ็กต์ Error คือออบเจ็กต์เหล่านี้จะบันทึกสแต็กเทรซ ซึ่งช่วยให้เครื่องมือแก้ไขข้อบกพร่องมีประโยชน์มากขึ้น
วิธีใช้คำมั่นสัญญาดังกล่าวมีดังนี้
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then() รับอาร์กิวเมนต์ 2 รายการ ได้แก่ Callback สำหรับกรณีที่สำเร็จ และอีกรายการสำหรับกรณีที่ล้มเหลว ทั้ง 2 รายการเป็นแบบไม่บังคับ คุณจึงเพิ่ม Callback สำหรับกรณีที่สำเร็จหรือล้มเหลวเท่านั้นได้
JavaScript Promise เริ่มต้นใน DOM ในชื่อ "Futures" จากนั้นเปลี่ยนชื่อเป็น "Promises" และสุดท้ายย้ายไปที่ JavaScript การมีออบเจ็กต์เหล่านี้ใน JavaScript แทนที่จะเป็น DOM นั้นดีมากเพราะจะพร้อมใช้งานในบริบท JS ที่ไม่ใช่เบราว์เซอร์ เช่น Node.js (การใช้ใน API หลักเป็นอีกเรื่องหนึ่ง)
แม้ว่าจะเป็นฟีเจอร์ JavaScript แต่ DOM ก็ไม่กลัวที่จะใช้ฟีเจอร์นี้ อันที่จริงแล้ว DOM API ใหม่ทั้งหมดที่มีเมธอดความสำเร็จ/ความล้มเหลวแบบไม่พร้อมกันจะใช้ Promise ซึ่งเกิดขึ้นแล้วกับการจัดการโควต้า, เหตุการณ์การโหลดแบบอักษร, ServiceWorker, Web MIDI, สตรีม และอื่นๆ
ความเข้ากันได้กับไลบรารีอื่นๆ
JavaScript promises API จะถือว่าสิ่งใดก็ตามที่มีเมธอด then() เป็นแบบ Promise (หรือ thenable ในภาษา Promise เฮ้อ) ดังนั้นหากคุณใช้ไลบรารีที่แสดงผล Q Promise ก็ไม่เป็นไร เพราะจะทำงานร่วมกับ JavaScript promises ใหม่ได้ดี
อย่างไรก็ตาม อย่างที่ผมได้กล่าวไปแล้วว่า Deferred ของ jQuery นั้นค่อนข้าง… ไม่เป็นประโยชน์ โชคดีที่คุณสามารถแคสต์เป็น Promise มาตรฐานได้ ซึ่งควรทำโดยเร็วที่สุด
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
ในที่นี้ $.ajax ของ jQuery จะแสดงผลเป็น Deferred เนื่องจากมีเมธอด then()
Promise.resolve() จึงเปลี่ยนเป็น JavaScript Promise ได้ อย่างไรก็ตาม บางครั้งออบเจ็กต์ Deferred จะส่งอาร์กิวเมนต์หลายรายการไปยัง Callback เช่น
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
ในขณะที่ JS Promise จะไม่สนใจทั้งหมดยกเว้นรายการแรก
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
ซึ่งโดยปกติแล้วจะเป็นสิ่งที่คุณต้องการ หรืออย่างน้อยก็ช่วยให้คุณเข้าถึงสิ่งที่ต้องการได้ นอกจากนี้ โปรดทราบว่า jQuery ไม่เป็นไปตามแบบแผนของการส่งออบเจ็กต์ Error ไปยังการปฏิเสธ
โค้ดแบบอะซิงโครนัสที่ซับซ้อนทำได้ง่ายขึ้น
เอาล่ะ มาเขียนโค้ดกัน สมมติว่าเราต้องการทำสิ่งต่อไปนี้
- เริ่มแสดงไอคอนหมุนเพื่อระบุว่ากำลังโหลด
- ดึงข้อมูล JSON บางส่วนสำหรับเรื่องราว ซึ่งจะให้ชื่อและ URL สำหรับแต่ละบท
- เพิ่มชื่อให้กับหน้าเว็บ
- ดึงข้อมูลแต่ละบท
- เพิ่มเรื่องราวลงในหน้าเว็บ
- หยุดวงล้อหมุน
… แต่ก็ต้องแจ้งให้ผู้ใช้ทราบด้วยหากมีข้อผิดพลาดเกิดขึ้นระหว่างทาง เราจะหยุดภาพหมุนที่จุดนั้นด้วย ไม่เช่นนั้นภาพจะหมุนต่อไปเรื่อยๆ จนทำให้ผู้ใช้เวียนหัวและขัดแย้งกับ UI อื่นๆ
แน่นอนว่าคุณจะไม่ใช้ JavaScript เพื่อแสดงเรื่องราว เนื่องจากการแสดงผลเป็น HTML นั้นเร็วกว่า แต่รูปแบบนี้เป็นเรื่องปกติเมื่อจัดการกับ API นั่นคือการดึงข้อมูลหลายรายการ แล้วทำบางอย่างเมื่อดำเนินการทั้งหมดเสร็จแล้ว
ก่อนอื่น มาจัดการกับการดึงข้อมูลจากเครือข่ายกันก่อน
การทำให้ XMLHttpRequest เป็น Promise
API เก่าจะได้รับการอัปเดตให้ใช้ Promise หากเป็นไปได้ในลักษณะที่เข้ากันได้แบบย้อนหลัง XMLHttpRequest เป็นตัวเลือกหลัก แต่ในระหว่างนี้เรามาเขียนฟังก์ชันง่ายๆ เพื่อส่งคำขอ GET กัน
function get(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// This is called even on 404 etc
// so check the status
if (req.status == 200) {
// Resolve the promise with the response text
resolve(req.response);
}
else {
// Otherwise reject with the status text
// which will hopefully be a meaningful error
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() {
reject(Error("Network Error"));
};
// Make the request
req.send();
});
}
มาลองใช้กันเลย
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.error("Failed!", error);
})
ตอนนี้เราสามารถส่งคำขอ HTTP ได้โดยไม่ต้องพิมพ์ XMLHttpRequest ด้วยตนเอง ซึ่งเป็นเรื่องดี เพราะยิ่งฉันเห็นการเขียนแบบ Camel Case ที่น่ารำคาญของ XMLHttpRequest น้อยลงเท่าไร ชีวิตฉันก็จะมีความสุขมากขึ้นเท่านั้น
การเชื่อมโยง
then() ไม่ใช่จุดสิ้นสุดของเรื่องราว คุณสามารถเชื่อมโยง then เข้าด้วยกันเพื่อเปลี่ยนรูปแบบค่าหรือเรียกใช้การดำเนินการแบบไม่พร้อมกันเพิ่มเติมทีละรายการได้
การเปลี่ยนรูปแบบค่า
คุณเปลี่ยนรูปแบบค่าได้ง่ายๆ โดยการแสดงผลค่าใหม่ ดังนี้
var promise = new Promise(function(resolve, reject) {
resolve(1);
});
promise.then(function(val) {
console.log(val); // 1
return val + 2;
}).then(function(val) {
console.log(val); // 3
})
ลองกลับไปดูตัวอย่างที่ใช้ได้จริงต่อไปนี้
get('story.json').then(function(response) {
console.log("Success!", response);
})
การตอบกลับเป็น JSON แต่ปัจจุบันเราได้รับเป็นข้อความธรรมดา เราสามารถเปลี่ยนฟังก์ชัน get เพื่อใช้ JSON responseType ได้ แต่เราก็สามารถแก้ปัญหานี้ใน Promise ได้เช่นกัน
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
เนื่องจาก JSON.parse() รับอาร์กิวเมนต์เดียวและแสดงผลค่าที่แปลงแล้ว เราจึงสร้างทางลัดได้ดังนี้
get('story.json').then(JSON.parse).then(function(response) {
console.log("Yey JSON!", response);
})
อันที่จริง เราสามารถสร้างฟังก์ชัน getJSON() ได้ง่ายๆ ดังนี้
function getJSON(url) {
return get(url).then(JSON.parse);
}
getJSON() ยังคงแสดงผลเป็น Promise ซึ่งจะดึงข้อมูล URL แล้วแยกวิเคราะห์การตอบกลับเป็น JSON
การจัดคิวการดำเนินการแบบอะซิงโครนัส
นอกจากนี้ คุณยังเชื่อมโยง then เพื่อเรียกใช้การดำเนินการแบบไม่พร้อมกันตามลำดับได้ด้วย
เมื่อคุณส่งคืนบางอย่างจาก Callback ของ then() จะมีสิ่งมหัศจรรย์เกิดขึ้นเล็กน้อย
หากคุณแสดงผลค่า ระบบจะเรียกใช้ then() ถัดไปด้วยค่านั้น อย่างไรก็ตาม หากคุณแสดงผลสิ่งที่คล้ายกับ Promise เมธอด then() ถัดไปจะรอ และจะเรียกใช้เมื่อ Promise นั้นได้รับการดำเนินการ (สำเร็จ/ไม่สำเร็จ) เท่านั้น เช่น
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("Got chapter 1!", chapter1);
})
ในที่นี้ เราส่งคำขอแบบไม่พร้อมกันไปยัง story.json ซึ่งจะให้ชุด URL ที่จะขอ จากนั้นเราจะขอ URL แรก นี่คือตอนที่ Promise เริ่มโดดเด่นกว่ารูปแบบ Callback แบบง่ายๆ อย่างแท้จริง
คุณยังสร้างเมธอดทางลัดเพื่อรับบทได้ด้วย ดังนี้
var storyPromise;
function getChapter(i) {
storyPromise = storyPromise || getJSON('story.json');
return storyPromise.then(function(story) {
return getJSON(story.chapterUrls[i]);
})
}
// and using it is simple:
getChapter(0).then(function(chapter) {
console.log(chapter);
return getChapter(1);
}).then(function(chapter) {
console.log(chapter);
})
เราจะไม่ดาวน์โหลด story.json จนกว่าจะมีการเรียกใช้ getChapter แต่ครั้งถัดไปที่มีการเรียกใช้ getChapter เราจะใช้ Story Promise ซ้ำ ดังนั้นระบบจะดึงข้อมูล story.json เพียงครั้งเดียว Yay Promises!
การจัดการข้อผิดพลาด
ดังที่เราเห็นก่อนหน้านี้ then() รับอาร์กิวเมนต์ 2 รายการ รายการหนึ่งสำหรับความสำเร็จ และอีกรายการสำหรับความล้มเหลว (หรือดำเนินการตามสัญญาและปฏิเสธตามภาษาของ Promise)
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.log("Failed!", error);
})
นอกจากนี้ คุณยังใช้catch()ได้ด้วย ดังนี้
get('story.json').then(function(response) {
console.log("Success!", response);
}).catch(function(error) {
console.log("Failed!", error);
})
catch() ไม่มีอะไรพิเศษ เป็นเพียงแค่ไวยากรณ์สำหรับ then(undefined, func) แต่อ่านง่ายกว่า โปรดทราบว่าตัวอย่างโค้ดทั้ง 2 รายการข้างต้นทำงานไม่เหมือนกัน โดยตัวอย่างหลังจะเทียบเท่ากับโค้ดต่อไปนี้
get('story.json').then(function(response) {
console.log("Success!", response);
}).then(undefined, function(error) {
console.log("Failed!", error);
})
ความแตกต่างนี้อาจดูเล็กน้อย แต่มีประโยชน์อย่างยิ่ง การปฏิเสธ Promise จะข้ามไปยัง then() ถัดไปที่มี Callback การปฏิเสธ (หรือ catch() เนื่องจากเทียบเท่ากัน) เมื่อใช้ then(func1, func2) ระบบจะเรียกใช้ func1 หรือ func2 แต่จะไม่เรียกใช้ทั้ง 2 อย่าง แต่เมื่อใช้ then(func1).catch(func2) ระบบจะเรียกใช้ทั้ง 2 รายการหาก func1 ปฏิเสธ เนื่องจากเป็นขั้นตอนที่แยกกันในเชน โปรดทำตามขั้นตอนต่อไปนี้
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Don't worry about it");
}).then(function() {
console.log("All done!");
})
ขั้นตอนข้างต้นคล้ายกับ try/catch ของ JavaScript ปกติมาก ข้อผิดพลาดที่เกิดขึ้นภายใน "try" จะไปยังบล็อก catch() ทันที นี่คือขั้นตอนข้างต้นในรูปแบบโฟลว์ชาร์ต (เพราะเราชอบโฟลว์ชาร์ต)
ทำตามเส้นสีน้ำเงินสำหรับสัญญาที่บรรลุผล หรือเส้นสีแดงสำหรับสัญญาที่ถูกปฏิเสธ
ข้อยกเว้นและสัญญา JavaScript
การปฏิเสธจะเกิดขึ้นเมื่อมีการปฏิเสธ Promise อย่างชัดแจ้ง แต่ก็อาจเกิดขึ้นโดยนัยด้วยหากมีการแสดงข้อผิดพลาดใน Callback ของคอนสตรัคเตอร์
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// This never happens:
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
ซึ่งหมายความว่าการทำงานที่เกี่ยวข้องกับ Promise ทั้งหมดภายใน Callback ของตัวสร้าง Promise จะเป็นประโยชน์ เพื่อให้ระบบตรวจจับข้อผิดพลาดโดยอัตโนมัติและกลายเป็นการปฏิเสธ
เช่นเดียวกับข้อผิดพลาดที่เกิดขึ้นใน Callback ของ then()
get('/').then(JSON.parse).then(function() {
// This never happens, '/' is an HTML page, not JSON
// so JSON.parse throws
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
การจัดการข้อผิดพลาดในทางปฏิบัติ
ในเรื่องราวและบทของเรา เราสามารถใช้ catch เพื่อแสดงข้อผิดพลาดต่อผู้ใช้ได้ดังนี้
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("Failed to show chapter");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
หากการดึงข้อมูล story.chapterUrls[0] ไม่สำเร็จ (เช่น http 500 หรือผู้ใช้ออฟไลน์) ระบบจะข้าม Callback ที่สำเร็จทั้งหมดที่ตามมา ซึ่งรวมถึง Callback ใน getJSON() ที่พยายามแยกวิเคราะห์การตอบกลับเป็น JSON และยังข้าม Callback ที่เพิ่ม chapter1.html ลงในหน้าด้วย แต่จะย้ายไปยัง Callback ของ catch แทน ด้วยเหตุนี้ ระบบจะเพิ่ม "แสดงบทไม่สำเร็จ" ลงในหน้าหากการดำเนินการก่อนหน้านี้ไม่สำเร็จ
เช่นเดียวกับ try/catch ของ JavaScript ระบบจะตรวจจับข้อผิดพลาดและโค้ดที่ตามมาจะทำงานต่อ ดังนั้นภาพหมุนจะซ่อนอยู่เสมอ ซึ่งเป็นสิ่งที่เราต้องการ โค้ดด้านบนจะกลายเป็นเวอร์ชันแบบไม่พร้อมกันที่ไม่บล็อกของโค้ดต่อไปนี้
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'
คุณอาจต้องการcatch()เพื่อวัตถุประสงค์ในการบันทึกเท่านั้น โดยไม่ต้องกู้คืนจากข้อผิดพลาด โดยให้ส่งข้อผิดพลาดอีกครั้ง เราสามารถทำได้โดยใช้เมธอด getJSON() ดังนี้
function getJSON(url) {
return get(url).then(JSON.parse).catch(function(err) {
console.log("getJSON failed for", url, err);
throw err;
});
}
เราดึงข้อมูลมาได้ 1 บท แต่เราต้องการทั้งหมด มาทำให้เป็นจริงกันเถอะ
การทำงานพร้อมกันและการจัดลำดับ: การใช้ประโยชน์สูงสุดจากทั้ง 2 วิธี
การคิดแบบไม่พร้อมกันไม่ใช่เรื่องง่าย หากคุณประสบปัญหาในการเริ่มต้น ให้ลองเขียนโค้ดราวกับว่าโค้ดนั้นเป็นแบบซิงโครนัส ในกรณีนี้
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}
catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none'
ใช้ได้เลย แต่เป็นการซิงค์และล็อกเบราว์เซอร์ขณะดาวน์โหลดสิ่งต่างๆ เพื่อให้การทำงานนี้เป็นแบบไม่พร้อมกัน เราจะใช้ then() เพื่อให้สิ่งต่างๆ เกิดขึ้นทีละอย่าง
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
แต่เราจะวนซ้ำผ่าน URL ของบทและดึงข้อมูลตามลำดับได้อย่างไร ใช้ไม่ได้:
story.chapterUrls.forEach(function(chapterUrl) {
// Fetch chapter
getJSON(chapterUrl).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
})
forEach ไม่รองรับการทำงานแบบไม่พร้อมกัน ดังนั้นบทต่างๆ ของเราจะปรากฏตามลำดับที่ดาวน์โหลด ซึ่งโดยพื้นฐานแล้วเป็นวิธีที่ Pulp Fiction เขียนขึ้น นี่ไม่ใช่เรื่อง Pulp Fiction
มาแก้ไขกันดีกว่า
การสร้างลำดับ
เราต้องการเปลี่ยนอาร์เรย์ chapterUrls ให้เป็นลำดับของ Promise เราทำได้โดยใช้ then() ดังนี้
// Start off with a promise that always resolves
var sequence = Promise.resolve();
// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
// Add these actions to the end of the sequence
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
นี่เป็นครั้งแรกที่เราได้เห็น Promise.resolve() ซึ่งสร้าง Promise ที่จะแสดงผลเป็นค่าใดก็ตามที่คุณระบุ หากคุณส่งอินสแตนซ์ของ Promise ไป ระบบจะแสดงผลอินสแตนซ์นั้น (หมายเหตุ: การดำเนินการนี้เป็นการเปลี่ยนแปลงข้อกำหนดที่การใช้งานบางอย่างยังไม่เป็นไปตามข้อกำหนด) หากคุณส่งสิ่งที่คล้ายกับ Promise (มีเมธอด then()) ระบบจะสร้าง Promise ของแท้ที่ดำเนินการสำเร็จ/ปฏิเสธในลักษณะเดียวกัน หากคุณส่งค่าอื่นๆ เช่น Promise.resolve('Hello') ระบบจะสร้าง Promise ที่ดำเนินการตามค่านั้น หากคุณเรียกใช้โดยไม่มีค่า
ดังที่แสดงด้านบน ระบบจะดำเนินการด้วย "undefined"
นอกจากนี้ยังมี Promise.reject(val) ซึ่งสร้างสัญญาที่ปฏิเสธด้วยค่าที่คุณให้ (หรือไม่ได้กำหนด)
เราสามารถจัดระเบียบโค้ดด้านบนได้โดยใช้ array.reduce ดังนี้
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the sequence
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
ซึ่งจะทำงานเหมือนกับตัวอย่างก่อนหน้านี้ แต่ไม่จำเป็นต้องใช้ตัวแปร "sequence" แยกต่างหาก ระบบจะเรียกใช้ Callback ของ Reduce สำหรับแต่ละรายการในอาร์เรย์
"sequence" จะเป็น Promise.resolve() ในครั้งแรก แต่สำหรับการเรียกที่เหลือ "sequence" จะเป็นค่าใดก็ตามที่เราแสดงผลจากการเรียกก่อนหน้า array.reduce
มีประโยชน์มากในการลดอาร์เรย์ให้เหลือค่าเดียว ซึ่งในกรณีนี้คือ Promise
มาสรุปข้อมูลทั้งหมดกัน
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
และนี่คือเวอร์ชันอะซิงโครนัสเต็มรูปแบบของเวอร์ชันซิงโครนัส แต่เราสามารถทำได้ดีกว่านี้ ขณะนี้หน้าเว็บของเรากำลังดาวน์โหลดในลักษณะนี้
เบราว์เซอร์สามารถดาวน์โหลดหลายสิ่งพร้อมกันได้ดี ดังนั้นเราจึงสูญเสียประสิทธิภาพจากการดาวน์โหลดทีละบท สิ่งที่เราต้องการทำคือดาวน์โหลดไฟล์ทั้งหมดพร้อมกัน แล้วประมวลผลเมื่อได้รับไฟล์ทั้งหมดแล้ว โชคดีที่มี API สำหรับการดำเนินการนี้
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
Promise.all รับอาร์เรย์ของ Promise และสร้าง Promise ที่จะดำเนินการเมื่อทั้งหมดเสร็จสมบูรณ์ คุณจะได้รับอาร์เรย์ของผลลัพธ์ (ไม่ว่า Promise จะดำเนินการสำเร็จหรือไม่) ตามลำดับเดียวกับ Promise ที่คุณส่งเข้ามา
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
ซึ่งอาจเร็วกว่าการโหลดทีละรายการหลายวินาที ทั้งนี้ขึ้นอยู่กับการเชื่อมต่อ และใช้โค้ดน้อยกว่าการลองครั้งแรกของเรา ระบบจะดาวน์โหลดตอนต่างๆ ตามลำดับใดก็ได้ แต่จะปรากฏบนหน้าจอตามลำดับที่ถูกต้อง
อย่างไรก็ตาม เรายังคงปรับปรุงประสิทธิภาพที่รับรู้ได้ เมื่อบทที่ 1 มาถึง เราควรเพิ่มลงในหน้า ซึ่งจะช่วยให้ผู้ใช้เริ่มอ่านได้ก่อนที่บทที่เหลือจะมาถึง เมื่อบทที่ 3 มาถึง เราจะไม่เพิ่มบทนี้ลงในหน้าเว็บเนื่องจากผู้ใช้อาจไม่ทราบว่าบทที่ 2 หายไป เมื่อบทที่ 2 มาถึง เราก็สามารถเพิ่มบทที่ 2 และ 3 ได้ เป็นต้น
ซึ่งทำได้โดยการดึงข้อมูล JSON สำหรับบททั้งหมดพร้อมกัน จากนั้นสร้างลำดับเพื่อเพิ่มบทเหล่านั้นลงในเอกสาร ดังนี้
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
และนี่คือสิ่งที่ดีที่สุดของทั้ง 2 อย่าง การส่งเนื้อหาทั้งหมดจะใช้เวลาเท่ากัน แต่ผู้ใช้จะได้รับเนื้อหาส่วนแรกเร็วกว่า
ในตัวอย่างง่ายๆ นี้ บททั้งหมดจะมาถึงในเวลาใกล้เคียงกัน แต่ประโยชน์ของการแสดงทีละบทจะเห็นได้ชัดเจนยิ่งขึ้นเมื่อมีบทที่ใหญ่ขึ้นและจำนวนมากขึ้น
การดำเนินการข้างต้นด้วยโค้ดเรียกกลับหรือเหตุการณ์สไตล์ Node.js จะมีโค้ดเพิ่มขึ้นประมาณ 2 เท่า แต่ที่สำคัญกว่านั้นคือติดตามได้ยากกว่า อย่างไรก็ตาม นี่ไม่ใช่จุดสิ้นสุดของเรื่องราวเกี่ยวกับ Promise เมื่อรวมกับฟีเจอร์ ES6 อื่นๆ แล้ว Promise จะใช้งานได้ง่ายยิ่งขึ้น
รอบโบนัส: ขีดความสามารถที่เพิ่มขึ้น
ตั้งแต่ที่ฉันเขียนบทความนี้ครั้งแรก ความสามารถในการใช้ Promise ได้ขยายออกไปอย่างมาก ตั้งแต่ Chrome 55 เป็นต้นมา ฟังก์ชันแบบไม่พร้อมกันอนุญาตให้เขียนโค้ดที่อิงตามสัญญาได้ราวกับว่าเป็นแบบพร้อมกัน แต่ไม่บล็อกเทรดหลัก อ่านข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ในบทความเกี่ยวกับฟังก์ชัน async เบราว์เซอร์หลักๆ รองรับทั้ง Promise และฟังก์ชันแบบไม่พร้อมกันอย่างแพร่หลาย ดูรายละเอียดได้ในข้อมูลอ้างอิง Promise และ async function ของ MDN
ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans และ Yutaka Hirano ที่ช่วยพิสูจน์อักษรและแก้ไข/ให้คำแนะนำ
นอกจากนี้ ขอขอบคุณ Mathias Bynens ที่อัปเดตส่วนต่างๆ ของบทความ