JavaScript Promises: บทนำ

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

เจค อาร์ชิบาลด์
เจค อาร์ชิบาลด์

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

[รัวกลองเริ่มต้น]

คำสัญญามาถึงใน JavaScript แล้ว

[ดอกไม้ไฟระเบิด กระดาษประกายระยิบระยับจากเบื้องบน ฝูงชนป่าวุ่นวาย]

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

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

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

การสนับสนุนเบราว์เซอร์

  • 32
  • 12
  • 29
  • 8

แหล่งที่มา

หากต้องการทำให้เบราว์เซอร์ที่ไม่มีสัญญาการใช้งานที่สมบูรณ์เป็นไปตามข้อกำหนด หรือเพิ่มคำมั่นสัญญาลงในเบราว์เซอร์อื่นๆ และ Node.js โปรดดู polyfill (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
});

นี่ไม่เลวเลย เราได้รับอิมเมจ ให้เพิ่ม Listener 2 รายการ จากนั้น JavaScript จะหยุดดำเนินการจนกว่าจะมีการเรียกใช้ Listener ดังกล่าว

ในตัวอย่างด้านบน เป็นไปได้ว่าเหตุการณ์ดังกล่าวเกิดขึ้นก่อนที่เราจะเริ่มฟัง เราจึงต้องแก้ปัญหานี้โดยใช้คุณสมบัติ "เสร็จสมบูรณ์" ของรูปภาพ

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
});

นี่คือสิ่งที่สัญญาไว้ แต่มีการตั้งชื่อที่ดีขึ้น หากองค์ประกอบรูปภาพ HTML มีเมธอด "พร้อมใช้งาน" ซึ่งแสดงผลคำสัญญา เราสามารถทำได้

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

โดยพื้นฐานแล้ว คำสัญญาจะคล้ายกับ Listener เหตุการณ์ ยกเว้นสิ่งต่อไปนี้

  • คำสัญญาจะประสบความสำเร็จหรือล้มเหลวเพียงครั้งเดียว ความสำเร็จไม่สามารถเกิดขึ้นได้หรือล้มเหลวถึง 2 ครั้ง และไม่สามารถเปลี่ยนจากความสำเร็จเป็นความล้มเหลวหรือในทางกลับกันได้
  • หากคำมั่นสัญญาสำเร็จหรือไม่สำเร็จ และคุณเพิ่มการเรียกกลับสำเร็จ/ไม่สำเร็จในภายหลัง ระบบจะเรียกใช้โค้ดเรียกกลับที่ถูกต้อง แม้ว่าเหตุการณ์จะเกิดขึ้นก่อนหน้านี้ก็ตาม

วิธีนี้เป็นประโยชน์อย่างยิ่งต่อความสำเร็จ/ความล้มเหลวแบบไม่พร้อมกันเนื่องจากไม่ค่อยสนใจเวลาที่แน่นอนที่เนื้อหาบางอย่างพร้อมใช้งานและสนใจตอบสนองต่อผลลัพธ์มากกว่า

คำศัพท์เกี่ยวกับคำสัญญา

หลักฐานของ Domenic Denicola ได้อ่านฉบับร่างแรกของบทความนี้ และให้คะแนนฉันเป็น "F" สำหรับคำศัพท์ เขาให้ขังฉัน บังคับให้ฉันคัดลอก รัฐและชะตากรรม 100 ครั้ง และเขียนจดหมายเป็นห่วงใยถึงผู้ปกครอง ถึงอย่างนั้น เรายังมีคำศัพท์ที่ผสมปนเปกันไปมากมาย แต่ต่อไปนี้เป็นพื้นฐานของเรา

คำสัญญาอาจมีสิ่งต่อไปนี้

  • fulfill - การดำเนินการที่เกี่ยวข้องกับคำสัญญาประสบความสำเร็จ
  • ปฏิเสธ - การดำเนินการที่เกี่ยวข้องกับคำสัญญาล้มเหลว
  • pending - ยังไม่ได้ดำเนินการหรือถูกปฏิเสธ
  • ตกลงแล้ว - ดำเนินการแล้วหรือถูกปฏิเสธ

