Tìm hiểu về vòng lặp, callback, promise và Async/Await trong Javascript

Giới thiệu

Trong những ngày đầu của Internet, các trang web thường bao gồm dữ liệu tĩnh trong một trang HTML. Nhưng giờ đây, các ứng dụng web đã trở nên tương tác và năng động hơn, việc thực hiện các hoạt động chuyên sâu như thực hiện các yêu cầu mạng bên ngoài để truy xuất dữ liệu API ngày càng trở nên cần thiết. Để xử lý các hoạt động này trong JavaScript, người lập trình cần phải sử dụng các kỹ thuật asynchronous programming techniques (lập trình không đồng bộ)

Là một nhà phát triển JavaScript, bạn cần biết cách làm việc với các API Web không đồng bộ và xử lý phản hồi hoặc lỗi của các hoạt động đó. Trong bài viết này, bạn sẽ tìm hiểu về vòng lặp sự kiện, cách xử lý ban đầu đối với hành vi không đồng bộ thông qua các callback, việc bổ sung các Promises trong ECMAScript 2015 được cập nhật và cách sử dụng async / await mới.

1. The Event Loop

Phần này sẽ giải thích cách JavaScript xử lý code không đồng bộ với vòng lặp sự kiện. Đầu tiên nó sẽ chạy qua phần của vòng lặp sự kiện tại nơi nó làm việc, và sau đó sẽ giải thích hai phần tử của vòng lặp sự kiện: stack and queue.

code JavaScript không sử dụng bất kỳ API Web không đồng bộ nào sẽ thực thi theo cách đồng bộ — tuần tự từng dòng code một. Điều này được chứng minh bằng đoạn code trong ví dụ sau:

// Define three example functions
function first() {
  console.log(1)
}

function second() {
  console.log(2)
}

function third() {
  console.log(3)
}

// Execute the functions
first()
second()
third()

Output :
1
2
3

Khi sử dụng API Web không đồng bộ, các quy tắc trở nên phức tạp hơn. API tích hợp sẵn mà bạn có thể kiểm tra điều này là setTimeout, đặt bộ đếm thời gian và thực hiện một hành động sau một khoảng thời gian cụ thể. setTimeout cần phải không đồng bộ, nếu không toàn bộ trình duyệt sẽ vẫn bị đóng băng trong thời gian chờ, dẫn đến trải nghiệm người dùng kém.

// Define three example functions, but one of them contains asynchronous code
function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

setTimeout nhận hai đối số: hàm mà nó sẽ chạy không đồng bộ và khoảng thời gian nó sẽ đợi trước khi gọi hàm đó. Trong đoạn code này, bạn đã gói console.log trong một hàm ẩn danh và chuyển nó vào setTimeout, sau đó đặt hàm chạy sau 0 mili giây.

Bây giờ hãy gọi các hàm, như bạn đã làm trước đây :

// Execute the functions
first()
second()
third()

Output:
1
3
2

Cho dù bạn đặt thời gian chờ thành 0 giây hay năm phút sẽ không có gì khác biệt — console.log được gọi bằng code không đồng bộ sẽ thực thi sau các chức năng đồng bộ. Điều này xảy ra vì môi trường máy chủ JavaScript, trong trường hợp này là trình duyệt, sử dụng một khái niệm gọi là vòng lặp sự kiện để xử lý các sự kiện đồng thời hoặc song song. Vì JavaScript chỉ có thể thực thi một câu lệnh tại một thời điểm, nó cần vòng lặp sự kiện được thông báo về thời điểm thực thi câu lệnh cụ thể nào. Vòng lặp sự kiện xử lý điều này với các khái niệm về stack and queue (ngăn xếp và hàng đợi).

2. Stack

Stack, hoặc call stack, giữ trạng thái của chức năng hiện đang chạy. Nếu bạn không quen với khái niệm stack, bạn có thể hình dung nó như một mảng có thuộc tính "vào sau ra trước" (LIFO), nghĩa là bạn chỉ có thể thêm hoặc bớt các mục từ cuối của stack. JavaScript sẽ chạy khung hiện tại (hoặc lệnh gọi hàm trong một môi trường cụ thể) trong stack, sau đó xóa nó và chuyển sang khung tiếp theo.

