Hãy xem Async/Await trong JavaScript bằng các ví dụ

Tác giả bài viết xem xét các ví dụ về Async/Await trong JavaScript. Nhìn chung, Async/Await là một cách thuận tiện để viết mã không đồng bộ. Trước khi tính năng này xuất hiện, mã như vậy được viết bằng lệnh gọi lại và lời hứa. Tác giả của bài viết gốc tiết lộ những ưu điểm của Async/Await bằng cách phân tích nhiều ví dụ khác nhau.

Chúng tôi nhắc nhở: cho tất cả độc giả của "Habr" - giảm giá 10 rúp khi đăng ký bất kỳ khóa học Skillbox nào bằng mã khuyến mại "Habr".

Hộp kỹ năng khuyến nghị: Khóa học giáo dục trực tuyến "nhà phát triển Java".

Gọi lại

Gọi lại là một chức năng mà cuộc gọi của nó bị trì hoãn vô thời hạn. Trước đây, lệnh gọi lại được sử dụng trong những vùng mã mà không thể nhận được kết quả ngay lập tức.

Dưới đây là ví dụ về việc đọc tệp không đồng bộ trong Node.js:

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

Vấn đề phát sinh khi bạn cần thực hiện nhiều thao tác không đồng bộ cùng một lúc. Hãy tưởng tượng kịch bản này: một yêu cầu được gửi tới cơ sở dữ liệu người dùng Arfat, bạn cần đọc trường profile_img_url của nó và tải xuống hình ảnh từ máy chủ someserver.com.
Sau khi tải xuống, chúng tôi chuyển đổi hình ảnh sang định dạng khác, ví dụ từ PNG sang JPEG. Nếu chuyển đổi thành công, một lá thư sẽ được gửi đến email của người dùng. Tiếp theo, thông tin về sự kiện được nhập vào tệp Transforms.log, cho biết ngày tháng.

Điều đáng chú ý là sự chồng chéo của các lệnh gọi lại và số lượng lớn }) trong phần cuối cùng của mã. Nó được gọi là Địa ngục gọi lại hoặc Kim tự tháp diệt vong.

Những nhược điểm của phương pháp này là rõ ràng:

  • Mã này rất khó đọc.
  • Nó cũng khó xử lý lỗi, thường dẫn đến chất lượng mã kém.

Để giải quyết vấn đề này, các lời hứa đã được thêm vào JavaScript. Chúng cho phép bạn thay thế việc lồng sâu các lệnh gọi lại bằng từ .then.

Khía cạnh tích cực của lời hứa là chúng làm cho mã dễ đọc hơn nhiều, từ trên xuống dưới thay vì từ trái sang phải. Tuy nhiên, lời hứa cũng có vấn đề:

  • Bạn cần thêm rất nhiều .then.
  • Thay vì thử/bắt, .catch được sử dụng để xử lý tất cả các lỗi.
  • Làm việc với nhiều lời hứa trong một vòng lặp không phải lúc nào cũng thuận tiện; trong một số trường hợp, chúng làm phức tạp mã.

Đây là một vấn đề sẽ cho thấy ý nghĩa của điểm cuối cùng.

Giả sử chúng ta có vòng lặp for in một chuỗi số từ 0 đến 10 theo các khoảng thời gian ngẫu nhiên (0–n giây). Sử dụng lời hứa, bạn cần thay đổi vòng lặp này để các số được in theo thứ tự từ 0 đến 10. Vì vậy, nếu mất 6 giây để in số 2 và XNUMX giây để in số XNUMX, thì số XNUMX sẽ được in trước, sau đó việc đếm ngược để in cái này sẽ bắt đầu.

Và tất nhiên, chúng tôi không sử dụng Async/Await hoặc .sort để giải quyết vấn đề này. Một giải pháp ví dụ là ở cuối.

Hàm không đồng bộ

Việc bổ sung các hàm không đồng bộ trong ES2017 (ES8) đã đơn giản hóa công việc làm việc với các lời hứa. Tôi lưu ý rằng các hàm async hoạt động “trên hết” các lời hứa. Các chức năng này không đại diện cho các khái niệm khác nhau về chất. Các hàm không đồng bộ được dùng để thay thế cho mã sử dụng lời hứa.

Async/Await giúp tổ chức công việc với mã không đồng bộ theo kiểu đồng bộ.

Do đó, việc biết các lời hứa sẽ giúp bạn hiểu rõ hơn các nguyên tắc của Async/Await.

cú pháp

Thông thường nó bao gồm hai từ khóa: async và chờ đợi. Từ đầu tiên biến hàm thành không đồng bộ. Các chức năng như vậy cho phép sử dụng chờ đợi. Trong mọi trường hợp khác, việc sử dụng chức năng này sẽ gây ra lỗi.

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

