Promise JS là gì? Cách sử dụng Promise trong JavaScript

3359

Bài viết được sự cho phép của tác giả Tống Xuân Hoài

Sẽ thật là thiếu sót nếu không nói về Promise trong JavaScript. Thực tế, các bài viết về Promise đã có rất nhiều người viết, bạn đọc có thể tìm thấy chúng bằng Google hoặc thi thoảng lại bắt gặp trên một hội nhóm có liên quan đến lập trình nào đó. Nhưng vì Promise là một kiến thức quan trọng và mỗi người lại có cách truyền đạt khác nhau do vậy đội ngũ TopDev đã cho ra đời bài viết này để đi sâu vào Promise là gì? một vài lưu ý và cả những nhầm lẫn có thể có. Qua đó giúp cho bạn đọc hình dung và tránh được một số lỗi trong quá trình coding của mình.

Promise JS là gì?

Promise là một cơ chế trong JavaScript giúp bạn thực thi các tác vụ bất đồng bộ mà không rơi vào callback hell hay pyramid of doom, là tình trạng các hàm callback lồng vào nhau ở quá nhiều tầng.

Hay nói cách khác, Promise JavaScript là một đối tượng đại diện cho kết quả của một hoạt động bất đồng bộ (asynchronous) trong tương lai.

Cách hoạt động của Promise

Một Promise gồm có 3 trạng thái tương ứng với 3 kết quả của hàm bất đồng bộ.

  • pending là trạng thái ban đầu, đang chờ kết quả.
  • fulfilled là trạng thái xử lý thành công, có kết quả trả về.
  • rejected là trạng thái thất bại, có thể kèm theo lỗi chi tiết.

Cách hoạt động của Promise

Cú pháp tạo một Promise

Để tạo một Promise, bạn sử dụng đối tượng Promise với một hàm constructor (hàm khởi tạo). Hàm khởi tạo này nhận hai tham số là resolvereject, dùng để báo cáo khi Promise được giải quyết hoặc bị từ chối.

let myPromise = new Promise(function(resolve, reject) {
    // logic bất đồng bộ
    let success = true;
    
    if (success) {
        resolve("Thành công!"); // Giải quyết Promise nếu thành công
    } else {
        reject("Thất bại!"); // Từ chối Promise nếu có lỗi
    }
});

Ví dụ sử dụng Promise JS

Ví dụ chúng ta tạo ra một Promise, nhận vào một số x, nếu x chia hết cho 2 thì trả về trạng thái fulfilled và ngược lại.

function fn(x) {
  return new Promise((resolve, reject) => {
    if (x % 2 === 0) {
      resolve(true);
    } else {
      reject(new Error('x is not even'));
    }
  })
}

Về bản chất, Promise là đại diện cho kết quả trả về trong tương lai. Ở ví dụ trên, chúng ta hoàn toàn không cần thiết phải tạo ra một Promise để xử lý, vì tất cả hành động bên trong hàm fn đều là đồng bộ. Vậy thì như thế nào là bất đồng bộ cũng như nên tạo ra Promise khi nào?

>> Thế nào là hàm bất đồng bộ? Click xem ngay!

Lấy ví dụ về hành vi thực hiện một truy vấn GET đến địa chỉ https://example.com, lúc này việc xử lý không đơn thuần phụ thuộc vào tốc độ của CPU nữa mà phụ thuộc vào tốc độ mạng của bạn. Mạng càng nhanh, bạn sẽ nhanh chóng có kết quả và ngược lại. Trong JavaScript, chúng ta có hàm fetch để gửi request, và hàm này là bất đồng bộ, nó trả về một Promise.

fetch('https://example.com');

Để xử lý kết quả của Promise, chúng ta có then và catch tương ứng với hai trường hợp thành công và thất bại.

fetch('https://example.com')
    .then(res => console.log(res))
    .catch(err => console.log(err));

Nếu sau một thời gian xử lý, fetch có trạng thái fulfilled, hàm trong then sẽ được kích hoạt, ngược lại, fetch có trạng thái rejected, ngay lập tức hàm trong catch được thực thi.

Đôi khi bạn sẽ bắt gặp một vài câu hỏi kiểu như là dự đoán kết quả trả về của ví dụ sau:

console.log('1');

fetch('https://example.com')
    .then(res => console.log(res))
    .catch(err => console.log(err))
    .finally(() => console.log('3'));

console.log(‘2’);

