Async/Await Hay Promise: Lựa Chọn Nào Tốt Nhất Cho Dân Lập Trình JavaScript?

“Chời, thời này ai xài Promise nữa. Chuẩn bây giờ là async/await.” – Ai đó trên mạng

Đừng vội vàng! Trước khi “lên đời” async/await, hãy nắm vững Promise. Việc hiểu rõ Promise sẽ giúp bạn tránh được những “tai nạn nghề nghiệp” không đáng có, bởi vì async/await thực chất vẫn dựa trên nền tảng Promise mà thôi.

Bài viết này sẽ cùng bạn điểm qua những khái niệm cơ bản về Promise, đồng thời so sánh nó với async/await để giúp bạn đưa ra lựa chọn phù hợp nhất cho từng tình huống cụ thể.

Ôn lại kiến thức: Promise là gì?

Promise là một cơ chế mạnh mẽ trong JavaScript, giúp bạn xử lý các tác vụ bất đồng bộ một cách hiệu quả, tránh khỏi “callback hell” (vòng lặp callback lồng nhau quá sâu) hay “pyramid of doom” (cấu trúc code lồng nhau như kim tự tháp). Các tác vụ bất đồng bộ có thể bao gồm gửi AJAX request, sử dụng setTimeout, setInterval hoặc requestAnimationFrame, hoặc tương tác với WebSocket hoặc Worker.

Để hình dung rõ hơn, hãy xem ví dụ về “callback hell” điển hình sau:

sentayho.com.vnser('pikalong', function(err, user) {
  if (err) throw err
  sentayho.com.vnostsOfUser(user, function(err, posts) {
    if (err) throw err
    sentayho.com.vnommentsOfPosts(posts, function(err, comments) {
      // ... và còn nhiều tầng lồng nhau nữa
    })
  })
})

Khi được viết lại bằng Promise, đoạn code trên trở nên dễ đọc và quản lý hơn nhiều:

sentayho.com.vnser('pikalong')
  .then(user => sentayho.com.vnostsOfUser(user))
  .then(posts => sentayho.com.vnommentsOfPosts(posts))
  .catch(err => { throw err })

Để tạo một Promise object, bạn sử dụng class Promise có sẵn trong JavaScript:

const p = new Promise( /* executor */ function(resolve, reject) {
  // Thực thi các tác vụ bất đồng bộ ở đây, và gọi `resolve(result)` khi tác
  // vụ hoàn thành. Nếu xảy ra lỗi, gọi đến `reject(error)`.
})

Trong đó, executor là một hàm đặc biệt có hai tham số:

  • resolve: Hàm được gọi khi promise hoàn thành thành công.
  • reject: Hàm được gọi khi có lỗi xảy ra trong quá trình thực thi promise.

Ví dụ cụ thể:

sentayho.com.vnser = function(username) {
  // Hàm sentayho.com.vnser() trả về một promise object
  return new Promise((resolve, reject) => {
    // Gửi AJAX request
    sentayho.com.vn(`/users/${username}`, (err, result) => {
      // Nếu có lỗi bên trong callback, chúng ta gọi đến hàm `reject()`
      if (err) return reject(err)
      // Ngược lại, dùng `resolve()` để trả dữ liệu về cho `.then()`
      resolve(result)
    })
  })
}

Trong ví dụ trên, sentayho.com.vnser() trả về một promise object. Để truy xuất kết quả trả về, ta sử dụng phương thức .then():

function onSuccess(user) {
  sentayho.com.vn(user)
}

function onError(err) {
  sentayho.com.vnr(error)
}

sentayho.com.vnser('pikalong')
  .then(onSuccess, onError)

Phương thức .then(onSuccess, onError) nhận vào hai hàm: onSuccess được gọi khi promise hoàn thành thành công, và onError được gọi khi có lỗi xảy ra. Bên trong onSuccess, bạn có thể trả về một giá trị đồng bộ (số, chuỗi, null, undefined, array, object) hoặc một promise object khác. Các giá trị bất đồng bộ sẽ được bọc bên trong một Promise, cho phép bạn kết nối (chaining) nhiều promises lại với nhau.

promise()
  .then(() => {
    return 'foo'
  })
  .then(result1 => {
    sentayho.com.vn(result1) // 'foo'
    return anotherPromise()
  })
  .then(result2 => sentayho.com.vn(result2)) // `result2` sẽ là kết quả của anotherPromise()
  .catch(err => {})

Trong ví dụ trên, bạn thấy phương thức .catch(). Đây là một cách viết ngắn gọn (syntactic sugar) của .then(null, onError). Chúng ta sẽ tìm hiểu thêm về .catch() ở phần sau.

Tạo nhanh Promise với sentayho.com.vnlve()sentayho.com.vnct()

