ลองดู Async/Await ใน JavaScript โดยใช้ตัวอย่าง

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

เราเตือนคุณ: สำหรับผู้อ่าน "Habr" ทุกคน - ส่วนลด 10 rubles เมื่อลงทะเบียนในหลักสูตร Skillbox ใด ๆ โดยใช้รหัสส่งเสริมการขาย "Habr"

Skillbox แนะนำ: หลักสูตรออนไลน์เพื่อการศึกษา “นักพัฒนาจาวา”.

โทรกลับ

Callback เป็นฟังก์ชันที่การโทรล่าช้าอย่างไม่มีกำหนด ก่อนหน้านี้มีการใช้การเรียกกลับในพื้นที่ของโค้ดที่ไม่สามารถรับผลลัพธ์ได้ทันที

นี่คือตัวอย่างของการอ่านไฟล์แบบอะซิงโครนัสใน Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

ปัญหาเกิดขึ้นเมื่อคุณจำเป็นต้องดำเนินการอะซิงโครนัสหลายๆ รายการพร้อมกัน ลองจินตนาการถึงสถานการณ์นี้: มีการร้องขอไปยังฐานข้อมูลผู้ใช้ Arfat คุณต้องอ่านฟิลด์ profile_img_url และดาวน์โหลดรูปภาพจากเซิร์ฟเวอร์ someserver.com
หลังจากดาวน์โหลด เราจะแปลงรูปภาพเป็นรูปแบบอื่น เช่น จาก PNG เป็น JPEG หากการแปลงสำเร็จ จดหมายจะถูกส่งไปยังอีเมลของผู้ใช้ จากนั้น ข้อมูลเกี่ยวกับเหตุการณ์จะถูกป้อนลงในไฟล์ changes.log ซึ่งระบุวันที่

ควรให้ความสนใจกับการทับซ้อนกันของการโทรกลับและ }) จำนวนมากในส่วนสุดท้ายของโค้ด เรียกว่า Callback Hell หรือ Pyramid of Doom

ข้อเสียของวิธีนี้ชัดเจน:

  • รหัสนี้อ่านยาก
  • นอกจากนี้ยังเป็นเรื่องยากที่จะจัดการกับข้อผิดพลาด ซึ่งมักจะทำให้โค้ดมีคุณภาพต่ำ

เพื่อแก้ไขปัญหานี้ จึงมีการเพิ่มคำสัญญาลงใน JavaScript พวกมันอนุญาตให้คุณแทนที่การซ้อนการโทรกลับแบบลึกด้วยคำว่า .then

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

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

นี่คือปัญหาที่จะแสดงความหมายของจุดสุดท้าย

สมมติว่าเรามี for loop ที่พิมพ์ลำดับตัวเลขตั้งแต่ 0 ถึง 10 ในช่วงเวลาสุ่ม (0–n วินาที) เมื่อใช้คำสัญญา คุณต้องเปลี่ยนการวนซ้ำนี้เพื่อให้ตัวเลขถูกพิมพ์ตามลำดับตั้งแต่ 0 ถึง 10 ดังนั้น หากใช้เวลา 6 วินาทีในการพิมพ์เลขศูนย์ และ 2 วินาทีในการพิมพ์เลขหนึ่ง ควรพิมพ์เลขศูนย์ก่อน จากนั้น การนับถอยหลังสำหรับการพิมพ์จะเริ่มขึ้น

และแน่นอนว่าเราไม่ได้ใช้ Async/Await หรือ .sort เพื่อแก้ไขปัญหานี้ ตัวอย่างวิธีแก้ปัญหาอยู่ตอนท้าย

ฟังก์ชันอะซิงก์

การเพิ่มฟังก์ชันอะซิงก์ใน ES2017 (ES8) ทำให้งานทำงานตามสัญญาง่ายขึ้น ฉันสังเกตว่าฟังก์ชัน async ทำงาน "เหนือ" ของสัญญา ฟังก์ชันเหล่านี้ไม่ได้แสดงถึงแนวคิดที่แตกต่างกันในเชิงคุณภาพ ฟังก์ชั่น Async มีไว้เป็นทางเลือกแทนโค้ดที่ใช้สัญญา

