/Promise là gì, cách cách dùng và một vài lưu ý!

Promise là gì, cách cách dùng và một vài lưu ý!

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ộ.

Vấn đề

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 cho nên tôi vẫn quyết định viết bài này.

Thời gian đầu mới học JavaScript, Promise là thứ gây nhầm lẫn nhiều nhất. Cứ ngỡ mình hiểu và biết cách sử dụng rồi nhưng trên thực tế, vẫn có nhiều cú trượt dài đau điếng rồi tự rút ra bài học cho mình. Tôi đọc nhiều bài nói về Promise cả tiếng Anh lẫn tiếng Việt, dần dần mọi thứ cứ như tích tiểu thành đại, giúp cho mình hiểu và sử dụng thế nào cho đúng đắn.

Bài viết này sẽ không đi sâu vào khái niệm của Promise mà chỉ đi qua 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

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ộ? Tôi có nhắc đến trong nhiều bài viết. Một số tác vụ I/O được cung cấp bởi hàm bất đồng bộ, vì chúng không thể trả về kết quả ngay lập tức mà bị phụ thuộc vào nhiều vào yếu tố bên ngoài như tốc độ phần cứng, tốc độ mạng… Nếu chờ các hành động này, có thể xảy ra một sự lãng phí thời gian hoặc gây ra cuộc tắc nghẽn nghiêm trọng.

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”. Bạn đọc có thể tham khảo thêm các bài viết về cơ chế xử lý bất đồng bộ tại Lập trình bất đồng bộ là gì? Tại sao JavaScript là ngôn ngữ lập trình bất đồng bộ?.

Trong Promise có một số hàm static hữu dụng trong nhiều trường hợp như: all, allSettled, any 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 fnB, fnC… 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));
});

Async/await – mảnh ghép còn thiếu của Promise?

Để tạo ra một Promise, chúng ta sử dung cú pháp new Promise, nhưng với async/await, đơn giản chỉ cần khai báo một hàm async.

async function fn(x) {
  if (x % 2 === 0) {
    return true;
  } else {
    throw new Error('x is not even');
  }
}

Trong khi Promise sử dụng then để xử lý kết quả trả về tại một thời điểm nào đó trong tương lai, thì với async/await chỉ đơn giản là sử dụng await.

const result = await fetch('https://example.com');
const resultJSON = await result.json();

Kể từ phiên bản Node.js 14.8, chúng ta có tính năng top-level await, tức là việc gọi await không bị giới hạn trong một hàm async nữa, mà có thể gọi được ở bên ngoài. Trước đó, để sử dụng await bắt buộc phải ở trong một hàm async.

async function getData() {
    const result = await fetch('https://example.com');
    const resultJSON = await result.json();
}

Trong khi Promise có .catch để xử lý lỗi, async/await bắt lỗi bằng try…catch.

async function getData() {
    try {
        const result = await fetch('https://example.com');
        const resultJSON = await result.json();
    } catch (err) {
        console.log(err);
    }
}

Có thể thấy async/await giúp chúng ta viết mã bất đồng bộ như đồng bộ, không có callback, cũng không cần then, chỉ cần chờ kết quả bằng await.

Tuy vậy, điều này cũng gây ra một số nhầm lẫn tai hại như quên từ quá await để chờ kết quả của hành vi bất đồng bộ hoặc “ngỡ” hàm bất đồng bộ là hàm đồng bộ.

async function getData() {
    try {
        const result = await fetch('https://example.com');
        const resultJSON = await result.json();
        return resultJSON;
    } catch (err) {
        console.log(err);
    }
}

function main() {
    const data = getData();
    console.log(data);
}

Về bản chất, getData trả về một Promise, cho nên chương trình trên sẽ hoạt động không chính xác nếu mục đích là lấy giá trị từ một cuộc gọi API. Để khắc phục, chúng ta cần sửa lại.

async function main() {
    const data = await getData();
    console.log(data);
}

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.

Bài gốc: 2coffee.dev