Promise là gì? Cách sử dụng trong JavaScript và một vài lưu ý

1648

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 viết mã của mình.

Promise là gì?

Promise là một đối tượng đại diện cho một kết quả trả về trong tương lai. Hay nói cách khác, Promise đại diện cho một kết quả của hàm không đồng bộ.

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.
Promise là gì?
Promise là gì?

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 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, điều đó khiến cho Node.js cũng không có Promise trong những ngày đầu, và đó cũng chính là hối hận lớn nhất mà cha đẻ Node.js phải thốt ra.

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.

Ví dụ một hàm xử lý bất request bất đồng bộ đời đầu là XMLHttpRequest. Nó xử lý kết quả thông qua callback.

function reqListener() {
  console.log(this.responseText);
}

const req = new XMLHttpRequest();
req.addEventListener("load", reqListener);
req.open("GET", "https://example.com");
req.send();

reqListener là một hàm sẽ được gọi lại khi có kết quả của truy vấn đến https://example.com. Xử lý bất đồng bộ bằng callback mang đến một vài rắc rối, trong đó có thể kể đến “callback hell”.

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)
}
  Con đường trở thành thực tập sinh tại Google
  Tìm hiểu về nguyên lý "vàng" SOLID trong lập trình hướng đối tượng

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

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

Bạn có thể đọc thêm về Promise.prototype.finally() ở đây. Lưu ý là phương thức này hiện chỉ được hỗ trợ bởi Firefox, Chrome và Opera thôi nhé.

return và return await

Thi thoảng, bạn sẽ bắt gặp đoạn mã trông giống như thế này ở đâu đó.

async function fn() {
    …
    return await asyncFn();
}

Hàm fn trên đang trả về một await của hàm bất đồng bộ asyncFn. Có lẽ tác giả đang dụng ý fn trả về một kết quả của asyncFn luôn, vì await là đang chờ kết quả của hàm bất đồng bộ mà, từ đó biến fn thành hàm đồng bộ, hay nói cách khác là fn “không” trả về một Promise nữa.

Nhưng rất tiếc đó là một nhầm lẫn tai hại, về bản chất await chỉ được sử dụng ở top-level, hoặc trong một hàm có khai báo async, mà đã là async thì hàm đó chắc chắn trả về một Promise. Do đó fn luôn luôn trả về Promise. Vậy tại sao lại return await asyncFn()?

Chỉ cần return asyncFn() thôi có thể tiết kiệm được một chút thời gian gõ phím của bạn, nó không có khác biệt gì đáng kể so với return await asyncFn(). Thay đổi lớn chỉ xảy ra khi xuất hiện try…catch ở return.

Hãy xem xét hai hàm sau:

async function rejectionWithReturnAwait () {
  try {
    return await Promise.reject(new Error())
  } catch (e) {
    return 'Saved!'
  }
}

async function rejectionWithReturn () {
  try {
    return Promise.reject(new Error())
  } catch (e) {
    return 'Saved!'
  }
}

Hàm đầu tiên, rejectionWithReturnAwait đang cố return await và khi Promise này trả ra lỗi, catch nhanh chóng bắt lỗi đó và xử lý lệnh return 'Saved!'. Nghĩa là hàm này trả về một Promise chứa chuỗi ‘Saved’.

Ngược lại, rejectionWithReturn vì không có await nên hàm đang cố gắng đẩy ra một lỗi Promise.reject(new Error())catch lúc này không bao giờ được thực thi nữa.

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