Async/Await ทำให้สามารถจัดระเบียบงานด้วยโค้ดอะซิงโครนัสในรูปแบบซิงโครนัสได้

ดังนั้นการรู้คำมั่นสัญญาทำให้เข้าใจหลักการของ Async/Await ได้ง่ายขึ้น

วากยสัมพันธ์

โดยปกติจะประกอบด้วยคำหลักสองคำ: async และ await คำแรกจะเปลี่ยนฟังก์ชันเป็นแบบอะซิงโครนัส ฟังก์ชั่นดังกล่าวอนุญาตให้ใช้ await ได้ ในกรณีอื่น การใช้ฟังก์ชันนี้จะทำให้เกิดข้อผิดพลาด

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async จะถูกแทรกไว้ที่จุดเริ่มต้นของการประกาศฟังก์ชัน และในกรณีของฟังก์ชันลูกศร จะอยู่ระหว่างเครื่องหมาย “=” และวงเล็บ

ฟังก์ชั่นเหล่านี้สามารถวางไว้ในวัตถุเป็นวิธีการหรือใช้ในการประกาศคลาส

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

หมายเหตุ! โปรดจำไว้ว่าตัวสร้างคลาสและตัวรับ/ตัวตั้งค่าไม่สามารถเป็นแบบอะซิงโครนัสได้

ความหมายและกฎการดำเนินการ

ฟังก์ชัน Async โดยพื้นฐานแล้วจะคล้ายกับฟังก์ชัน JS มาตรฐาน แต่มีข้อยกเว้นอยู่

ดังนั้นฟังก์ชัน async จะส่งคืนสัญญาเสมอ:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

โดยเฉพาะ fn ส่งคืนสตริงสวัสดี เนื่องจากนี่เป็นฟังก์ชันอะซิงโครนัส ค่าสตริงจึงถูกรวมไว้ในสัญญาโดยใช้ตัวสร้าง

นี่คือการออกแบบทางเลือกที่ไม่มี Async:

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

ในกรณีนี้ สัญญาจะถูกส่งกลับ "ด้วยตนเอง" ฟังก์ชันอะซิงโครนัสจะถูกห่อหุ้มด้วยสัญญาใหม่เสมอ

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

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

แต่จะเกิดอะไรขึ้นหากมีข้อผิดพลาดภายในฟังก์ชันอะซิงโครนัส?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

หากไม่ได้รับการประมวลผล foo() จะส่งคืนสัญญาพร้อมการปฏิเสธ ในสถานการณ์นี้ Promise.reject ที่มีข้อผิดพลาดจะถูกส่งกลับแทน Promise.resolve

ฟังก์ชัน Async จะแสดงสัญญาเสมอ โดยไม่คำนึงถึงสิ่งที่ส่งคืน

ฟังก์ชันอะซิงโครนัสหยุดชั่วคราวทุกครั้งที่ await

Await ส่งผลต่อนิพจน์ ดังนั้น หากนิพจน์เป็นสัญญา ฟังก์ชันอะซิงก์จะถูกระงับจนกว่าสัญญาจะบรรลุผล หากนิพจน์ไม่ใช่ Promise นิพจน์นั้นจะถูกแปลงเป็น Promise ผ่าน Promise.resolve จากนั้นจึงดำเนินการให้เสร็จสิ้น

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