Đối với ví dụ chỉ chứa code đồng bộ, trình duyệt xử lý việc thực thi theo thứ tự sau:

  • Thêm first() vào stack, run first() ghi 1 vào console, xóa first() khỏi stack.
  • Thêm second() vào stack, run second() ghi 2 vào console, xóa second() khỏi stack.
  • Thêm third() vào stack, run third() ghi 3 vào console, xóa third() khỏi stack.

Ví dụ thứ 2:

  • Thêm first() vào stack, run first() ghi 1 vào console, xóa first() khỏi stack.
  • Thêm second()vào stack, run second(). Thêm setTimeout() vào stack, chạy API web setTimeout() khởi động bộ đếm thời gian và thêm hàm ẩn danh vào hàng đợi, xóa setTimeout() khỏi stack.
  • Xóa second() khỏi stack.
  • Thêm third() vào stack, chạy third() ghi 3 vào console, xóa third() khỏi stack.

Vòng loop kiểm tra hàng đợi cho bất kỳ message nào đang chờ xử lý và tìm hàm ẩn danh(anonymous function) từ setTimeout (), thêm hàm vào stack ghi 2 vào console, sau đó xóa nó khỏi stack. Sử dụng setTimeout, một API Web không đồng bộ, giới thiệu khái niệm về hàng đợi, sẽ trình bày ở phần tiếp theo.

3. Queue

Queue - Hàng đợi, còn được gọi là hàng đợi thông báo hoặc hàng đợi tác vụ (message queue or task queue), là một vùng chờ cho các function. Bất cứ khi nào stack trống, vòng lặp sẽ kiểm tra hàng đợi xem có message chờ nào không, bắt đầu từ message cũ nhất. Khi nó tìm thấy, nó sẽ thêm vào stack, nó sẽ thực thi chức năng trong thông báo.

Trong ví dụ setTimeout, anonymous function chạy ngay sau phần còn lại của quá trình thực thi cấp cao nhất, vì bộ đếm thời gian được đặt thành 0 giây. Điều quan trọng cần nhớ là bộ đếm thời gian không có nghĩa là đoạn code sẽ thực thi chính xác trong 0 giây hoặc bất kể thời gian cụ thể nào, mà nó sẽ thêm chức năng ẩn danh vào hàng đợi trong khoảng thời gian đó. Hệ thống hàng đợi này tồn tại bởi vì nếu bộ đếm thời gian thêm chức năng ẩn danh trực tiếp vào stack khi bộ đếm thời gian kết thúc, nó sẽ làm gián đoạn bất kỳ function nào hiện đang chạy, điều này có thể gây ra các tác động không mong muốn và không thể đoán trước. Lưu ý: Ngoài ra còn có một hàng đợi khác được gọi là hàng đợi công việc hoặc hàng đợi vi nhiệm vụ xử lý các lời hứa. Các vi nhiệm vụ như lời hứa được xử lý ở mức độ ưu tiên cao hơn so với các vi nhiệm vụ như setTimeout.

Bây giờ bạn đã biết cách vòng lặp sử dụng ngăn stack và queue để xử lý thứ tự thực thi code. Nhiệm vụ tiếp theo là tìm ra cách kiểm soát thứ tự thực thi trong code của bạn. Để làm điều này, trước tiên bạn sẽ tìm hiểu về cách ban đầu để đảm bảo code không đồng bộ được xử lý chính xác bởi vòng lặp: các callback functions.

4. Callback Functions

Trong ví dụ setTimeout, hàm có thời gian chờ chạy sau mọi thứ. Nhưng nếu bạn muốn đảm bảo một trong các function, như third(), chạy sau thời gian chờ, thì bạn sẽ phải sử dụng các phương pháp mã hóa không đồng bộ. Thời gian chờ ở đây có thể đại diện cho một lệnh gọi API không đồng bộ có chứa dữ liệu. Bạn muốn làm việc với dữ liệu từ lệnh gọi API, nhưng bạn phải đảm bảo dữ liệu được trả về trước.

