JavaScript Promises: บทนำ

พรอมต์ช่วยให้การคํานวณแบบเลื่อนเวลาและแบบไม่พร้อมกันง่ายขึ้น พรอมต์แสดงการดำเนินการที่ยังไม่เสร็จสมบูรณ์

นักพัฒนาซอฟต์แวร์ เตรียมตัวให้พร้อมสําหรับช่วงเวลาสําคัญในประวัติศาสตร์การพัฒนาเว็บ

[Drumroll begins]

Promise พร้อมใช้งานใน JavaScript แล้ว

[ดอกไม้ไฟระเบิด กระดาษสีรุ้งโปรยปรายลงมา ผู้คนตื่นเต้น]

ณ จุดนี้ คุณจะอยู่ภายใต้หมวดหมู่ใดหมวดหมู่หนึ่งต่อไปนี้

  • ผู้คนรอบตัวคุณกำลังเชียร์ แต่คุณไม่แน่ใจว่าเกิดอะไรขึ้น บางทีคุณอาจไม่แน่ใจว่า "สัญญา" คืออะไร คุณอาจไม่สนใจ แต่ความกดดันจากกระดาษแวววาวก็หนักอึ้งอยู่บนบ่า หากเป็นเช่นนั้น ไม่ต้องกังวลไป เราใช้เวลานานมากกว่าจะรู้ว่าทำไมต้องใส่ใจกับเรื่องนี้ คุณอาจต้องเริ่มที่จุดเริ่มต้น
  • คุณต่อยอากาศ ใกล้ถึงเวลาแล้วใช่ไหม คุณเคยใช้ Promise เหล่านี้มาก่อนแล้ว แต่คุณรู้สึกรำคาญที่การติดตั้งใช้งานทั้งหมดมี API ที่ต่างกันเล็กน้อย API สำหรับ JavaScript เวอร์ชันอย่างเป็นทางการคืออะไร คุณอาจต้องเริ่มด้วยคำศัพท์
  • คุณทราบเรื่องนี้อยู่แล้วและดูถูกผู้ที่ตื่นเต้นราวกับว่าเพิ่งทราบข่าวนี้ ใช้เวลาสักครู่เพื่อชื่นชมความเหนือชั้นของตัวเอง แล้วตรงไปที่การอ้างอิง API

การรองรับเบราว์เซอร์และ polyfill

การรองรับเบราว์เซอร์

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

แหล่งที่มา

หากต้องการทำให้เบราว์เซอร์ที่ไม่มีการใช้งาน 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 เพื่ออธิบายออบเจ็กต์ที่คล้ายกับ Promise ตรงที่มีเมธอด 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 ไปยังการปฏิเสธ

โค้ดแบบแอซิงค์ที่ซับซ้อนทำได้ง่ายขึ้น

มาเขียนโค้ดกัน สมมติว่าเราต้องการดำเนินการต่อไปนี้

  1. เริ่มหมุนเพื่อบ่งบอกว่ากำลังโหลด
  2. ดึงข้อมูล JSON บางส่วนสำหรับเรื่องราว ซึ่งจะให้ชื่อและ URL ของแต่ละบท
  3. เพิ่มชื่อลงในหน้า
  4. ดึงข้อมูลแต่ละบท
  5. เพิ่มเรื่องราวลงในหน้า
  6. หยุดตัวหมุน

… แต่ก็ต้องแจ้งให้ผู้ใช้ทราบด้วยหากเกิดข้อผิดพลาดระหว่างดำเนินการ เราจะต้องหยุดตัวหมุน ณ จุดนั้นด้วย ไม่เช่นนั้นตัวหมุนจะหมุนต่อไปเรื่อยๆ จนทำให้ผู้ใช้สับสนและ 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() จะยังคงแสดงผลเป็น Promise ซึ่งดึงข้อมูล 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 เราจะใช้การสัญญาของเรื่องราวซ้ำ ดังนั้น 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()รายการถัดไปที่มี Callback การปฏิเสธ (หรือ 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;
 
});
}

เราดึงข้อมูลบทที่ 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 (มีเมธอด 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 ของ MDN function

ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans และ Yutaka Hirano ที่อ่านตรวจแก้และทำการแก้ไข/แนะนำ

และขอขอบคุณ Mathias Bynens ที่ช่วยอัปเดตส่วนต่างๆของบทความ