Thực ra câu hỏi này nhằm kiểm tra kiến thức của bạn về hành vi bất đồng bộ trong JavaScript. Kết quả in ra là 1, 2, 3 chứ không phải là 1, 3, 2. Điều này xảy ra là do cơ chế xử lý bất đồng bộ, vì kết quả của hành vi bất đồng bộ sẽ được trả về trong tương lai cho nên JavaScript sẽ nghĩ rằng: “OK, hàm bất đồng bộ này chưa có kết quả ngay được, để đó đã, xử lý tiếp các lệnh bên dưới, khi nào hết sạch thì quay lại xem nó có kết quả chưa”

Trong Promise có một số hàm static hữu dụng trong nhiều trường hợp như: allallSettledany và race.

Promise.all nhận vào một mảng các Promise, nó cũng trả về một Promise và có trạng thái fulfilled khi tất cả Promise trong mảng đều thành công, ngược lại, nó có trạng thái rejected khi chỉ cần 1 Promise trong mảng bị thất bại.

Promise.allSettled cũng tương tự như Promise.all chỉ có điều nó luôn trả về tất cả kết quả của Promise trong mảng cho dù là thành công hay thất bại. Cả hai Promise.all và Promise.allSettled hữu ích trong trường hợp bạn cần chạy nhiều hàm bất đồng bộ ngay lập tức mà không quan trọng thứ tự kết quả.

Trong khi Promise.race trả về kết quả của Promise được giải quyết nhanh nhất trong mảng, không kể là thành công hay thất bại, thì Promise.any lại trả về kết quả thành công của Promise có trạng thái fulfilled đầu tiên trong mảng. race và any phù hợp trong các trường hợp bạn có nhiều Promise thực hiện hành động giống nhau và cần một trường hợp dự phòng giữa các kết quả đó.

Việc làm JavaScript Hồ Chí Minh dành cho bạn!

Promise JS thay thế “callback hell”

Thật ngạc nhiên khi biết rằng trước kia JS không có Promise, đúng vậy bạn không nghe nhầm đâu. Mọi tác vụ bất đồng bộ trước kia được xử lý qua callback, chúng ta cần xác định một hàm callback để xử lý kết quả trong tương lai.

Dưới đây là một callback hell điển hình.

api.getUser('pikalong', function(err, user) {
  if (err) throw err
  api.getPostsOfUser(user, function(err, posts) {
    if (err) throw err
    api.getCommentsOfPosts(posts, function(err, comments) {
      // vân vân và mây mây...
    })
  })
})

Ví dụ trên khi được viết lại bằng Promise sẽ là:

api.getUser('pikalong')
  .then(user => api.getPostsOfUser(user))
  .then(posts => api.getCommentsOfPosts(posts))
  .catch(err => { throw err })

Để tạo ra một promise object thì bạn dùng class Promise có sẵn trong trình duyệt như sau:

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ó hai tham số:

  • resolve là hàm sẽ được gọi khi promise hoàn thành
  • reject là hàm sẽ được gọi khi có lỗi xảy ra

Ví dụ một hàm bất đồng bộ fnA, nhận vào một hàm callback có chứa 2 tham số data đại diện cho kết quả khi thành công và err đại diện cho lỗi. Tương tự là các hàm fnBfnC… Như vậy nếu muốn kết hợp các hàm xử lý này với nhau thì chúng ta phải lồng chúng vào nhau.