Giải pháp ban đầu để giải quyết vấn đề này là sử dụng các callback function. Các callback function không có cú pháp đặc biệt; chúng chỉ là một hàm đã được truyền như một đối số cho một hàm khác. Hàm nhận một hàm khác làm đối số được gọi là hàm bậc cao hơn. Theo định nghĩa này, bất kỳ hàm nào cũng có thể trở thành callback function nếu nó được truyền dưới dạng đối số. Bản chất các lệnh callback không phải là không đồng bộ, nhưng có thể được sử dụng cho mục đích không đồng bộ. Ví dụ :

// A function
function fn() {
  console.log('Just a function')
}

// A function that takes another function as an argument
function higherOrderFunction(callback) {
  // When you call a function that is passed as an argument, it is referred to as a callback
  callback()
}

// Passing a function
higherOrderFunction(fn)

=> Output:
Just a function

Hãy trở lại với first, second, third functions và setTimeout.

function first() {
  console.log(1)
}

function second() {
  setTimeout(() => {
    console.log(2)
  }, 0)
}

function third() {
  console.log(3)
}

Nhiệm vụ là làm cho hàm third() luôn trì hoãn thực thi cho đến khi hành động không đồng bộ trong hàmsecond() đã hoàn thành. Đây là nơi các callback xuất hiện. Thay vì thực thi function first(), second()third() ở cùng cấp độ thực thi cao nhất, bạn sẽ chuyển third() làm đối số cho second(). Function second() sẽ thực hiện callback sau khi hành động không đồng bộ đã hoàn thành.

Dưới đây là ba hàm có áp dụng callback:

// Define three functions
function first() {
  console.log(1)
}

function second(callback) {
  setTimeout(() => {
    console.log(2)

    // Execute the callback function
    callback()
  }, 0)
}

function third() {
  console.log(3)
}

first()
second(third)

Output:
1
2
3

Đầu tiên 1 sẽ được in ra và sau khi bộ đếm thời gian hoàn thành (trong trường hợp này là 0 giây, nhưng bạn có thể thay đổi nó thành bất kỳ số lượng nào), nó sẽ in ra 2 rồi đến 3. Bằng cách chuyển một hàm làm callback, bạn đã trì hoãn thực hiện thành công hoạt động cho đến khi hoàn thành API Web không đồng bộ (setTimeout).

Điểm mấu chốt ở đây là các callback function không phải là không đồng bộ — setTimeout là Web API không đồng bộ chịu trách nhiệm xử lý các tác vụ không đồng bộ. Callback chỉ cho phép bạn được thông báo về thời điểm một task không đồng bộ đã hoàn thành và xử lý thành công hay thất bại của task.

Bây giờ bạn đã học cách sử dụng cáccallback để xử lý các task không đồng bộ, phần tiếp theo sẽ giải thích các vấn đề của việc lồng quá nhiều callback lại và tạo ra một "pyramid of doom" - kim tự tháp của sự diệt vong

5. Nested Callbacks and the Pyramid of Doom

Các callback function là một cách hiệu quả để đảm bảo việc thực thi một hàm bị trì hoãn cho đến khi một hàm khác hoàn thành và trả về cùng với dữ liệu. Tuy nhiên, do tính chất lồng ghép của các callback, đoạn code có thể trở nên lộn xộn nếu bạn có nhiều yêu cầu không đồng bộ liên tiếp dựa vào nhau. Đây là một sự thất bại lớn đối với các nhà phát triển JavaScript từ rất sớm và kết quả là code chứa các Nested Callbacks thường được gọi là "pyramid of doom" or "callback hell."

Ví dụ:

function pyramidOfDoom() {
  setTimeout(() => {
    console.log(1)
    setTimeout(() => {
      console.log(2)
      setTimeout(() => {
        console.log(3)
      }, 500)
    }, 2000)
  }, 1000)
}

Output:
1
2
3

Trên thực tế, với code không đồng bộ có thể rất phức tạp. Rất có thể bạn sẽ cần thực hiện xử lý lỗi trong code không đồng bộ, sau đó chuyển một số dữ liệu từ mỗi response vào request tiếp theo. Làm điều này với các callback sẽ khiến code của bạn khó đọc và khó bảo trì.

