Promise ช่วยลดความซับซ้อนของการคำนวณแบบเลื่อนเวลาและแบบอะซิงโครนัส Promise แสดงถึงการดำเนินการที่ยังไม่เสร็จสมบูรณ์
นักพัฒนาแอป โปรดเตรียมตัวให้พร้อมสำหรับช่วงเวลาสำคัญในประวัติศาสตร์ การพัฒนาเว็บ
[เริ่มรัวกลอง]
Promise พร้อมให้ใช้งานใน JavaScript แล้ว
[ดอกไม้ไฟระเบิด กระดาษกลิตเตอร์โปรยปรายจากด้านบน ฝูงชนส่งเสียงเชียร์]
ณ จุดนี้ คุณจะอยู่ในหมวดหมู่ใดหมวดหมู่หนึ่งต่อไปนี้
- ผู้คนกำลังเชียร์รอบตัวคุณ แต่คุณไม่แน่ใจว่าเกิดอะไรขึ้น คุณอาจไม่แน่ใจด้วยซ้ำว่า "สัญญา" คืออะไร คุณอาจยักไหล่ แต่ น้ำหนักของกระดาษกลิตเตอร์ก็กดทับอยู่บนบ่าของคุณ หากคุณยังไม่เข้าใจ ก็ไม่ต้องกังวล เพราะเราเองก็ใช้เวลานานมากในการทำความเข้าใจว่าทำไมเราถึงควรสนใจเรื่องนี้ คุณอาจต้องการเริ่มต้นที่จุดเริ่มต้น
- คุณต่อยอากาศ ถึงเวลาแล้วใช่ไหม คุณเคยใช้สิ่งต่างๆ ที่เป็น Promise เหล่านี้มาก่อน แต่รู้สึกรำคาญที่การใช้งานทั้งหมดมี API ที่แตกต่างกันเล็กน้อย API สำหรับ JavaScript เวอร์ชันอย่างเป็นทางการคืออะไร คุณอาจต้องการเริ่มต้นด้วยคำศัพท์
- คุณทราบเรื่องนี้อยู่แล้วและหัวเราะเยาะผู้ที่ตื่นเต้นกับเรื่องนี้ราวกับเป็นเรื่องใหม่ ใช้เวลาสักครู่เพื่อดื่มด่ำกับความเหนือกว่าของตัวคุณเอง จากนั้นตรงไปที่การอ้างอิง API
การรองรับเบราว์เซอร์และ Polyfill
หากต้องการทำให้เบราว์เซอร์ที่ยังไม่ได้ใช้การติดตั้งใช้งาน Promise อย่างสมบูรณ์เป็นไปตามข้อกำหนด หรือเพิ่ม Promise ลงในเบราว์เซอร์อื่นๆ และ Node.js โปรดดู Polyfill (2k gzipped)
ทำไมถึงต้องวุ่นวายขนาดนี้
JavaScript เป็นแบบ Single Thread ซึ่งหมายความว่าสคริปต์ 2 รายการไม่สามารถทำงานพร้อมกันได้ แต่จะต้องทำงานทีละรายการ ในเบราว์เซอร์ JavaScript ใช้เธรดร่วมกับสิ่งอื่นๆ อีกมากมายซึ่งแตกต่างกันไปในแต่ละเบราว์เซอร์ แต่โดยปกติแล้ว JavaScript จะอยู่ในคิวเดียวกับการวาด การอัปเดต สไตล์ และการจัดการการกระทำของผู้ใช้ (เช่น การไฮไลต์ข้อความและการโต้ตอบ กับตัวควบคุมแบบฟอร์ม) กิจกรรมในรายการใดรายการหนึ่งจะทำให้รายการอื่นๆ ล่าช้า
ในฐานะมนุษย์ คุณเป็นแบบมัลติเธรด คุณพิมพ์ด้วยหลายนิ้ว ขับรถและสนทนาไปพร้อมกันได้ ฟังก์ชันการบล็อก เพียงอย่างเดียวที่เราต้องจัดการคือการจาม ซึ่งกิจกรรมทั้งหมดที่กำลังดำเนินการอยู่จะต้อง หยุดชั่วคราวในระหว่างที่จาม ซึ่งค่อนข้างน่ารำคาญ โดยเฉพาะเมื่อคุณขับรถและพยายามคุยโทรศัพท์ คุณไม่ ต้องการเขียนโค้ดที่ทำให้เกิดอาการจาม
คุณอาจเคยใช้เหตุการณ์และการเรียกกลับเพื่อหลีกเลี่ยงปัญหานี้ เหตุการณ์มีดังนี้
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 ไม่ได้ให้วิธีในการทำเช่นนั้น นอกจากนี้ นี่คือ การโหลดรูปภาพ 1 รูป ซึ่งจะยิ่งซับซ้อนมากขึ้นหากเราต้องการทราบว่ารูปภาพชุดหนึ่งๆ โหลดเสร็จเมื่อใด
กิจกรรมไม่ใช่ทางออกที่ดีที่สุดเสมอไป
เหตุการณ์เหมาะสําหรับสิ่งต่างๆ ที่อาจเกิดขึ้นหลายครั้งในออบเจ็กต์เดียวกัน เช่น 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 จะสำเร็จหรือล้มเหลวได้เพียงครั้งเดียว โดยจะสำเร็จหรือล้มเหลวซ้ำไม่ได้ และไม่สามารถเปลี่ยนจากสำเร็จเป็นล้มเหลวหรือในทางกลับกันได้
- หาก Promise สำเร็จหรือล้มเหลว และคุณเพิ่มการเรียกกลับที่สำเร็จ/ล้มเหลวในภายหลัง ระบบจะเรียกใช้การเรียกกลับที่ถูกต้อง แม้ว่าเหตุการณ์จะเกิดขึ้นก่อนหน้านี้ก็ตาม
ซึ่งมีประโยชน์อย่างยิ่งสำหรับความสำเร็จ/ความล้มเหลวแบบไม่พร้อมกัน เนื่องจากคุณอาจสนใจผลลัพธ์มากกว่าเวลาที่รายการนั้นๆ พร้อมใช้งาน
คำศัพท์เกี่ยวกับสัญญา
Domenic Denicola ตรวจทานร่างแรก ของบทความนี้และให้คะแนนฉันเป็น "F" สำหรับคำศัพท์ เขาลงโทษฉัน บังคับให้ฉันคัดลอก รัฐและชะตากรรม 100 ครั้ง และเขียนจดหมายถึงพ่อแม่ของฉันด้วยความกังวล ถึงอย่างนั้น ฉันก็ยัง สับสนกับคำศัพท์ต่างๆ อยู่มาก แต่ต่อไปนี้คือข้อมูลเบื้องต้น
สัญญาอาจเป็นได้ดังนี้
- ดำเนินการแล้ว - การดำเนินการที่เกี่ยวข้องกับสัญญาสำเร็จแล้ว
- ถูกปฏิเสธ - การดำเนินการที่เกี่ยวข้องกับสัญญาไม่สำเร็จ
- รอดำเนินการ - ยังไม่ได้ดำเนินการหรือปฏิเสธ
- ชำระแล้ว - ดำเนินการหรือปฏิเสธแล้ว
ข้อกำหนด
ยังใช้คำว่า thenable เพื่ออธิบายออบเจ็กต์ที่คล้ายกับ Promise
เนื่องจากมีเมธอด then
คำนี้ทำให้ฉันนึกถึงอดีตผู้จัดการทีมฟุตบอลอังกฤษ Terry Venables ดังนั้นฉันจะใช้คำนี้ให้น้อยที่สุด
Promises มาถึง JavaScript แล้ว
Promise มีมาสักระยะแล้วในรูปแบบของไลบรารี เช่น
Promise ของ JavaScript และ Promise ที่กล่าวถึงข้างต้นมีลักษณะการทำงานที่เหมือนกันและเป็นมาตรฐาน ที่เรียกว่า Promises/A+ หาก คุณเป็นผู้ใช้ jQuery ก็จะมีสิ่งที่คล้ายกันที่เรียกว่า Deferreds อย่างไรก็ตาม Deferred ไม่เป็นไปตามข้อกำหนดของ Promise/A+ ซึ่งทำให้มีความแตกต่างเล็กน้อยและมีประโยชน์น้อยกว่า ดังนั้นโปรดระวัง jQuery ยังมีประเภท Promise ด้วย แต่เป็นเพียง ส่วนย่อยของ Deferred และมีปัญหาเดียวกัน
แม้ว่าการใช้งาน Promise จะเป็นไปตามลักษณะการทำงานที่เป็นมาตรฐาน แต่ API โดยรวมจะแตกต่างกัน Promise ของ JavaScript มีลักษณะคล้ายกับ RSVP.js ใน API วิธีสร้างคำมั่นสัญญา
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 คือการบันทึก
การติดตามสแต็ก ซึ่งจะช่วยให้เครื่องมือแก้ไขข้อบกพร่องมีประโยชน์มากขึ้น
วิธีใช้คำมั่นสัญญาดังกล่าวมีดังนี้
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then()
รับอาร์กิวเมนต์ 2 รายการ ได้แก่ การเรียกกลับสำหรับกรณีที่สำเร็จ และอีกรายการสำหรับกรณีที่ไม่สำเร็จ
ทั้ง 2 อย่างนี้ไม่บังคับ คุณจึงเพิ่มการเรียกกลับสำหรับกรณีที่สำเร็จหรือล้มเหลวเท่านั้นได้
Promise ของ JavaScript เริ่มต้นใน DOM เป็น "Futures" จากนั้นเปลี่ยนชื่อเป็น "Promises" และสุดท้ายก็ย้ายไปอยู่ใน JavaScript การมีฟีเจอร์เหล่านี้ใน JavaScript แทนที่จะอยู่ใน DOM เป็นเรื่องดีเพราะฟีเจอร์เหล่านี้จะพร้อมใช้งานในบริบท JS ที่ไม่ใช่เบราว์เซอร์ เช่น Node.js (ไม่ว่าฟีเจอร์เหล่านี้จะใช้ใน API หลักหรือไม่ก็ตาม)
แม้ว่าจะเป็นฟีเจอร์ JavaScript แต่ DOM ก็ไม่กลัวที่จะใช้ ใน ความเป็นจริงแล้ว DOM API ใหม่ทั้งหมดที่มีเมธอด async ที่สำเร็จ/ล้มเหลวจะใช้ Promise ซึ่งเกิดขึ้นแล้วกับ การจัดการโควต้า เหตุการณ์การโหลดแบบอักษร ServiceWorker Web MIDI Streams และอื่นๆ
ความเข้ากันได้กับไลบรารีอื่นๆ
JavaScript Promises API จะถือว่าทุกอย่างที่มีเมธอด then()
เป็น
เหมือน Promise (หรือ thenable
ในภาษา Promise sigh) ดังนั้นหากคุณใช้ไลบรารี
ที่ส่งคืน Promise ของ Q ก็ไม่เป็นไร เพราะจะทำงานร่วมกับ
JavaScript Promises ใหม่ได้
แม้ว่าอย่างที่ผมได้กล่าวไป Deferred ของ jQuery จะไม่ค่อยมีประโยชน์นัก โชคดีที่คุณสามารถส่งต่อไปยัง Promise มาตรฐานได้ ซึ่งควรทำโดยเร็วที่สุด
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
ในที่นี้ $.ajax
ของ jQuery จะแสดงผล Deferred เนื่องจากมีเมธอด then()
Promise.resolve()
จึงเปลี่ยนให้เป็นพรอมิส JavaScript ได้ อย่างไรก็ตาม
บางครั้ง Deferred จะส่งอาร์กิวเมนต์หลายรายการไปยังฟังก์ชันเรียกกลับ เช่น
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
ในขณะที่ Promise ของ JS จะไม่สนใจทุกอย่างยกเว้นรายการแรก
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
โชคดีที่โดยปกติแล้วการดำเนินการนี้มักจะให้สิ่งที่คุณต้องการ หรืออย่างน้อยก็ให้สิทธิ์เข้าถึง สิ่งที่คุณต้องการ นอกจากนี้ โปรดทราบว่า jQuery ไม่ได้ทำตามรูปแบบการส่งออบเจ็กต์ข้อผิดพลาดไปยังการปฏิเสธ
โค้ดแบบอะซิงโครนัสที่ซับซ้อนทำได้ง่ายขึ้น
มาเขียนโค้ดกันเลย สมมติว่าเราต้องการทำสิ่งต่อไปนี้
- เริ่มหมุนเพื่อระบุว่ากำลังโหลด
- ดึงข้อมูล 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
ด้วยตนเอง ซึ่งเป็นเรื่องดีเพราะยิ่งฉันไม่ต้องเห็นการใช้ตัวพิมพ์เล็กและตัวพิมพ์ใหญ่สลับกันที่น่าหงุดหงิดของ 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 ซึ่งเป็น Promise ที่ดึงข้อมูล URL แล้วแยกวิเคราะห์
การตอบกลับเป็น JSON
การจัดคิวการดำเนินการแบบอะซิงโครนัส
นอกจากนี้ คุณยังเชื่อมโยง then
เพื่อเรียกใช้การดำเนินการแบบไม่พร้อมกันตามลำดับได้ด้วย
เมื่อคุณส่งคืนบางอย่างจากแฮนเดิลการเรียกกลับของ 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 แรกในชุดนั้น นี่คือเวลาที่สัญญา
เริ่มโดดเด่นอย่างแท้จริงจากรูปแบบการเรียกกลับแบบง่ายๆ
คุณยังสร้างวิธีการลัดเพื่อดูส่วนต่างๆ ได้ด้วย
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.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 รายการข้างต้นไม่ได้ทำงานเหมือนกัน โดยโค้ดตัวอย่างที่ 2 จะเทียบเท่ากับโค้ดต่อไปนี้
get('story.json').then(function(response) {
console.log("Success!", response);
}).then(undefined, function(error) {
console.log("Failed!", error);
})
ความแตกต่างนี้อาจดูเล็กน้อย แต่มีประโยชน์อย่างยิ่ง การปฏิเสธ Promise จะข้าม
ไปยัง then()
ถัดไปพร้อมการเรียกกลับการปฏิเสธ (หรือ catch()
เนื่องจาก
เทียบเท่ากัน) โดยจะเรียกใช้ then(func1, func2)
, func1
หรือ func2
อย่างใดอย่างหนึ่งเท่านั้น แต่เมื่อใช้ 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 อย่างชัดเจน แต่ก็อาจเกิดขึ้นโดยนัยด้วย หากมีการส่งข้อผิดพลาดในโค้ดเรียกกลับของตัวสร้าง
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 ทั้งหมดภายใน Promise Constructor 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 หรือผู้ใช้ออฟไลน์)
ระบบจะข้ามการเรียกกลับที่สำเร็จทั้งหมดที่ตามมา ซึ่งรวมถึงการเรียกกลับใน
getJSON()
ซึ่งพยายามแยกวิเคราะห์การตอบกลับเป็น JSON และยังข้ามการเรียกกลับที่เพิ่ม chapter1.html ลงในหน้าเว็บด้วย แต่จะไปที่แฮนเดิลข้อผิดพลาดแทน
ดังนั้น ระบบจะเพิ่มข้อความ "แสดงตอนไม่สำเร็จ" ลงในหน้าหากการดำเนินการก่อนหน้าไม่สำเร็จ
เช่นเดียวกับ 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 ที่มีค่าดังกล่าว หากเรียกใช้โดยไม่มีค่า
ดังที่กล่าวข้างต้น ระบบจะแสดงค่าเป็น "ไม่ระบุ"
นอกจากนี้ ยังมี Promise.reject(val)
ซึ่งสร้าง Promise ที่ปฏิเสธด้วย
ค่าที่คุณระบุ (หรือไม่ได้กำหนด)
เราสามารถจัดระเบียบโค้ดด้านบนได้โดยใช้
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())
ซึ่งจะทำเช่นเดียวกับตัวอย่างก่อนหน้า แต่ไม่จำเป็นต้องมีตัวแปร "ลำดับ" แยกต่างหาก ระบบจะเรียกใช้ฟังก์ชันเรียกกลับ reduce สำหรับแต่ละรายการในอาร์เรย์
"ลำดับ" คือPromise.resolve()
ครั้งแรก แต่สำหรับการเรียกที่เหลือ "ลำดับ" คือค่าใดก็ตามที่เราส่งคืนจากการเรียกครั้งก่อน 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 ทั้งหมดดำเนินการเสร็จสมบูรณ์แล้ว คุณจะได้รับอาร์เรย์ของผลลัพธ์ (ไม่ว่าจะเป็นอะไรก็ตามที่สัญญาให้ไว้) ตามลำดับเดียวกับสัญญาที่คุณส่งเข้ามา
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 ก็ขยายออกไป อย่างมาก ตั้งแต่ Chrome 55 เป็นต้นมา ฟังก์ชันแบบไม่พร้อมกันอนุญาตให้เขียนโค้ดที่อิงตาม Promise ราวกับว่าเป็นแบบพร้อมกัน แต่ไม่บล็อกเทรดหลัก คุณอ่านข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ในบทความเกี่ยวกับฟังก์ชันแบบอะซิงโครนัสของฉัน เบราว์เซอร์หลักๆ รองรับทั้ง Promise และฟังก์ชันแบบไม่พร้อมกันอย่างกว้างขวาง ดูรายละเอียดได้ในข้อมูลอ้างอิง Promise และฟังก์ชัน async ของ MDN
ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans และ Yutaka Hirano ที่ช่วยตรวจทานและให้คำแนะนำ/แก้ไข
นอกจากนี้ ขอขอบคุณ Mathias Bynens ที่ช่วยอัปเดตส่วนต่างๆ ของบทความนี้