JavaScript Promises: บทนำ

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

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

[Drumroll begins]

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

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

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

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 เหตุการณ์ ยกเว้นในกรณีต่อไปนี้

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

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

คำศัพท์เกี่ยวกับ Promise

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

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

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

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

Promises มาถึง JavaScript แล้ว

พรอมต์มีมานานแล้วในรูปแบบไลบรารี เช่น

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

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

ตัวสร้าง Promise จะรับอาร์กิวเมนต์ 1 รายการ ซึ่งเป็น Callback ที่มี 2 พารามิเตอร์ ได้แก่ resolve และ reject ทําบางอย่างภายในการเรียกกลับ ซึ่งอาจเป็นแบบแอสซิงค์ จากนั้นเรียกใช้ 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() ยังคงแสดงสัญญาผูกมัดที่ดึง 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 หรือผู้ใช้ออฟไลน์อยู่) ระบบจะข้าม Callback ที่สำเร็จต่อไปนี้ทั้งหมด ซึ่งรวมถึงการเรียกใน getJSON() ที่พยายามแยกวิเคราะห์การตอบกลับเป็น JSON และจะข้าม Callback ที่เพิ่ม chapter1.html ลงในหน้า แต่จะไปที่การเรียกคืนข้อมูลของ catch แทน ระบบจึงจะเพิ่มข้อความ "แสดงบทไม่สำเร็จ" ลงในหน้าหากการดำเนินการก่อนหน้าไม่สำเร็จ

เช่นเดียวกับ try/catch ของ JavaScript ระบบจะจับข้อผิดพลาดและโค้ดถัดไปจะทำงานต่อ ดังนั้นภาพสปินเนอร์จะซ่อนอยู่เสมอ ซึ่งเป็นสิ่งที่เราต้องการ บรรทัดด้านบนจะกลายเป็นเวอร์ชันแบบไม่บล็อกแบบแอสซิงค์ของ

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

คุณอาจต้องcatch()เพื่อวัตถุประสงค์ในการบันทึกเท่านั้น โดยไม่มีการกู้คืนจากข้อผิดพลาด ซึ่งทำได้โดยโยนข้อผิดพลาดอีกครั้ง เราทําเช่นนี้ได้โดยใช้วิธี getJSON() ดังนี้

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

เราดึงข้อมูลบทหนึ่งมาได้ แต่เราต้องการทุกบท มาทำให้มันเกิดขึ้นกัน

การทำงานแบบขนานและการจัดลําดับ: ใช้ประโยชน์จากทั้ง 2 อย่าง

การคิดแบบแอสซิงค์ไม่ใช่เรื่องง่าย ถ้าคุณยังทำไม่ได้ดีนัก ให้ลองเขียนโค้ดให้เหมือนกับว่าโค้ดเป็นแบบซิงโครนัส ในกรณีนี้

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

เยี่ยมเลย แต่มีการซิงค์และล็อกเบราว์เซอร์ขณะที่ดาวน์โหลด เราใช้ then() เพื่อทําให้เหตุการณ์ต่างๆ เกิดขึ้นทีละรายการเพื่อให้การทำงานเป็นแบบแอสซิงค์

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

แต่เราจะวนดู URL ของบทและดึงข้อมูลตามลำดับได้อย่างไร การดำเนินการต่อไปนี้ใช้ไม่ได้

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

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

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

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

มาผสานรวมทั้งหมดกันเลย

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

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

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

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all จะรับอาร์เรย์ของ Promise และสร้าง Promise ที่ดำเนินการสำเร็จเมื่อ Promise ทั้งหมดดำเนินการเสร็จสมบูรณ์ คุณจะได้รับอาร์เรย์ของผลลัพธ์ (ไม่ว่าจะมีการตอบสนองตามสัญญาหรือไม่) ตามลําดับเดียวกับสัญญาที่คุณส่งเข้ามา

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

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

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

โดยเราจะดึงข้อมูล JSON ของทุกบทพร้อมกัน จากนั้นสร้างลําดับเพื่อเพิ่มลงในเอกสาร ดังนี้

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

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

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

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

รอบพิเศษ: ความสามารถที่ขยายการให้บริการ

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

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

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