Ví dụ :

// Example asynchronous function
function asynchronousRequest(args, callback) {
  // Throw an error if no arguments are passed
  if (!args) {
    return callback(new Error('Whoa! Something went wrong.'))
  } else {
    return setTimeout(
      // Just adding in a random number so it seems like the contrived asynchronous function
      // returned different data
      () => callback(null, {body: args + ' ' + Math.floor(Math.random() * 10)}),
      500,
    )
  }
}

// Nested asynchronous requests
function callbackHell() {
  asynchronousRequest('First', function first(error, response) {
    if (error) {
      console.log(error)
      return
    }
    console.log(response.body)
    asynchronousRequest('Second', function second(error, response) {
      if (error) {
        console.log(error)
        return
      }
      console.log(response.body)
      asynchronousRequest(null, function third(error, response) {
        if (error) {
          console.log(error)
          return
        }
        console.log(response.body)
      })
    })
  })
}

// Execute
callbackHell()

Output:
First 9
Second 3
Error: Whoa! Something went wrong.
    at asynchronousRequest (<anonymous>:4:21)
    at second (<anonymous>:29:7)
    at <anonymous>:9:13

Cách xử lý code không đồng bộ này rất khó làm theo. Kết quả là, khái niệm về Promises đã được giới thiệu trong ES6. Đây là trọng tâm của phần tiếp theo.

6. Promises

Một Promises đại diện cho việc hoàn thành một chức năng không đồng bộ. Nó là một đối tượng có thể trả về một giá trị trong tương lai. Nó hoàn thành mục tiêu cơ bản giống như một callback function, nhưng với nhiều tính năng bổ sung và cú pháp dễ đọc hơn. Là một nhà phát triển JavaScript, bạn có thể sẽ dành nhiều thời gian hơn cho các promises so với việc tạo chúng, vì các API Web không đồng bộ thường trả về một promises. Hướng dẫn này sẽ chỉ cho bạn cách thực hiện cả hai.

6.1. Creating a promise

Bạn có thể khởi tạo một promise với cú pháp new Promise như sau, và nếu bạn kiểm tra trong console sẽ thấy trạng thái đang chờ xử lý và giá trị chưa xác định :

// Initialize a promise
const promise = new Promise((resolve, reject) => {})

__proto__: Promise
[[PromiseStatus]]: "pending"
[[PromiseValue]]: undefined

Cho đến nay, không có gì được thiết lập cho promise, vì vậy nó sẽ nằm ở đó trong trạng thái chờ xử lý mãi mãi. Điều đầu tiên bạn có thể làm để kiểm tra một promise là thực hiện nó bằng cách thực hiện nó với một giá trị:

const promise = new Promise((resolve, reject) => {
  resolve('We did it!')
})

__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: "We did it!"

Như đã nói ở đầu phần này, một promise là một object có thể trả về một giá trị. Sau khi được thực hiện thành công, giá trị sẽ chuyển từ không xác định thành được điền vào dữ liệu.

Một promise có thể có ba trạng thái: pending, fulfilled, rejected.

  • pending : Trạng thái ban đầu trước khi được thực hiện hoặc bị từ chối
  • fulfilled: Thao tác thành công, promise đã được thực hiện
  • rejected: Thao tác không thành công, promise đã bị từ chối Sau khi được thực hiện hoặc bị từ chối, một lời hứa được giải quyết.

Bây giờ bạn đã có ý tưởng về cách các promise được tạo ra, hãy xem cách một nhà phát triển có thể sử dụng những promise này.

6.2. Consuming a Promise

Promise trong phần cuối cùng đã hoàn thành với một giá trị và để truy cập giá trị, Promise có một phương thức được gọi sau đó sẽ chạy sau khi đựợc thực thi.

Đây là cách bạn sẽ trả về và ghi lại giá trị của promise:

promise.then((response) => {
  console.log(response)
})

Output: 
We did it!

Cùng đến với ví dụ sau sử dụng setTimeout trong Promise. Sử dụng cú pháp then đảm bảo rằng phản hồi sẽ chỉ được ghi lại khi setTimeout hoàn thành sau 2000 mili giây. Tất cả điều này được thực hiện mà không cần nesting callbacks. Sau hai giây, nó sẽ thực thi giá trị promise :

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve('Resolving an asynchronous request!'), 2000)
})