Async được chèn vào ngay đầu phần khai báo hàm và trong trường hợp hàm mũi tên, giữa dấu “=” và dấu ngoặc đơn.

Các hàm này có thể được đặt trong một đối tượng dưới dạng các phương thức hoặc được sử dụng trong khai báo lớp.

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

NB! Điều đáng ghi nhớ là các hàm tạo và getters/setters của lớp không thể không đồng bộ.

Ngữ nghĩa và quy tắc thực thi

Các hàm Async về cơ bản tương tự như các hàm JS tiêu chuẩn, nhưng vẫn có những ngoại lệ.

Do đó, các hàm async luôn trả về lời hứa:

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

Cụ thể, fn trả về chuỗi hello. Chà, vì đây là hàm không đồng bộ nên giá trị chuỗi được gói trong một lời hứa bằng cách sử dụng hàm tạo.

Đây là một thiết kế thay thế không có Async:

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

Trong trường hợp này, lời hứa được trả lại “thủ công”. Hàm không đồng bộ luôn được bao bọc trong một lời hứa mới.

Nếu giá trị trả về là nguyên thủy, hàm async sẽ trả về giá trị bằng cách gói nó trong một lời hứa. Nếu giá trị trả về là một đối tượng lời hứa thì độ phân giải của nó sẽ được trả về trong một lời hứa mới.

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

Nhưng điều gì sẽ xảy ra nếu có lỗi bên trong hàm không đồng bộ?

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

Nếu nó không được xử lý, foo() sẽ trả lại lời hứa với sự từ chối. Trong trường hợp này, Promise.reject có lỗi sẽ được trả về thay vì Promise.resolve.

Các hàm Async luôn đưa ra một lời hứa, bất kể kết quả được trả về là gì.

Các hàm không đồng bộ tạm dừng mỗi lần chờ đợi.

Chờ đợi ảnh hưởng đến biểu thức. Vì vậy, nếu biểu thức là một lời hứa thì hàm async sẽ bị tạm dừng cho đến khi lời hứa được thực hiện. Nếu biểu thức không phải là một lời hứa, nó sẽ được chuyển đổi thành một lời hứa thông qua Promise.resolve và sau đó được hoàn thành.

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

Và đây là mô tả về cách hoạt động của hàm fn.

  • Sau khi gọi nó, dòng đầu tiên được chuyển đổi từ const a = chờ 9; trong const a = đang chờ Promise.resolve(9);.
  • Sau khi sử dụng Đang chờ, quá trình thực thi hàm sẽ bị tạm dừng cho đến khi a nhận được giá trị của nó (trong tình huống hiện tại là 9).
  • delayAndGetRandom(1000) tạm dừng việc thực thi hàm fn cho đến khi nó tự hoàn thành (sau 1 giây). Điều này có hiệu quả dừng chức năng fn trong 1 giây.
  • delayAndGetRandom(1000) thông qua giải quyết trả về một giá trị ngẫu nhiên, sau đó được gán cho biến b.
  • Vâng, trường hợp với biến c cũng tương tự như trường hợp với biến a. Sau đó, mọi thứ dừng lại trong một giây, nhưng bây giờ delayAndGetRandom(1000) không trả về gì vì nó không bắt buộc.
  • Kết quả là các giá trị được tính bằng công thức a + b * c. Kết quả được gói gọn trong một lời hứa sử dụng Promise.resolve và được hàm trả về.

Những khoảng dừng này có thể gợi nhớ đến các trình tạo trong ES6, nhưng có điều gì đó liên quan đến nó lý do của bạn.

Giải quyết vấn đề

Bây giờ chúng ta hãy xem giải pháp cho vấn đề nêu trên.

Hàm finishMyTask sử dụng Đang chờ để chờ kết quả của các hoạt động như queryDatabase, sendEmail, logTaskInFile và các hoạt động khác. Nếu bạn so sánh giải pháp này với giải pháp sử dụng lời hứa, những điểm tương đồng sẽ trở nên rõ ràng. Tuy nhiên, phiên bản Async/Await đơn giản hóa rất nhiều sự phức tạp về cú pháp. Trong trường hợp này, không có số lượng lớn lệnh gọi lại và chuỗi như .then/.catch.

Đây là một giải pháp với đầu ra là số, có hai lựa chọn.

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

Và đây là giải pháp sử dụng hàm async.

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

Xử lý lỗi

Các lỗi chưa được xử lý được gói gọn trong một lời hứa bị từ chối. Tuy nhiên, các hàm async có thể sử dụng try/catch để xử lý lỗi một cách đồng bộ.

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() là một hàm không đồng bộ thành công (“số hoàn hảo”) hoặc thất bại do có lỗi (“Xin lỗi, số quá lớn”).

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

