JavaScript Promises: บทนำ

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

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

[เริ่มกลอง]

คำมั่นสัญญาเข้ามาใน JavaScript แล้ว!

[ดอกไม้ไฟระเบิด กระดาษระยิบระยับจากด้านบน ผู้คนเต็มไปด้วยพลุ]

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

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

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

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

  • 32
  • 12
  • 29
  • 8

แหล่งที่มา

หากต้องการให้เบราว์เซอร์ที่ไม่ได้ทำงานอย่างสมบูรณ์ได้ทำตามข้อกำหนดเฉพาะ หรือเพิ่มสัญญากับเบราว์เซอร์อื่นๆ และ Node.js ให้ลองใช้ polyfill (2k gzip)

เรื่องวุ่นวายไปทุกเรื่อง

JavaScript เป็นแบบชุดข้อความเดียว ซึ่งหมายความว่าสคริปต์ 2 บิตจะทำงานพร้อมกันไม่ได้ และต้องเรียกใช้ทีละบิต ในเบราว์เซอร์ JavaScript จะแชร์ชุดข้อความกับสิ่งอื่นๆ ที่แตกต่างกันไปในแต่ละเบราว์เซอร์ แต่โดยทั่วไปแล้ว JavaScript จะอยู่ในคิวเดียวกับการวาดภาพ การอัปเดตรูปแบบ และการจัดการการดำเนินการของผู้ใช้ (เช่น การไฮไลต์ข้อความและการโต้ตอบกับการควบคุมแบบฟอร์ม) กิจกรรมในข้อใดข้อหนึ่งเหล่านี้จะทำให้กิจกรรมอื่นๆ ล่าช้า

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

คุณอาจเคยใช้เหตุการณ์และ Callback เพื่อแก้ปัญหานี้ กิจกรรมมีดังนี้

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

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

คำมั่นสัญญาอาจเป็น:

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

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

คำมั่นสัญญามาถึงใน JavaScript!

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

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

แม้ว่าการติดตั้งใช้งานที่สัญญาไว้จะเป็นไปตามลักษณะการทำงานที่เป็นมาตรฐาน แต่ API โดยรวมจะแตกต่างกัน สัญญา JavaScript มีความคล้ายคลึงกับใน API กับ RSVP.js วิธีการสร้างคำมั่นสัญญามีดังนี้

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

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

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

วิธีที่คุณจะใช้คำมั่นสัญญามีดังนี้

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

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

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

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

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

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

อย่างไรก็ตาม ตามที่ผมได้พูดไปแล้วว่า Deferred ของ jQuery อาจดู...ไม่มีประโยชน์ โชคดีที่คุณสามารถส่งคำแนะนำที่ได้มาตรฐานซึ่งคุ้มค่าที่จะต้องทำโดยเร็วที่สุด

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

ตรงนี้ $.ajax ของ jQuery แสดงผลหน่วงเวลา เนื่องจากมีเมธอด then() อยู่แล้ว Promise.resolve() จึงเปลี่ยนเป็น JavaScript สัญญาได้ อย่างไรก็ตาม บางครั้ง Deferred จะส่งอาร์กิวเมนต์หลายอาร์กิวเมนต์ไปยัง Callback ตัวอย่างเช่น

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 เพื่อเรียกใช้การดำเนินการแบบไม่พร้อมกันในลำดับได้ด้วย

การคืนสินค้าจาก Callback ของ 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 สำหรับส่งคำขอ จากนั้นเราจะขอรายการแรกจาก URL เหล่านั้น นั่นคือสัญญาเริ่มโดดเด่นจากรูปแบบ Callback ง่ายๆ

หรือจะสร้างวิธีลัดเพื่อรับส่วนเนื้อหาก็ได้เช่นกัน ดังนี้

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

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

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

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

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

เช่นเดียวกันกับข้อผิดพลาดที่เกิดขึ้นใน 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);
})

การจัดการข้อผิดพลาดในทางปฏิบัติ

เราสามารถตรวจจับเรื่องราวและบทต่างๆ เพื่อแสดงข้อผิดพลาดให้กับผู้ใช้ได้ ดังนี้

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

หากการดึงข้อมูล story.chapterUrls[0] ไม่สำเร็จ (เช่น http 500 หรือผู้ใช้ออฟไลน์อยู่) ระบบจะข้าม Callback ที่สำเร็จต่อไปนี้ทั้งหมด ซึ่งรวมถึงการเรียกใน getJSON() ที่พยายามแยกวิเคราะห์การตอบกลับเป็น JSON และจะข้าม Callback ที่เพิ่ม chapter1.html ลงในหน้า แต่จะเลื่อนไปที่ Callback ที่รับได้ ดังนั้นระบบจึงเพิ่ม "แสดงส่วนเนื้อหาไม่สำเร็จ" ลงในหน้าหากมีการดำเนินการก่อนหน้าไม่สำเร็จ

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

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

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

นอกจากนี้ยังมี Promise.reject(val) ซึ่งสร้างสัญญาที่ปฏิเสธคุณค่าที่คุณมอบให้ (หรือไม่ได้ระบุ)

เราสามารถจัดระเบียบโค้ดด้านบนได้โดยใช้ array.reduce ดังนี้

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

ซึ่งเหมือนกับตัวอย่างก่อนหน้านี้ แต่ไม่จำเป็นต้องใช้ตัวแปร "ลำดับ" แยกต่างหาก ระบบจะเรียกใช้ Callback แบบลดทอนสำหรับแต่ละรายการในอาร์เรย์ "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 และ 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';
})

เท่านี้ก็เรียบร้อย! การส่งเนื้อหาทั้งหมดต้องใช้เวลาเท่ากัน แต่ผู้ใช้จะได้รับเนื้อหาส่วนแรกเร็วกว่านั้น

ในตัวอย่างเล็กๆ น้อยๆ นี้ ทุกบทจะมีเวลาไล่เลี่ยกัน แต่ประโยชน์ของการแสดงทีละบทก็คือมากกว่าบทที่มีขนาดใหญ่และมากกว่า

การดำเนินการข้างต้นด้วย Callback หรือเหตุการณ์แบบ Node.js คือการเพิ่มโค้ดเป็น 2 เท่า แต่ที่สำคัญกว่านั้น การติดตามนั้นไม่ง่ายนัก อย่างไรก็ตาม เรื่องราวก็ยังไม่จบเพียงเท่านี้ เพราะเมื่อนำฟีเจอร์ ES6 เหล่านี้มาใช้ร่วมกับฟีเจอร์อื่นๆ

รอบพิเศษ: ความสามารถที่เพิ่มขึ้น

ตั้งแต่แรกเริ่มที่ผมเขียนบทความนี้ ความสามารถในการใช้ Promises ก็เพิ่มขึ้นอย่างมาก ตั้งแต่ Chrome 55 เป็นต้นมา ฟังก์ชันอะซิงโครนัส (async) ช่วยให้สามารถเขียนโค้ดตามสัญญาได้เสมือนว่าเป็นแบบซิงโครนัส แต่ไม่บล็อกเทรดหลัก คุณอ่านข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ใน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 ที่อัปเดตส่วนต่างๆ ของบทความ