// Log the result
promise.then((response) => {
  console.log(response)
})

Output:
Resolving an asynchronous request!

Các promise cũng có thể được xâu chuỗi để truyền dữ liệu tới nhiều hơn một hành động không đồng bộ. Nếu một giá trị được trả về trong then, thì một then khác có thể được thêm vào sẽ đáp ứng với giá trị trả về của then trước đó:

// Chain a promise
promise
  .then((firstResponse) => {
    // Return a new value for the next then
    return firstResponse + ' And chaining!'
  })
  .then((secondResponse) => {
    console.log(secondResponse)
  })
  
 Output:
 Resolving an asynchronous request! And chaining!

6.3. Error Handling

Đến đây, bạn chỉ xử lý một promise với một thực thi thành công, nhưng chưa xử lý lỗi — nếu API gặp sự cố hoặc một yêu cầu không đúng định dạng hoặc trái phép được gửi đi. Trong phần này, bạn sẽ tạo một hàm để kiểm tra cả trường hợp thành công và lỗi khi tạo và sử dụng một promise.

function getUsers(onSuccess) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Handle resolve and reject in the asynchronous API
      if (onSuccess) {
        resolve([
          {id: 1, name: 'Jerry'},
          {id: 2, name: 'Elaine'},
          {id: 3, name: 'George'},
        ])
      } else {
        reject('Failed to fetch data!')
      }
    }, 1000)
  })
}

Để có kết quả thành công, bạn trả về các đối tượng JavaScript đại diện cho dữ liệu người dùng mẫu.

Để xử lý lỗi, bạn sẽ sử dụng instance method catch . Nó sẽ cung cấp cho bạn một callback thất bại với error là một tham số.

Chạy getUser với onSuccess được đặt thành false, sử dụng phương thức then cho trường hợp thành công và phương thức catch đối với lỗi:

// Run the getUsers function with the false flag to trigger an error
getUsers(false)
  .then((response) => {
    console.log(response)
  })
  .catch((error) => {
    console.error(error)
  })  
//khi gặp lỗi
Failed to fetch data!

//Khi thành công
(3) [{…}, {…}, {…}]
0: {id: 1, name: "Jerry"}
1: {id: 2, name: "Elaine"}
3: {id: 3, name: "George"}

Bạn có thể tham khảo bảng sau về các phuơng thức :

6.4. Using the Fetch API with Promises

Một trong những API Web hữu ích và được sử dụng thường xuyên nhất trả về một promise là Fetch API, cho phép bạn thực hiện một yêu cầu tài nguyên không đồng bộ qua mạng. Fetch là một quá trình gồm hai phần và yêu cầu then.

Ví dụ :

// Fetch a user from the GitHub API
fetch('https://api.github.com/users/octocat')
  .then((response) => {
    return response.json()
  })
  .then((data) => {
    console.log(data)
  })
  .catch((error) => {
    console.error(error)
  })

Request Fetch được gửi đến URL https://api.github.com/users/octocat, URL này chờ phản hồi một cách không đồng bộ. Đầu tiên, then chuyển phản hồi đến một hàm ẩn danh định dạng response dưới dạng dữ liệu JSON, sau đó chuyển JSON đến then thứ hai ghi dữ liệu vào console. Câu lệnh catch ghi lại bất kỳ lỗi nào vào console.

login: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...

Đây là dữ liệu được request từ https://api.github.com/users/octocat, hiển thị ở dạng JSON.

Phần này của hướng dẫn cho thấy rằng các promise kết hợp nhiều cải tiến để xử lý code không đồng bộ. Tuy nhiên, trong khi sử dụng then để xử lý các hành động không đồng bộ dễ làm theo hơn so với callback, một số nhà phát triển vẫn thích một định dạng đồng bộ để viết code không đồng bộ. Để giải quyết nhu cầu này, ECMAScript 2016 (ES7) đã giới thiệu các hàm không đồng bộ và từ khóa await để làm việc với các promise dễ dàng hơn.

7. Async Functions with async/await

