JavaScript Promises: บทนำ

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

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

[เริ่มกลอง]

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

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

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

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

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

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

  • Chrome: 32.
  • ขอบ: 12.
  • Firefox: 29.
  • Safari: 8.

แหล่งที่มา

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

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

JavaScript เป็นเธรดเดี่ยว ซึ่งหมายความว่าสคริปต์สองบิตไม่สามารถทำงานได้ ในเวลาเดียวกัน รายงานเหล่านั้นจะสลับกันไป ในเบราว์เซอร์ 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
});

ไม่จามเลย เราได้รับภาพ เพิ่มผู้ฟัง 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
});

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

สัญญา JavaScript เริ่มต้นใน DOM เป็น "ฟิวเจอร์ส" เปลี่ยนชื่อเป็น "สัญญา" และสุดท้ายก็เปลี่ยนเป็น JavaScript การมีโค้ดใน JavaScript แทนที่จะเป็น DOM นั้นยอดเยี่ยมมากเนื่องจากจะสามารถใช้งานในบริบท JS ที่ไม่ใช่เบราว์เซอร์ เช่น Node.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 อาร์กิวเมนต์ รายการหนึ่งเพื่อความสำเร็จ 1 รายการ สำหรับความล้มเหลว (หรือดำเนินการตามและปฏิเสธโดยสัญญาไว้):

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 จะ ไม่เรียกทั้งคู่ แต่ด้วย then(func1).catch(func2) ทั้งสองสิ่งนี้จะ ถูกเรียก หาก 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 ดังนั้นข้อผิดพลาดจะถูกตรวจจับโดยอัตโนมัติและ กลายเป็นการปฏิเสธ

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

ระบบจะตรวจจับข้อผิดพลาดและโค้ดที่เกิดตามมา เช่นเดียวกับ test/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แฟน นั่นเอง นี่ไม่ใช่ นวนิยาย Pulp Fame มาช่วยกันแก้ไขกันเถอะ

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

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

แล้วเวอร์ชันการซิงค์ก็ด้วย แต่เราทำได้ ได้ดียิ่งขึ้น ขณะที่หน้าเว็บของเรากำลังดาวน์โหลดดังนี้:

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

ขอขอบคุณ Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp Addy Osmani, Arthur Evans และ Yutaka Hirano เป็นผู้พิสูจน์อักษรในเรื่องนี้และ ของ Google

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