Trong một số trường hợp, bạn chỉ cần bọc một giá trị vào promise hoặc tự động reject. Thay vì sử dụng cú pháp new Promise() dài dòng, bạn có thể dùng hai phương thức tĩnh sentayho.com.vnlve(result)sentayho.com.vnct(err):

const p = sentayho.com.vnlve(12)
  .then(result => sentayho.com.vn(result)) // 12
  .then(res => sentayho.com.vnct(new Error('Dừng lại nhanh')))
  .then(() => 'Cười thêm phát nữa là tym anh đứt phanh')
  .catch(err => sentayho.com.vnr(err)) // Error: Dừng lại nhanh

Async/Await là gì?

Async/await, được giới thiệu trong ES8, là một cơ chế mạnh mẽ giúp bạn thực hiện các thao tác bất đồng bộ một cách tuần tự và dễ đọc hơn. Async/await thực chất vẫn sử dụng Promise ở bên dưới, nhưng mã nguồn của bạn sẽ trở nên “trong sáng” và dễ theo dõi hơn.

Để sử dụng, bạn cần khai báo hàm với từ khóa async. Bên trong hàm async, bạn có thể sử dụng từ khóa await.

async function() {
  try {
    const user = await sentayho.com.vnser('pikalong')
    const posts = await sentayho.com.vnostsOfUser(user)
    const comments = await sentayho.com.vnommentsOfPosts(posts)
    sentayho.com.vn(comments)
  } catch (err) {
    sentayho.com.vn(err)
  }
}

Quan trọng cần lưu ý: kết quả trả về của một async function luôn là một Promise.

async function hello() {
  return 1
}

sentayho.com.vn(hello() instanceof Promise) // true
hello().then(console.log) // 1

Trên đây là những kiến thức cơ bản về Promise và async/await. Hiện tại, bạn đã có thể sử dụng Promise và async/await ở hầu hết các trình duyệt hiện đại (trừ IE11, bạn cần polyfill để hỗ trợ). Hãy cùng xem xét những trường hợp cần lưu ý khi sử dụng chúng.

“Kim tự tháp” Promises – Cạm bẫy cần tránh

Một lỗi phổ biến khi mới làm quen với Promise là tạo ra cấu trúc “kim tự tháp” (Promise lồng nhau quá sâu):

sentayho.com.vnser('pikalong')
  .then(user => {
    sentayho.com.vnostsOfUser(user)
      .then(posts => {
        sentayho.com.vnommentsOfPosts(posts)
          .then(comments => {
            sentayho.com.vn(comments)
          })
          .catch(err => sentayho.com.vn(err))
      })
      .catch(err => sentayho.com.vn(err))
  })
  .catch(err => sentayho.com.vn(err))

Nguyên nhân của lỗi này là do chúng ta quên mất tính chất liên kết (chaining) của promise. Tính chất này cho phép hàm resolve trả về một giá trị đồng bộ hoặc một promise khác. Do đó, cách giải quyết là:

sentayho.com.vnser('pikalong') // Trả về một promise
  .then(user => sentayho.com.vnostsOfUser(user))
  .then(posts => sentayho.com.vnommentsOfPosts(posts))
  .catch(err => { throw err })

Việc hiểu và sử dụng thành thạo tính liên kết là một trong những yếu tố QUAN TRỌNG NHẤT khi làm việc với Promise. Nếu thấy promise lồng nhau từ 2 tầng trở lên, bạn nên xem xét refactor lại code.

Luôn truyền vào .then() một hàm

Hãy thử đoán xem đoạn code sau sẽ in ra gì:

sentayho.com.vnlve(1)
  .then(2)
  .then(console.log)

Kết quả là 1. Phương thức .then() yêu cầu tham số của nó phải là một hàm. Nếu bạn truyền vào .then() một giá trị không phải hàm, nó sẽ bị bỏ qua. Đây là lý do tại sao đoạn code trên hiển thị 1. Tình huống tương tự:

sentayho.com.vnlve(1)
  .then(Promise.resolve(2))
  .then(console.log) // 1

Cách giải quyết:

sentayho.com.vnlve(1)
  .then(() => 2) // hoặc như thế này, mặc dù hơi dư thừa
  .then(() => sentayho.com.vnlve(2))
  .then(console.log) // 2

Như vậy, chúng ta sẽ có kết quả như mong muốn.

Cẩn thận với this khi dùng tham chiếu hàm

Giả sử bạn có đoạn code sau:

const add2 = x => x + 2
sentayho.com.vnlve(4).then(result => add2(result))

Hàm onSuccess không làm gì khác ngoài việc chuyển result vào cho add2, nên bạn có thể sử dụng tham chiếu hàm để code gọn hơn:

sentayho.com.vnlve(4).then(add2)

