Promise ลดความซับซ้อนของการคำนวณแบบเลื่อนเวลาและแบบไม่พร้อมกัน พรอมต์แสดงการดำเนินการที่ยังไม่เสร็จสมบูรณ์
นักพัฒนาซอฟต์แวร์ เตรียมตัวให้พร้อมสําหรับช่วงเวลาสําคัญในประวัติศาสตร์การพัฒนาเว็บ
[Drumroll begins]
Promise พร้อมใช้งานใน JavaScript แล้ว
[ดอกไม้ไฟระเบิด กระดาษสีรุ้งโปรยปรายลงมา ผู้คนตื่นเต้น]
ณ จุดนี้ คุณจะอยู่ภายใต้หมวดหมู่ใดหมวดหมู่หนึ่งต่อไปนี้
- ผู้คนรอบตัวคุณกำลังเชียร์ แต่คุณไม่แน่ใจว่าเกิดอะไรขึ้น คุณอาจไม่แน่ใจว่า "สัญญา" คืออะไร คุณอาจไม่สนใจ แต่ความกดดันจากกระดาษแวววาวก็หนักอึ้งอยู่บนบ่า หากเป็นเช่นนั้น ไม่ต้องกังวลไป เราใช้เวลานานมากกว่าจะรู้ว่าทำไมต้องใส่ใจกับเรื่องนี้ คุณอาจต้องเริ่มที่จุดเริ่มต้น
- คุณต่อยอากาศ ใกล้ถึงเวลาแล้วใช่ไหม คุณเคยใช้ Promise เหล่านี้มาก่อนแล้ว แต่คุณรู้สึกรำคาญที่การติดตั้งใช้งานทั้งหมดมี API ที่ต่างกันเล็กน้อย API สำหรับ JavaScript เวอร์ชันอย่างเป็นทางการคืออะไร คุณอาจต้องเริ่มด้วยคำศัพท์
- คุณทราบเรื่องนี้อยู่แล้วและดูถูกผู้ที่ตื่นเต้นราวกับว่าเพิ่งทราบข่าวนี้ ใช้เวลาสักครู่เพื่อชื่นชมความเหนือชั้นของคุณ แล้วตรงไปที่การอ้างอิง API
การรองรับเบราว์เซอร์และ polyfill
หากต้องการทำให้เบราว์เซอร์ที่ไม่มีการใช้งาน Promise อย่างสมบูรณ์เป็นไปตามข้อกำหนด หรือเพิ่ม Promise ลงในเบราว์เซอร์อื่นๆ และ Node.js โปรดดูโพลีไฟล์ (2k ที่บีบอัดด้วย Gzip)
เกิดอะไรขึ้น
JavaScript เป็นเทรดเธรดเดียว ซึ่งหมายความว่าสคริปต์ 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
เป็นต้น เหตุการณ์เหล่านี้ไม่เกี่ยวข้องกับสิ่งที่เกิดขึ้นก่อนที่คุณแนบโปรแกรมฟัง แต่สำหรับการดำเนินการแบบไม่พร้อมกันที่สำเร็จ/ล้มเหลว คุณควรใช้รูปแบบประมาณนี้
img1.callThisIfLoadedOrWhenLoaded(function() {
// loaded
}).orIfFailedCallThis(function() {
// failed
});
// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
// all loaded
}).orIfSomeFailedCallThis(function() {
// one or more failed
});
ซึ่งก็คือสิ่งที่ Promise ทำ แต่ใช้ชื่อที่ดีกว่า หากองค์ประกอบรูปภาพ HTML มีเมธอด "ready" ที่แสดงผลพรอมต์ เราอาจทำดังนี้
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
Domenic Denicola เป็นผู้พิสูจน์อักษรฉบับร่างแรกของบทความนี้และให้คะแนนฉัน "F" สำหรับการใช้คำศัพท์ เขากักบริเวณฉัน บังคับให้ฉันคัดลอกรัฐและชะตากรรม 100 ครั้ง และเขียนจดหมายแสดงความกังวลถึงพ่อแม่ อย่างไรก็ตาม ฉันยังคงสับสนกับคำศัพท์หลายคำ แต่ข้อมูลเบื้องต้นมีดังนี้
คําสัญญาอาจเป็นสิ่งต่อไปนี้
- fulfilled - การดําเนินการที่เกี่ยวข้องกับคํามั่นสัญญาสําเร็จ
- ปฏิเสธ - การดำเนินการที่เกี่ยวข้องกับความตั้งใจไม่สำเร็จ
- รอดำเนินการ - ยังไม่ได้ดำเนินการหรือปฏิเสธ
- settled - ดำเนินการเสร็จสมบูรณ์หรือถูกปฏิเสธ
ข้อกำหนดยังใช้คำว่า thenable เพื่ออธิบายออบเจ็กต์ที่คล้ายกับสัญญาด้วย นั่นคือออบเจ็กต์ที่มีเมธอด then
คํานี้ทำให้ฉันนึกถึงอดีตผู้จัดการทีมฟุตบอลทีมชาติอังกฤษอย่าง Terry Venables ดังนั้นเราจะใช้คํานี้ให้น้อยที่สุด
Promises มาถึง JavaScript แล้ว
พรอมต์มีมานานแล้วในรูปแบบไลบรารี เช่น
Promise ด้านบนและ Promise ของ JavaScript มีการทำงานร่วมกันแบบมาตรฐานที่เรียกว่า Promises/A+ หากคุณเป็นผู้ใช้ jQuery ก็มีสิ่งที่คล้ายกันที่เรียกว่า Deferred อย่างไรก็ตาม Deferred ไม่เป็นไปตามข้อกำหนดของ Promise/A+ ซึ่งทำให้แตกต่างเล็กน้อยและมีประโยชน์น้อยกว่า ดังนั้นโปรดระวัง jQuery ยังมีประเภท Promise ด้วย แต่เป็นเพียงชุดย่อยของ Deferred และมีปัญหาเดียวกัน
แม้ว่าการใช้งาน Promise จะเป็นไปตามลักษณะการทำงานมาตรฐาน แต่ API โดยรวมจะแตกต่างกัน พรอมต์ 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
เช่นเดียวกับ throw
ใน JavaScript ธรรมดา ธรรมเนียมปฏิบัติ (แต่ไม่ใช่ข้อบังคับ) คือการปฏิเสธด้วยออบเจ็กต์ Error ข้อดีของออบเจ็กต์ข้อผิดพลาดคือจะบันทึกสแต็กเทรซ ซึ่งทำให้เครื่องมือแก้ไขข้อบกพร่องมีประโยชน์มากขึ้น
วิธีใช้คํามั่นสัญญามีดังนี้
promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});
then()
ใช้อาร์กิวเมนต์ 2 รายการ ได้แก่ ฟังก์ชันการเรียกกลับสำหรับกรณีที่สำเร็จ และอีกรายการสำหรับกรณีที่ไม่สำเร็จ ทั้งสองรายการเป็นตัวเลือก คุณจึงเพิ่มการเรียกกลับสำหรับกรณีที่สำเร็จหรือล้มเหลวเท่านั้นได้
พรอมต์ JavaScript เริ่มต้นใน DOM เป็น "Futures" เปลี่ยนชื่อเป็น "Promises" และย้ายไปอยู่ใน JavaScript ในที่สุด การมีองค์ประกอบเหล่านี้ใน JavaScript แทน DOM นั้นยอดเยี่ยมมาก เนื่องจากจะพร้อมใช้งานในบริบท JS ที่ไม่ใช่เบราว์เซอร์ เช่น Node.js (การใช้องค์ประกอบเหล่านี้ใน API หลักหรือไม่นั้นเป็นอีกเรื่องหนึ่ง)
แม้ว่าจะเป็นฟีเจอร์ JavaScript แต่ DOM ก็ใช้ฟีเจอร์เหล่านี้ได้ อันที่จริงแล้ว DOM API ใหม่ทั้งหมดที่มีเมธอดสำเร็จ/ไม่สำเร็จแบบแอสซิงค์จะใช้ Promise ซึ่งเรากำลังดำเนินการกับการจัดการโควต้า, เหตุการณ์การโหลดแบบอักษร, ServiceWorker, Web MIDI, สตรีม และอื่นๆ
ความเข้ากันได้กับไลบรารีอื่นๆ
Promises API ของ JavaScript จะถือว่าทุกอย่างที่มีเมธอด then()
เป็นรูปแบบ Promise (หรือ thenable
ในภาษา Promise sigh) ดังนั้นหากคุณใช้ไลบรารีที่แสดงผล Promise ของ Q ก็ไม่เป็นไร ไลบรารีจะทำงานร่วมกับ Promise ของ JavaScript เวอร์ชันใหม่ได้
แต่อย่างที่บอกไป Deferred ของ jQuery ไม่ค่อยมีประโยชน์สักเท่าไหร่ แต่คุณแคสต์คำมั่นสัญญาเหล่านั้นเป็นคำมั่นสัญญามาตรฐานได้ ซึ่งควรทำโดยเร็วที่สุด
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
ในตัวอย่างนี้ $.ajax
ของ jQuery จะแสดงผล Deferred เนื่องจากมีเมธอด then()
Promise.resolve()
จึงเปลี่ยนเป็นสัญญา JavaScript ได้ อย่างไรก็ตาม บางครั้งการเลื่อนเวลาอาจส่งอาร์กิวเมนต์หลายรายการไปยังการเรียกกลับ เช่น
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
ขณะที่สัญญาของ JS จะละเว้นทั้งหมดยกเว้นรายการแรก
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
ด้วยตนเอง ซึ่งยอดเยี่ยมมาก เพราะยิ่งไม่ต้องเห็น 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
แต่เรายังแก้ปัญหานี้ในพรอมต์ได้อีกด้วย
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()
จะยังคงแสดงผลพรอมต์ ซึ่งดึงข้อมูล URL แล้วแยกวิเคราะห์การตอบกลับเป็น JSON
การจัดคิวการดำเนินการแบบอะซิงโครนัส
นอกจากนี้ คุณยังใช้ then
ต่อกันเพื่อเรียกใช้การดำเนินการแบบแอซิงค์ตามลำดับได้ด้วย
เมื่อคุณแสดงผลลัพธ์จาก then()
callback สิ่งต่างๆ จะดูน่าอัศจรรย์
หากคุณแสดงผลค่า ระบบจะเรียกใช้ 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 เริ่มโดดเด่นกว่ารูปแบบการเรียกกลับแบบง่าย
คุณยังสร้างวิธีการลัดเพื่อดูส่วนเนื้อหาได้ด้วย โดยทำดังนี้
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
เราจะใช้ Promise ของเรื่องราวซ้ำ ดังนั้น story.json
จะได้รับการดึงข้อมูลเพียงครั้งเดียว เย้ แหวนแทนคำสัญญา
การจัดการข้อผิดพลาด
ดังที่เราเห็นก่อนหน้านี้ 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()
รายการถัดไปที่มีคอลแบ็กการปฏิเสธ (หรือ 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 เพื่อให้ระบบตรวจพบข้อผิดพลาดและปฏิเสธโดยอัตโนมัติ
เช่นเดียวกับข้อผิดพลาดที่แสดงใน then()
callbacks
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 ลงในหน้าเว็บด้วย แต่จะไปที่การเรียกคืนข้อมูลของ 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;
});
}
เราดึงข้อมูลบทหนึ่งมาได้ แต่เราต้องการทุกบท มาทำให้สิ่งนั้นเกิดขึ้นกัน
การทำงานแบบขนานและการจัดลําดับ: ใช้ประโยชน์จากทั้ง 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 (มีเมธอด then()
) ระบบจะสร้าง Promise
จริงที่ดำเนินการตาม/ปฏิเสธในลักษณะเดียวกัน หากคุณส่งค่าอื่น เช่น Promise.resolve('Hello')
ก็จะสร้างคำมั่นสัญญาที่สอดคล้องกับค่านั้น หากคุณเรียกใช้โดยไม่มีค่า ดังที่แสดงด้านบน ระบบจะแสดงผลเป็น "undefined"
นอกจากนี้ยังมี 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())
การดำเนินการนี้ทํางานเหมือนกับตัวอย่างก่อนหน้านี้ แต่ไม่ต้องแยกตัวแปร "sequence" ระบบจะเรียกใช้การเรียกกลับ 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 ทั้งหมดดำเนินการเสร็จสมบูรณ์ คุณจะได้รับอาร์เรย์ของผลลัพธ์ (ไม่ว่าจะมีการตอบสนองตามสัญญาหรือไม่) ตามลําดับเดียวกับสัญญาที่คุณส่งเข้ามา
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';
})
วิธีนี้อาจเร็วกว่าการโหลดทีละรายการ 2-3 วินาที ทั้งนี้ขึ้นอยู่กับการเชื่อมต่อ และโค้ดน้อยกว่าวิธีแรก คุณสามารถดาวน์โหลดส่วนเนื้อหาในลำดับใดก็ได้ แต่ส่วนเนื้อหาจะปรากฏบนหน้าจอตามลำดับที่ถูกต้อง
อย่างไรก็ตาม เรายังคงปรับปรุงประสิทธิภาพที่รับรู้ได้ เมื่อบทที่ 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';
})
เท่านี้เราก็ได้ภาพที่ดีที่สุดแล้ว การส่งเนื้อหาทั้งหมดจะใช้เวลาเท่าเดิม แต่ผู้ใช้จะได้รับเนื้อหาส่วนแรกเร็วขึ้น
ในตัวอย่างนี้ บททั้งหมดจะปรากฏขึ้นในช่วงเวลาใกล้เคียงกัน แต่ประโยชน์ของการแสดงทีละบทจะชัดเจนขึ้นเมื่อมีบทจำนวนมากขึ้น
การทำตามขั้นตอนข้างต้นด้วยการเรียกกลับหรือเหตุการณ์สไตล์ Node.js จะใช้โค้ดประมาณ 2 เท่า แต่ที่สำคัญกว่านั้นคือไม่ง่ายต่อการติดตาม อย่างไรก็ตาม การใช้ Promise นั้นไม่ได้จบแค่นี้ เมื่อใช้ร่วมกับฟีเจอร์อื่นๆ ของ ES6 คุณจะใช้งาน Promise ได้ง่ายขึ้น
รอบพิเศษ: ความสามารถที่ขยายการให้บริการ
นับตั้งแต่ที่เราเขียนบทความนี้ขึ้น ความสามารถในการใช้ Promises ได้ขยายตัวไปอย่างมาก ตั้งแต่ Chrome 55 ฟังก์ชันแบบแอซิงโครนัสอนุญาตให้เขียนโค้ดที่อิงตามสัญญาว่าจะดำเนินการราวกับว่าเป็นแบบซิงโครนัส แต่ไม่บล็อกเธรดหลัก อ่านข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ในบทความเกี่ยวกับฟังก์ชันแบบแอสซิงค์ เบราว์เซอร์หลักๆ รองรับทั้ง Promise และฟังก์ชัน Async อย่างแพร่หลาย ดูรายละเอียดได้ในข้อมูลอ้างอิงเกี่ยวกับPromise และasync function ของ MDN
ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans และ Yutaka Hirano ที่อ่านตรวจแก้และทำการแก้ไข/แนะนำ
และขอขอบคุณ Mathias Bynens ที่อัปเดตส่วนต่างๆของบทความ