และนี่คือคำอธิบายว่าฟังก์ชัน fn ทำงานอย่างไร

  • หลังจากเรียกแล้ว บรรทัดแรกจะถูกแปลงจาก const a = await 9; ใน const a = รอ Promise.resolve (9);
  • หลังจากใช้ Await การดำเนินการฟังก์ชันจะถูกระงับจนกว่าจะได้รับค่า (ในสถานการณ์ปัจจุบันคือ 9)
  • DelayAndGetRandom(1000) หยุดการทำงานของฟังก์ชัน fn ชั่วคราวจนกว่าจะดำเนินการเสร็จสิ้น (หลังจาก 1 วินาที) การดำเนินการนี้จะหยุดฟังก์ชัน fn เป็นเวลา 1 วินาทีอย่างมีประสิทธิภาพ
  • DelayAndGetRandom(1000) ผ่านการแก้ไขจะส่งกลับค่าสุ่ม ซึ่งจากนั้นจะกำหนดให้กับตัวแปร b
  • กรณีที่มีตัวแปร c ก็คล้ายกับกรณีที่มีตัวแปร a หลังจากนั้น ทุกอย่างหยุดชั่วขณะ แต่ตอนนี้ DelayAndGetRandom(1000) ไม่ได้ส่งคืนสิ่งใดเลยเนื่องจากไม่จำเป็น
  • เป็นผลให้ค่าถูกคำนวณโดยใช้สูตร a + b * c ผลลัพธ์จะรวมอยู่ในสัญญาโดยใช้ Promise.resolve และส่งคืนโดยฟังก์ชัน

การหยุดชั่วคราวเหล่านี้อาจชวนให้นึกถึงเครื่องกำเนิดไฟฟ้าใน ES6 แต่ก็มีบางอย่างอยู่ เหตุผลของคุณ.

การแก้ปัญหา

ทีนี้เรามาดูวิธีแก้ปัญหาที่กล่าวมาข้างต้นกันดีกว่า

ฟังก์ชัน FinishMyTask ใช้ Await เพื่อรอผลลัพธ์ของการดำเนินการ เช่น queryDatabase, sendEmail, logTaskInFile และอื่นๆ หากคุณเปรียบเทียบวิธีแก้ปัญหานี้กับวิธีที่ใช้สัญญา ความคล้ายคลึงกันจะชัดเจน อย่างไรก็ตาม เวอร์ชัน Async/Await ช่วยลดความซับซ้อนทางวากยสัมพันธ์ทั้งหมดได้อย่างมาก ในกรณีนี้ ไม่มีการเรียกกลับและลูกโซ่เช่น .then/.catch จำนวนมาก

นี่คือวิธีแก้ปัญหาผลลัพธ์ของตัวเลข มีสองตัวเลือก

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

และนี่คือวิธีแก้ปัญหาโดยใช้ฟังก์ชันอะซิงก์

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

เกิดข้อผิดพลาดในการประมวลผล

ข้อผิดพลาดที่ไม่สามารถจัดการได้จะรวมอยู่ในสัญญาที่ถูกปฏิเสธ อย่างไรก็ตาม ฟังก์ชันอะซิงก์สามารถใช้ try/catch เพื่อจัดการข้อผิดพลาดพร้อมกันได้

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() เป็นฟังก์ชันอะซิงโครนัสที่จะสำเร็จ (“จำนวนสมบูรณ์”) หรือล้มเหลวโดยมีข้อผิดพลาด (“ขออภัย ตัวเลขใหญ่เกินไป”)

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

เนื่องจากตัวอย่างข้างต้นคาดว่า canRejectOrReturn จะดำเนินการ ความล้มเหลวของตัวเองจะส่งผลให้เกิดการดำเนินการของ catch block ด้วยเหตุนี้ ฟังก์ชัน foo จะลงท้ายด้วย undefinition (เมื่อไม่มีการส่งคืนสิ่งใดในบล็อก try) หรือโดยตรวจพบข้อผิดพลาด ด้วยเหตุนี้ ฟังก์ชันนี้จะไม่ล้มเหลวเนื่องจาก try/catch จะจัดการฟังก์ชัน foo เอง

นี่เป็นอีกตัวอย่างหนึ่ง:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

ควรให้ความสนใจกับข้อเท็จจริงที่ว่าในตัวอย่าง canRejectOrReturn จะถูกส่งคืนจาก foo Foo ในกรณีนี้จะลงท้ายด้วยตัวเลขสมบูรณ์หรือส่งคืนข้อผิดพลาด (“ขออภัย ตัวเลขใหญ่เกินไป”) catch block จะไม่ถูกดำเนินการ