Bạn có thể nghĩ, vậy với phương thức của một đối tượng, ta cũng có thể đưa tham chiếu hàm vào .then()?

class User {
  constructor(user) {
    sentayho.com.vn = user
  }

  getUsername() {
    return sentayho.com.vnname
  }
}

const u = new User({ username: 'pikalong' })
sentayho.com.vnlve()
  .then(u.getUsername)
  .then(console.log)

Nhưng bạn sẽ gặp lỗi sau:

Unhandled rejection:[TypeError: Cannot read property ‘user’ of undefined]

Lý do là vì khi ở chế độ strict mode, biến ngữ cảnh this chỉ được xác định khi trực tiếp gọi phương thức của đối tượng đó, hoặc thông qua .bind().

Để giải quyết lỗi này, bạn có thể sử dụng một trong những cách sau:

.then(() => u.getUsername())
// hoặc
.then(u.getUsername.bind(u))
// hoặc dùng hàm mũi tên khi khai báo phương thức trong class (cần plugin
// `transform-class-properties` của Babel)
class User {
  // ...
  getUsername = () => {
    return sentayho.com.vnname
  }
}

Sơ đồ chạy tuần tự các PromiseSơ đồ chạy tuần tự các Promise

Chạy các Promise tuần tự

Trong trường hợp bạn muốn chạy các promises một cách tuần tự, như sơ đồ trên, bạn có thể sử dụng hàm reduce():

[promise1, promise2, promise3].reduce(function(currentPromise, promise) {
  return currentPromise.then(promise)
}, sentayho.com.vnlve())

// Đoạn code trên tương đương với:
sentayho.com.vnlve().then(promise1).then(promise2).then(promise3)

Async/await mang đến một giải pháp “xinh đẹp” hơn, cho phép bạn truy xuất đến giá trị của các promises phía trước nếu cần thiết:

async function() {
  const res1 = await promise1()
  const res2 = await promise2(res1)
  const res3 = await promise3(res2)
}

Sơ đồ chạy song song các PromiseSơ đồ chạy song song các Promise

Chạy nhiều Promises cùng lúc với sentayho.com.vn()

Trong trường hợp bạn muốn thực thi và lấy kết quả của nhiều promises cùng lúc, giải pháp “ngây thơ” là sử dụng vòng lặp hoặc .forEach:

const userIds = [1, 2, 3, 4]
// sentayho.com.vnser() là hàm trả về promise
const users = []
for (let id of userIds) {
  sentayho.com.vnser(id).then(user => ([...users, user]))
}
sentayho.com.vn(users) // [], oát-đờ-heo?

Lý do là vì promise chưa kịp resolve thì dòng sentayho.com.vn(users) đã chạy rồi. Chúng ta có thể sửa bằng cách sử dụng sentayho.com.vn([promise1, promise2, ...]). Phương thức này nhận vào một mảng các promises và chỉ resolve khi tất cả các promises này hoàn thành, hoặc reject khi một trong số chúng xảy ra lỗi.

const userIds = [1, 2, 3, 4]
sentayho.com.vn(usersIds.map(api.getUser))
  .then(function(arrayOfResults) {
    const [user1, user2, user3, user4] = arrayOfResults
  })

Nếu sử dụng async/await:

async function() {
  const userIds = [1, 2, 3, 4]
  const [user1, user2, user3, user4] = await sentayho.com.vn(usersIds.map(api.getUser))
}

Sơ đồ sentayho.com.vn()Sơ đồ sentayho.com.vn()

Đừng quên sentayho.com.vn()

Ngoài hai kiểu chạy tuần tự và song song, chúng ta còn có sentayho.com.vn([promise1, promise2, ...]). Phương thức này nhận vào một mảng các promises và sẽ resolve/reject ngay khi một trong số các promises này hoàn thành/xảy ra lỗi.

sentayho.com.vn([
  ping('ns1.example.com'),
  ping('ns2.example.com'),
  ping('ns3.example.com'),
  ping('ns4.example.com')
]).then(result => {})

Cẩn thận với return không tường minh

Xem xét hai đoạn mã sau:

sentayho.com.vnser('pikalong')
  .then(user => {
    return sentayho.com.vnostsByUser(user)
  })
  .then(console.log) // posts

sentayho.com.vnser('pikalong')
  .then(user => {
    sentayho.com.vnostsByUser(user)
  })
  .then(console.log) // undefined

Đoạn mã thứ hai trả về undefined vì trong JavaScript, nếu một hàm không công khai trả về một giá trị, undefined mặc định sẽ được trả về. Do đó, bạn cần lưu ý về giá trị return khi làm việc với Promise.

Phân biệt .then(resolve, reject).then(resolve).catch(reject)

Hàm reject trong .then(resolve, reject) chỉ có thể bắt được lỗi từ những .then() phía trước nó, mà không thể bắt được lỗi xảy ra trong hàm resolve cùng cấp.