Vì ví dụ trên kỳ vọng canRejectOrReturn sẽ được thực thi nên lỗi của chính nó sẽ dẫn đến việc thực thi khối bắt. Kết quả là, hàm foo sẽ kết thúc với kết quả không xác định (khi không có gì được trả về trong khối thử) hoặc bị phát hiện lỗi. Kết quả là hàm này sẽ không bị lỗi vì try/catch sẽ tự xử lý hàm foo đó.

Đây là một ví dụ khác:

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

Điều đáng chú ý là trong ví dụ này, canRejectOrReturn được trả về từ foo. Foo trong trường hợp này kết thúc bằng số hoàn hảo hoặc trả về Lỗi (“Xin lỗi, số quá lớn”). Khối Catch sẽ không bao giờ được thực thi.

Vấn đề là foo trả lại lời hứa được truyền từ canRejectOrReturn. Vì vậy, giải pháp cho foo trở thành giải pháp cho canRejectOrReturn. Trong trường hợp này, mã sẽ chỉ bao gồm hai dòng:

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

Đây là những gì sẽ xảy ra nếu bạn sử dụng chờ đợi và quay lại cùng nhau:

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

Trong đoạn mã trên, foo sẽ thoát thành công với cả số hoàn hảo và lỗi được bắt. Sẽ không có sự từ chối nào ở đây. Nhưng foo sẽ trả về với canRejectOrReturn, không phải không xác định. Hãy đảm bảo điều này bằng cách xóa dòng return wait canRejectOrReturn() :

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

Những sai lầm và cạm bẫy thường gặp

Trong một số trường hợp, việc sử dụng Async/Await có thể dẫn đến lỗi.

Bị lãng quên đang chờ đợi

Điều này xảy ra khá thường xuyên - từ khóa chờ đợi bị quên trước lời hứa:

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

Như bạn có thể thấy, không có sự chờ đợi hoặc trả lại trong mã. Do đó, foo luôn thoát với trạng thái không xác định mà không bị trễ 1 giây. Nhưng lời hứa sẽ được thực hiện. Nếu nó đưa ra lỗi hoặc bị từ chối thì UnhandledPromiseRejectionWarning sẽ được gọi.

Chức năng không đồng bộ trong cuộc gọi lại

Các hàm không đồng bộ thường được sử dụng trong .map hoặc .filter làm hàm gọi lại. Một ví dụ là hàm getPublicReposCount(username), hàm này trả về số lượng kho lưu trữ đang mở trên GitHub. Giả sử có ba người dùng có số liệu mà chúng tôi cần. Đây là mã cho nhiệm vụ này:

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'];
}

Chúng tôi cần tài khoản ArfatSalman, octocat, norvig. Trong trường hợp này chúng tôi làm:

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

Điều đáng chú ý là Đang chờ trong lệnh gọi lại .map. Ở đây, count là một loạt các lời hứa và .map là lệnh gọi lại ẩn danh cho từng người dùng được chỉ định.

Sử dụng quá nhất quán chờ đợi

Hãy lấy mã này làm ví dụ:

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

Ở đây số repo được đặt trong biến đếm, sau đó số này được thêm vào mảng đếm. Vấn đề với mã là cho đến khi dữ liệu của người dùng đầu tiên đến từ máy chủ, tất cả người dùng tiếp theo sẽ ở chế độ chờ. Vì vậy, tại một thời điểm chỉ có một người dùng được xử lý.

Ví dụ: nếu mất khoảng 300 mili giây để xử lý một người dùng, thì đối với tất cả người dùng, thời gian đó đã là một giây; thời gian sử dụng tuyến tính phụ thuộc vào số lượng người dùng. Nhưng vì số lượng repo không phụ thuộc lẫn nhau nên các quy trình có thể được thực hiện song song. Điều này đòi hỏi phải làm việc với .map và 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 nhận được một loạt lời hứa làm đầu vào và trả về một lời hứa. Điều thứ hai, sau khi tất cả các lời hứa trong mảng đã hoàn thành hoặc ở lần từ chối đầu tiên, sẽ được hoàn thành. Có thể xảy ra trường hợp tất cả chúng không bắt đầu cùng một lúc - để đảm bảo bắt đầu đồng thời, bạn có thể sử dụng p-map.

Kết luận

Các chức năng không đồng bộ ngày càng trở nên quan trọng cho sự phát triển. Chà, để sử dụng thích ứng các hàm async, bạn nên sử dụng Trình lặp không đồng bộ. Một nhà phát triển JavaScript phải thành thạo về vấn đề này.

Hộp kỹ năng khuyến nghị:

Nguồn: www.habr.com

Thêm một lời nhận xét