ข้อกำหนดยังใช้คำว่า thenable เพื่ออธิบายออบเจ็กต์ที่มีลักษณะเหมือนคำสัญญา เพราะมีเมธอด then คำนี้ทำให้ผมนึกถึงอดีตผู้จัดการทีมฟุตบอลอังกฤษ Terry Venables ผมจะใช้คำนี้น้อยที่สุดเท่าที่จะเป็นไปได้

ได้รับคำสัญญาใน JavaScript

คำสัญญานั้นมีมาระยะหนึ่งแล้วในรูปแบบของห้องสมุด เช่น

สัญญาข้างต้นและ JavaScript นั้นมีลักษณะการทำงานที่เป็นมาตรฐานซึ่งพบได้บ่อย ซึ่งเรียกว่า Promises/A+ หากคุณเป็นผู้ใช้ jQuery ก็จะมีลักษณะการทำงานที่คล้ายกันที่เรียกว่า Deferred อย่างไรก็ตาม การเลื่อนเวลาไม่เป็นไปตามข้อกำหนดของ Promise/A+ ซึ่งทำให้แตกต่างกันอย่างสิ้นเชิงและมีประโยชน์น้อยกว่า โปรดระวัง jQuery ยังมีประเภท Promise แต่นี่เป็นเพียงส่วนย่อยของ Deferred และมีปัญหาเดียวกัน

แม้ว่าการติดตั้งใช้งานสัญญาจะเป็นไปตามลักษณะการทำงานที่เป็นมาตรฐาน แต่ 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"));
  }
});

ตัวสร้างสัญญาใช้อาร์กิวเมนต์ 1 รายการ ซึ่งเป็นโค้ดเรียกกลับที่มีพารามิเตอร์ 2 รายการ ซึ่งจะแก้ปัญหาและปฏิเสธ ดำเนินการบางอย่างภายในโค้ดเรียกกลับ โดยอาจเป็นแบบอะซิงโครนัส แล้วแก้ปัญหาการโทรสำเร็จในกรณีที่ทุกอย่างได้ผล มิเช่นนั้นระบบจะปฏิเสธสาย

เช่นเดียวกับ throw ใน JavaScript เวอร์ชันเดิม จะมีธรรมเนียมปฏิบัติในการปฏิเสธด้วยออบเจ็กต์ข้อผิดพลาด แต่ไม่บังคับ ประโยชน์ของออบเจ็กต์ข้อผิดพลาดคือบันทึกสแต็กเทรซ เครื่องมือแก้ไขข้อบกพร่องจึงมีประโยชน์มากขึ้น

วิธีใช้คำสัญญาดังกล่าวมีดังนี้

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

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

สัญญาด้วย JavaScript เริ่มจากใน DOM เป็น "สัญญาซื้อขายล่วงหน้า" เปลี่ยนชื่อเป็น "สัญญา" และสุดท้ายก็ย้ายไปอยู่ใน JavaScript การใส่แท็กเหล่านี้ไว้ใน JavaScript แทนที่จะเป็น DOM ก็เป็นเรื่องดี เพราะจะใช้งานได้ในบริบท JS ที่ไม่ใช่เบราว์เซอร์ เช่น Node.js (แต่คำถามอีกข้อคือ คำถามอีกข้อหนึ่ง)

แม้ว่าจะเป็นฟีเจอร์ JavaScript แต่ DOM ก็ไม่กลัวที่จะใช้ ในความเป็นจริง DOM API ใหม่ทั้งหมดที่มีเมธอดความสำเร็จ/ล้มเหลวไม่พร้อมกันจะใช้คำสัญญา ซึ่งเกิดขึ้นกับการจัดการโควต้า เหตุการณ์การโหลดแบบอักษร ServiceWorker Web MIDI สตรีม และอื่นๆ

ความเข้ากันได้กับไลบรารีอื่นๆ