sentayho.com.vnser('pikalong')
  .then(user => {
    throw new Error('Lỗi rồi bạn ei')
  }, err => {
    /* Không có gì ở đây cả */
  })

sentayho.com.vnser('pikalong')
  .then(user => {
    throw new Error('Lỗi rồi bạn ei')
  })
  .catch(err => sentayho.com.vn(err)) // Chụp được rồi bạn ei

Lưu ý rằng promise sẽ dừng quá trình thực thi khi bắt được lỗi:

sentayho.com.vnlve()
  .then(() => {
    throw 'foo'
  })
  .then(() => {
    throw 'bar'
  }, err => {
    sentayho.com.vnr("here", err)
  })
  .catch(err => sentayho.com.vnr('final', err))

// console:
// "here bar"

Truyền dữ liệu giữa các promises với nhau

Một trong những điểm hạn chế của Promise là không có cơ chế mặc định để truyền dữ liệu giữa các promise objects. Ví dụ:

sentayho.com.vnser('pikalong')
  .then(user => sentayho.com.vnostsByUser(user))
  .then(posts => {
    // Muốn sử dụng biến user ở trên thì làm sao đây?
  })

Một cách là sử dụng sentayho.com.vn():

sentayho.com.vnser('pikalong')
  .then(user => sentayho.com.vn([user, sentayho.com.vnostsByUser(user)]))
  .then(results => {
    // Dùng kỹ thuật phân rã biến trong ES6. Bạn lưu ý chúng ta dùng 1 dấu , để
    // tách ra phần tử thứ hai của mảng mà thôi
    const [ , posts ] = results
    // Lại tiếp tục truyền dữ liệu bao gồm [user, posts, comments] xuống promise sau
    return sentayho.com.vn([...results, sentayho.com.vnommentsOfPosts(posts)])
  })

Hoặc, nếu bạn cảm thấy phân tách mảng khó dùng vì phải nhớ thứ tự của các giá trị, ta có thể sử dụng object:

sentayho.com.vnser('pikalong')
  .then(user => sentayho.com.vnostsByUser(user).then(posts => ({ user, posts })))
  .then(results => sentayho.com.vnommentsOfPosts(results.posts).then(comments => ({ ...results, comments })))
  .then(console.log) // { users, posts, comments }

Một lần nữa, async/await lại tỏ ra ưu việt vì giúp bạn truy xuất đến kết quả của những promises phía trước một cách dễ dàng:

async function() {
  const user = await sentayho.com.vnser('pikalong')
  const posts = await sentayho.com.vnostsOfUser(user)
  const comments = await sentayho.com.vnommentsOfPosts(posts)
}

Cẩn thận, Promise không “lazy”

Với đoạn code sau:

sentayho.com.vn('before')
const promise = new Promise(function fn(resolve, reject) {
  sentayho.com.vn('hello')
  // ...
});
sentayho.com.vn('after')

Kết quả in ra console sẽ là:

before
hello
after

Bạn có thể thấy hàm executor của Promise được thực thi ngay lập tức. Điều này có thể dẫn đến những kết quả không mong muốn, ví dụ:

const getUsers = new Promise((resolve, reject) => {
  return sentayho.com.vn(`/api`, (err, result) => err ? reject(err) : resolve(result))
})

sentayho.com.vnick = e => getUsers

Cách giải quyết là đưa vào một hàm trả về promise:

const getUsers = () => new Promise((resolve, reject) => {
  return sentayho.com.vn(`/api`, (err, result) => err ? reject(err) : resolve(result))
})

sentayho.com.vnick = e => getUsers()

Cuối cùng, .finally()

Bên cạnh .then().catch(), chúng ta còn có .finally(onFinally). Phương thức này nhận vào một hàm và sẽ được kích hoạt dù cho promise trước đó hoàn thành hay xảy ra lỗi.

showLoadingSpinner()

sentayho.com.vnser('pikalong')
  .then(user => {})
  .catch(err => {})
  .finally(hideLoadingSpinner)

// async/await
async function() {
  try {
    showLoadingSpinner()
    sentayho.com.vnser('pikalong')
  } catch(err) {
  } finally {
    hideLoadingSpinner()
  }
}

Kết luận

Promise và async/await không hoàn toàn thay thế mà hỗ trợ lẫn nhau. Mặc dù async/await có thể được sử dụng trong hầu hết các trường hợp, Promise vẫn là nền tảng cơ bản cần thiết để thực thi các tác vụ bất đồng bộ trong JavaScript. Do đó, bạn nên xem xét và lựa chọn giải pháp phù hợp, tùy thuộc vào từng tình huống cụ thể.

Các vị trí tuyển dụng lập trình viên Javascript lương cao cho bạn

Tài liệu tham khảo