fnA((data1, err1) => {
    fnB((data2, err2) => {
        fnC((data3, err3) => {
        ….  
        }
    }
}

Khi có quá nhiều callback được viết như vậy, mã của chúng ta sẽ trở thành “địa ngục gọi lại” theo đúng nghĩa đen, gây rối rắm và cản trở quá trình đọc hiểu.

Promise được giới thiệu để mang đến một cách tiếp cận mới trong quá trình xử lý bất đồng bộ. Chúng ta vẫn sẽ có callback nhưng hạn chế được “hell”, vì giờ đây mọi thứ được xử lý nối tiếp nhau thông qua then.

fnA()
    .then(data => fnB(data))
    .then(data => fnC(data))
    ...  
    .catch(err => console.log(err));

Ngay sau đó, rất nhiều hàm xử lý bất đồng bộ bằng callback trước đó được viết lại bằng cách sử dụng new Promise. Trong Node.js, chúng ta còn có hẳn một build-in modules để hỗ trợ việc chuyển đổi này là util.promisify. Callback đến bây giờ vẫn được hỗ trợ, tuy nhiên với nhiều lợi ích mà Promise mang lại, nhiều thư viện mới ra đời sử dụng Promise để xử lý bất đồng bộ theo mặc định.

Promise trong các vòng lặp

Có nhiều sai lầm đáng tiếc khi sử dụng Promise mà chưa hiểu rõ bản chất của nó, một trong số đó có thể kể đến như là xử lý bất đồng bộ tuần tự trong vòng lặp.

Giả sử bạn cần lặp qua 5 trang, gọi API phân trang từ 1 -> 5 và ghép dữ liệu trả về theo thứ tự vào trong một mảng.

const results = [];
for (let i = 1; i <= 5; i++) {
  fetch('https://example.com?page=' + i)
    .then(response => response.json())
    .then(data => results.push(data));
}

Đoạn mã trên tạo ra một vòng lặp lấy dữ liệu từ page 1 đến page 5, dữ liệu trả về được đẩy vào trong results. Thoạt nhìn kết quả sẽ là một mảng dữ liệu theo thứ tự từ trang đầu đến trang cuối, nhưng thực tế, mỗi lần chạy, bạn sẽ thấy dữ liệu trong results được sắp xếp không theo một quy luật nào cả.

Hãy nhớ lại fetch, trả về một Promise, đại diện cho một kết quả trong tương lai… Trong khi for đang cố gắng lặp qua nhanh nhất có thể, có thể xem rằng 5 lệnh fetch được gọi và bắt đầu chạy “gần như ngay lập tức”. Lúc này, tốc độ CPU không còn quá quan trọng nữa, tốc độ mạng mới là thứ quyết định đến lệnh fetch nào có kết quả đầu tiên, và ngay khi có, nó lập tức được push vào results, chính vì thế mà dữ liệu được thêm vào một cách ngẫu nhiên.

Để giải quyết, có rất nhiều cách. Ví dụ:

const results = [];
fetch("https://example.com?page=1")
  .then((response) => response.json())
  .then((data) => results.push(data))
  .then(() => fetch("https://example.com?page=2"))
  .then((response) => response.json())
  .then((data) => results.push(data));
  …

Thật điên rồ, chẳng ai lại đi viết như thế cả, ví dụ có đến 1000 page thì sẽ thế nào? Đùa thế chứ, ví dụ trên chỉ cố gắng làm sáng tỏ việc chờ request trước đó hoàn thành thì mới xử lý tiếp đến request tiếp theo như thế nào.

Bluebird là một thư viện xử lý Promise rất tốt, nó cung cấp nhiều hàm tiện ích giúp chúng ta làm việc dễ dàng hơn với các hàm bất đồng bộ.

Ví dụ trên viết lại bằng cách sử dụng hàm each của Bluebird.

const results = [];
Promise.each([1, 2, 3, 4, 5], (i) => {
  return fetch('https://example.com?page=' + i)
    .then(response => response.json())
    .then(data => results.push(data));
});

“Kim tự tháp” Promises

Một lỗi chúng ta hay mắc phải khi mới làm quen với Promise, đó là tạo ra “kim tự tháp” promises như thế này.

api.getUser('pikalong')
  .then(user => {
     api.getPostsOfUser(user)
       .then(posts => {
         api.getCommentsOfPosts(posts)
         .then(comments => {
           console.log(comments)
         })
         .catch(err => console.log(err))
       })
       .catch(err => console.log(err))
  })
  .catch(err => console.log(err))

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

api.getUser('pikalong')
  // Trả về một promise
  .then(user => api.getPostsOfUser(user))
  .then(posts => api.getCommentsOfPosts(posts))
  .catch(err => { throw err })

Theo chúng tôi, việc hiểu và sử dụng thành thạo tính liên kết là một trong những điểm QUAN TRỌNG NHẤT khi làm việc với Promise. Khi promise lồng vào nhau từ 2 tầng trở lên thì đã đến lúc bạn phải refactor lại rồi.

3 cách xử lý nhiều Promise cùng lúc

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

Trong trường hợp muốn chạy các promises một cách tuần tự như sơ đồ ở trên, bạn có thể dùng hàm Array.prototype.reduce .

[promise1, promise2, promise3].reduce(function(currentPromise, promise) {
  return currentPromise.then(promise)
}, Promise.resolve())

// Đoạn ở trên khi được viết dài dòng ra
Promise.resolve().then(promise1).then(promise2).then(promise3)

Async/await mang đến 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)
}

Chạy nhiều Promises cùng lúc với Promise.all()

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

const userIds = [1, 2, 3, 4]

// api.getUser() là hàm trả về promise
const users = []
for (let id of userIds) {
  api.getUser(id).then(user => ([...users, user]))
}

console.log(users) // [], oát-đờ-heo?

Lý do là vì khi promise chưa kịp resolve thì dòng console.log đã chạy rồi. Chúng ta có thể sửa bằng cách dùng Promise.all([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.

>> Xem thêm: Xử lý bất đồng bộ với Promise.all trong JavaScript

const userIds = [1, 2, 3, 4]

Promise.all(usersIds.map(api.getUser))
  .then(function(arrayOfResults) {
    const [user1, user2, user3, user4] = arrayOfResults
  })

Nếu dùng async/await thì…

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

Promise.race()

Ngoài hai kiểu chạy tuần tự và song song ở trên, chúng ta còn có Promise.race([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.

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

Các lưu ý khi sử dụng Promise

Các lưu ý khi sử dụng Promise JavaScript

nguồn ảnh: @tapasadhikary

Luôn đưa vào .then() một hàm

Bạn thử đoán xem đoạn code sau sẽ in ra gì?

Promise.resolve(1)
  .then(2)
  .then(console.log)

Câu trả lời là 1 đó. Phương thức .then đòi hỏi tham số của nó phải là một hàm. Nếu bạn đưa vào .then() một giá trị, nó sẽ bị bỏ qua, giải thích tại sao đoạn code trên hiển thị 1. Trường hợp tương tự:

Promise.resolve(1)
  .then(Promise.resolve(2))
  .then(console.log) // 1

Cách giải quyết:

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

Chúng ta sẽ được kết quả như ý.

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

Promise.resolve(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ể dùng tham chiếu hàm để đoạn code trên gọn hơn.

Promise.resolve(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) {
    this.user = user
  }

  getUsername() {
    return this.user.username
  }
}

const u = new User({ username: 'pikalong' })
Promise.resolve()
  .then(u.getUsername)
  .then(console.log)

Nhưng bạn lại nhận được lỗi sau:

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

Lý do là vì khi trong 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(). Bạn có thể xem giải thích chi tiết hơn ở đây.

Để giải quyết lỗi này, bạn có thể 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 this.user.username
  }
}

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

Xét hai đoạn mã sau:

api.getUser('pikalong')
  .then(user => {
    return api.getPostsByUser(user)
  })
  .then(console.log) // posts

api.getUser('pikalong')
  .then(user => {
    api.getPostsByUser(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ề (nguồn). 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) và .then(resolve).catch(reject)

Hàm reject trong .then(resolve, reject) chỉ có thể chụp đượ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.

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

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

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

Promise.resolve()
  .then(() => { throw 'foo' })
  .then(() => { throw 'bar' }, err => { console.error("here", err) })
  .catch(err => console.error('final', err))

// console:
// "here bar"

Promise không có cơ chế mặc định truyền dữ liệu

Một trong những yếu điểm của Promise là không có cơ chế mặc định để bạn truyền dữ liệu giữa các promise objects với nhau. Nghĩa là:

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

Một cách là dùng Promise.all().

api.getUser('pikalong')
  .then(user => Promise.all([user, api.getPostsByUser(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 Promise.all([...results, api.getCommentsOfPosts(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ị thì ta có thể dùng object như sau:

api.getUser('pikalong')
  .then(user => api.getPostsByUser(user).then(posts => ({ user, posts })))
  .then(results => api.getCommentsOfPosts(results.posts).then(comments => ({ ...results, comments })))
  .then(console.log) // { users, posts, comments }

Lại một lần nữa, async/await lại tỏa sáng vì giúp bạn truy xuất đến kết quả của những promises phía trước.

async function() {
  const user = await api.getUser('pikalong')
  const posts = await api.getPostsOfUser(user)
  const comments = await api.getCommentsOfPosts(posts)
}

Cẩn thận nha, Promise không lazy

Với đoạn code sau:

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

Kết quả được in ra console lần lượt 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, chẳng hạn như:

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

button.onclick = 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 http.get(`/api`, (err, result) =>  err ? reject(err) : resolve(result))
})

button.onclick = e => getUsers()

Cuối cùng, .finally()

Bên cạnh .then() và .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 nó hoàn thành hay xảy ra lỗi.

showLoadingSpinner()
api.getUser('pikalong')
  .then(user => {})
  .catch(err => {})
  .finally(hideLoadingSpinner)

// async/await
async function() {
  try {
    showLoadingSpinner()
    api.getUser('pikalong')
  } catch(err) {
  } finally {
    hideLoadingSpinner()
  }
}

Lưu ý là phương thức này hiện chỉ được hỗ trợ bởi Firefox, Chrome và Opera thôi nhé.

Trên đây, đội ngũ biên tập viên của TopDev đã cung cấp tất tần tật thông tin về Promise và cách sử dụng Promise trong JavaScript, hi vọng bài viết trên sẽ hữu ích với bạn.

Xem thêm:

Bài viết gốc được đăng tải tại 2coffee.dev

Tìm việc làm IT mới nhất trên TopDev