ปัญหาคือ foo ส่งคืนสัญญาที่ส่งผ่านจาก canRejectOrReturn ดังนั้นวิธีแก้ปัญหาของ foo จึงกลายเป็นวิธีแก้ปัญหาของ canRejectOrReturn ในกรณีนี้ โค้ดจะประกอบด้วยสองบรรทัดเท่านั้น:

try {
    const promise = canRejectOrReturn();
    return promise;
}

นี่คือสิ่งที่จะเกิดขึ้นหากคุณใช้ await และ return ร่วมกัน:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

ในโค้ดด้านบน foo จะออกได้สำเร็จโดยตรวจพบทั้งจำนวนสมบูรณ์และข้อผิดพลาด จะไม่มีการปฏิเสธที่นี่ แต่ foo จะกลับมาพร้อมกับ canRejectOrReturn ไม่ใช่โดยไม่ได้กำหนด มาตรวจสอบสิ่งนี้ด้วยการลบบรรทัด return await canRejectOrReturn() ออก:

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

ข้อผิดพลาดและข้อผิดพลาดทั่วไป

ในบางกรณี การใช้ Async/Await อาจทำให้เกิดข้อผิดพลาดได้

รอจนลืม.

สิ่งนี้เกิดขึ้นค่อนข้างบ่อย - คีย์เวิร์ด await จะถูกลืมก่อนสัญญา:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

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

ฟังก์ชั่น Async ในการโทรกลับ

ฟังก์ชัน Async มักใช้ใน .map หรือ .filter เป็นการเรียกกลับ ตัวอย่างคือฟังก์ชัน fetchPublicReposCount(username) ซึ่งส่งคืนจำนวนที่เก็บที่เปิดอยู่บน GitHub สมมติว่ามีผู้ใช้สามคนที่เราต้องการเมตริก นี่คือรหัสสำหรับงานนี้:

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

เราต้องการบัญชี ArfatSalman, octocat, norvig ในกรณีนี้เราทำ:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

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

การใช้ await สม่ำเสมอมากเกินไป

ลองใช้โค้ดนี้เป็นตัวอย่าง:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

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

ตัวอย่างเช่นหากใช้เวลาประมาณ 300 มิลลิวินาทีในการประมวลผลผู้ใช้หนึ่งรายดังนั้นสำหรับผู้ใช้ทั้งหมดจะใช้เวลาหนึ่งวินาทีแล้ว เวลาที่ใช้เป็นเส้นตรงขึ้นอยู่กับจำนวนผู้ใช้ แต่เนื่องจากการได้รับจำนวน repo ไม่ได้ขึ้นอยู่กับแต่ละกระบวนการ กระบวนการจึงสามารถขนานกันได้ สิ่งนี้ต้องทำงานร่วมกับ .map และ Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all ได้รับอาร์เรย์ของสัญญาเป็นอินพุตและส่งคืนสัญญา อย่างหลัง หลังจากที่สัญญาทั้งหมดในอาเรย์เสร็จสมบูรณ์หรือเมื่อการปฏิเสธครั้งแรกเสร็จสิ้น อาจเกิดขึ้นได้ว่าพวกเขาทั้งหมดไม่ได้เริ่มพร้อมกัน - เพื่อให้แน่ใจว่าจะสตาร์ทพร้อมกัน คุณสามารถใช้ p-map

ข้อสรุป

ฟังก์ชั่น Async มีความสำคัญมากขึ้นสำหรับการพัฒนา สำหรับการใช้งานฟังก์ชั่น async แบบปรับตัวคุณควรใช้ ตัววนซ้ำ Async. นักพัฒนา JavaScript ควรมีความเชี่ยวชาญในเรื่องนี้

Skillbox แนะนำ:

ที่มา: will.com

เพิ่มความคิดเห็น