JavaScript สัญญา API จะดำเนินการทุกอย่างด้วยเมธอด then() แบบสัญญาว่า (หรือ thenable ในการพูดจา ถอนหายใจ) ดังนั้นหากคุณใช้ไลบรารีที่ส่งคืนคำสัญญา Q ก็ไม่เป็นไร นั่นคือ JavaScript จะให้บริการอย่างมีประสิทธิภาพ

อย่างที่บอกไปแล้วว่าการเลื่อนเวลาของ 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 ไม่เป็นไปตามรูปแบบการส่งออบเจ็กต์ข้อผิดพลาดไปยังการปฏิเสธ

โค้ดแบบไม่พร้อมกันที่ซับซ้อนใช้งานได้ง่ายขึ้น

เอาล่ะ มาเขียนโค้ดกันเถอะ สมมติว่าเราต้องการ

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

... แต่อย่าลืมแจ้งให้ผู้ใช้ทราบหากมีบางอย่างผิดปกติระหว่างทาง เราจะต้องหยุดตัวหมุนไว้ที่จุดนั้นด้วย มิเช่นนั้น ตัวหมุนจะยังหมุน เวียนศีรษะ และเกิดข้อขัดข้องกับ UI อื่นๆ

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

เรามาเริ่มดึงข้อมูลจากเครือข่ายกัน

การให้คำมั่นเกี่ยวกับ XMLHttpRequest

ระบบจะอัปเดต API เก่าให้ใช้สัญญา หากทำได้ในแบบที่เข้ากันได้แบบย้อนหลัง 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() หากคุณแสดงผลค่า ระบบจะเรียกใช้ then() ถัดไปด้วยค่านั้น อย่างไรก็ตาม หากคุณส่งคืนผลลัพธ์ที่คล้ายกับสิ่งที่สัญญาไว้ then() รายการถัดไปจะรอการดำเนินการดังกล่าว และจะมีการเรียกใช้ก็ต่อเมื่อสัญญานั้นจบลง (สำเร็จ/ล้มเหลว) เช่น

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

ซึ่งเราจะส่งคำขอแบบไม่พร้อมกันไปยัง story.json ซึ่งทำให้มีชุด 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 เพียงครั้งเดียว เย่ สัญญาเลย!

การจัดการข้อผิดพลาด

อย่างที่เห็นก่อนหน้านี้ then() ใช้อาร์กิวเมนต์ 2 ตัว ส่วนแรกใช้สำหรับความสำเร็จ ส่วนที่สองคือล้มเหลว (หรือทำตามคำสั่งและปฏิเสธ ในการพูดตามสัญญา) ดังนี้

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);
})

ข้อแตกต่างนั้นเล็กน้อย แต่มีประโยชน์อย่างยิ่ง คำมั่นสัญญาที่ปฏิเสธจะข้ามไปยัง then() ถัดไปพร้อมการเรียกกลับการปฏิเสธ (หรือ 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!");
})

ขั้นตอนด้านบนคล้ายกับการลอง/จับ JavaScript ตามปกติมาก โดยข้อผิดพลาดที่เกิดขึ้นภายใน "ลอง" จะไปที่บล็อก catch() ทันที นี่คือโฟลว์ชาร์ตที่ด้านบน (เพราะผมชอบโฟลว์ชาร์ต)

ทำตามเส้นสีน้ำเงินสำหรับคำสัญญาที่เติมเต็ม หรือสีแดงสำหรับคำที่ปฏิเสธ

สัญญาและข้อยกเว้น JavaScript

การปฏิเสธเกิดขึ้นเมื่อคำสัญญาถูกปฏิเสธอย่างชัดเจน แต่ก็เป็นไปโดยนัยหากเกิดข้อผิดพลาดในโค้ดเรียกกลับของตัวสร้าง

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);
})

ซึ่งหมายความว่าการเรียกใช้งานทั้งหมดที่เกี่ยวข้องกับคำสัญญาภายในการเรียกกลับตัวสร้างสัญญาจะเป็นประโยชน์ เพื่อให้ตรวจพบข้อผิดพลาดโดยอัตโนมัติและกลายเป็นการปฏิเสธ