Function async cho phép bạn xử lý code không đồng bộ theo cách có vẻ đồng bộ. Các async function vẫn sử dụng các promise ẩn, nhưng có cú pháp JavaScript truyền thống hơn. Trong phần này, bạn sẽ thử các ví dụ về cú pháp này. Bạn có thể tạo một async function bằng cách thêm từ khóa async trước một hàm. Mặc dù hàm này chưa xử lý bất kỳ thứ gì không đồng bộ nhưng nó hoạt động khác với một hàm truyền thống. Nếu bạn thực thi hàm, bạn sẽ thấy rằng nó trả về một promise với [[PromiseStatus]][[PromiseValue]] thay vì trả về giá trị.

// Create an async function
async function getUser() {
  return {}
}
console.log(getUser())

Output:
__proto__: Promise
[[PromiseStatus]]: "fulfilled"
[[PromiseValue]]: Object

Điều này có nghĩa là bạn có thể xử lý một async function với sau đó theo cách giống như cách bạn có thể xử lý một promise. Hãy thử điều này với code sau:

getUser().then((response) => console.log(response))

Output:
{}

Một async function có thể xử lý một promise được gọi trong nó bằng cách sử dụng toán tử await. Await có thể được sử dụng trong một async function và sẽ đợi cho đến khi một lpromise lắng xuống trước khi thực thi code được chỉ định. Với kiến thức này, bạn có thể viết lại request Fetch từ phần cuối cùng bằng cách sử dụng async / awaitnhư sau:

// Handle fetch with async/await
async function getUser() {
  const response = await fetch('https://api.github.com/users/octocat')
  const data = await response.json()

  console.log(data)
}

// Execute async function
getUser()

Output:
login: "octocat",
id: 583231,
avatar_url: "https://avatars3.githubusercontent.com/u/583231?v=4"
blog: "https://github.blog"
company: "@github"
followers: 3203
...

Lưu ý: Trong nhiều môi trường, async là cần thiết để sử dụng await — tuy nhiên, một số phiên bản mới của trình duyệt và Node cho phép sử dụng await cấp cao nhất, điều này cho phép bạn bỏ qua việc tạo một async function để bao gồm await.

Cuối cùng, vì bạn đang xử lý promise đã thực hiện trong async function, nên bạn cũng có thể xử lý lỗi trong hàm. Thay vì sử dụng phương thức catch với then, bạn sẽ sử dụng try/catch để xử lý exception.

// Handling success and errors with async/await
async function getUser() {
  try {
    // Handle success in try
    const response = await fetch('https://api.github.com/users/octocat')
    const data = await response.json()

    console.log(data)
  } catch (error) {
    // Handle error in catch
    console.error(error)
  }
}

Chương trình bây giờ sẽ bỏ qua khối catch nếu nó nhận được lỗi và ghi lỗi đó vào console.

Code JavaScript không đồng bộ hiện đại thường được xử lý bằng cú pháp async / await, nhưng điều quan trọng là phải có kiến ​​thức làm việc về cách hoạt động của các promise, đặc biệt là vì các promise có khả năng bổ sung các tính năng không thể xử lý với async / await, như kết hợp các promise với Promise .all().

Lưu ý: async / await có thể được sao chép bằng cách sử dụng các trình tạo kết hợp với các promise để thêm tính linh hoạt hơn cho code của bạn.

Kết luận :

Vì các API Web thường cung cấp dữ liệu không đồng bộ, học cách xử lý kết quả của các hành động không đồng bộ là một phần thiết yếu để trở thành nhà phát triển JavaScript. Trong bài viết này, bạn đã biết cách môi trường máy chủ sử dụng vòng lặp sự kiện để xử lý thứ tự thực thi code với satck và queue. Bạn cũng đã thử các ví dụ về ba cách để xử lý sự thành công hay thất bại của một sự kiện không đồng bộ, với các callback, promise và cú pháp async / await. Cuối cùng, bạn đã sử dụng API Web tìm nạp để xử lý các hành động không đồng bộ.

Nguồn : https://www.taniarascia.com/asynchronous-javascript-event-loop-callbacks-promises-async-await/


All Rights Reserved