เช่นเดียวกับข้อผิดพลาดในโค้ดเรียกกลับ 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 ในหน้าเว็บด้วย แต่จะเปลี่ยนไปอยู่ที่ การเรียกกลับที่จับต้องได้ ดังนั้น ระบบจะเพิ่ม "แสดงส่วนเนื้อหาไม่สำเร็จ" ลงในหน้าหากการดำเนินการก่อนหน้านี้ไม่สำเร็จ

เหมือนกับการลอง/จับผิดของ 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 ของบทและดึง URL ตามลำดับได้อย่างไร การดำเนินการนี้ ไม่ได้ผล

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach ไม่ได้รับรู้ถึงตัวตนของผู้ใช้แบบพร้อมกัน ดังนั้นบทของเราจึงจะปรากฏตามลำดับการดาวน์โหลด ซึ่งก็คือวิธีการเขียน Pulp Stories นั่นเอง นี่ไม่ใช่เรื่อง Pulp Science นะ มาแก้ไขกัน

การสร้างลำดับ

เราต้องการเปลี่ยนอาร์เรย์ chapterUrls เป็นลำดับของคำสัญญา ซึ่งทำได้โดยใช้ 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 ก็จะเพียงแค่ส่งคืนอินสแตนซ์ดังกล่าว (หมายเหตุ: นี่เป็นการเปลี่ยนแปลงข้อกำหนดที่การติดตั้งใช้งานบางรายการยังไม่ได้ปฏิบัติตาม) หากคุณส่งผ่านข้อความที่มีลักษณะคล้ายๆ กับสัญญา (มีเมธอด then()) ระบบจะสร้าง Promise ของแท้ที่ตอบสนอง/ปฏิเสธในลักษณะเดียวกัน หากคุณส่งผ่านค่าอื่นๆ เช่น Promise.resolve('Hello') ถือเป็นการสัญญา ที่เติมเต็มด้วยคุณค่านั้น หากเรียกโดยไม่มีค่า ดังที่กล่าวไว้ด้านบน ระบบจะเติมผลลัพธ์ด้วย "ไม่ระบุ"

นอกจากนี้ยังมี 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" แยกต่างหาก เราจะเรียกใช้การลดโค้ดเรียกกลับสำหรับแต่ละรายการในอาร์เรย์ "sequence" คือ Promise.resolve() ในครั้งแรก แต่สำหรับการเรียกที่เหลือ "sequence" คือคำที่เรากลับมาจากการเรียกครั้งก่อน array.reduce ซึ่งมีประโยชน์มากในการแยกอาร์เรย์ให้เหลือค่าเดียว ซึ่งในกรณีนี้คือสิ่งที่สัญญาไว้

เรามารวมทั้งหมดนี้กัน

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

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 มาถึงแล้ว เราสามารถเพิ่มบทที่ 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 เท่า แต่ที่สำคัญกว่านั้นทำตามได้ยาก อย่างไรก็ตาม คำสัญญาไม่ได้จบลงเพียงเท่านี้ เพราะเมื่อนำฟีเจอร์ ES6 มาใช้ร่วมกับฟีเจอร์อื่นๆ ของ ES6 จะยิ่งง่ายขึ้นอีก

รอบพิเศษ: เพิ่มความสามารถ

ตั้งแต่ที่ผมเขียนบทความนี้ขึ้นเอง ความสามารถในการใช้ Promises ก็ขยายตัวขึ้นอย่างมาก ตั้งแต่ Chrome 55 เป็นต้นมา ฟังก์ชันแบบไม่พร้อมกันช่วยให้เขียนโค้ดตามคำสัญญาได้เสมือนว่าเป็นแบบซิงโครนัส แต่ไม่บล็อกเทรดหลัก อ่านข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ในmy async functions article รองรับทั้งฟังก์ชัน Promises และ Async อย่างกว้างขวางในเบราว์เซอร์หลักๆ คุณดูรายละเอียดได้ในข้อมูลอ้างอิงของ Promise และฟังก์ชันอะซิงโครนัสของ MDN

ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans และ Yutaka Hirano ที่พิสูจน์อักษรในเรื่องนี้และได้ทำการแก้ไข/ให้คำแนะนำ

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