Bài 7.1: Promises và cách sử dụng trong JavaScript

Chào mừng bạn quay trở lại với chuỗi bài blog về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một khái niệm cực kỳ quan trọng trong JavaScript hiện đại, đặc biệt là khi làm việc với các tác vụ cần thời gian để hoàn thành: Promises.

Nếu bạn đã từng gặp khó khăn với các hàm callback lồng nhau chồng chéo tạo ra "callback hell", thì Promises chính là cứu tinh mà bạn đang tìm kiếm. Promises mang đến một cách tiếp cận có tổ chức và dễ quản lý hơn rất nhiều đối với mã bất đồng bộ (asynchronous code).

Bất Đồng Bộ Là Gì và Tại Sao Cần Promises?

Trong JavaScript, đặc biệt là trong môi trường trình duyệt hoặc Node.js, rất nhiều hoạt động diễn ra không đồng bộ (asynchronous). Điều này có nghĩa là chúng ta khởi tạo một tác vụ (ví dụ: tải dữ liệu từ mạng, đọc file, thiết lập bộ hẹn giờ) và sau đó không chờ nó hoàn thành ngay lập tức. Thay vào đó, chúng ta để chương trình tiếp tục chạy các tác vụ khác, và khi tác vụ bất đồng bộ kia hoàn thành, nó sẽ thông báo lại cho chúng ta biết kết quả.

Cách truyền thống để xử lý kết quả của tác vụ bất đồng bộ là sử dụng callback functions. Chúng ta truyền một hàm như một đối số, và hàm này sẽ được gọi khi tác vụ bất đồng bộ hoàn thành.

Tuy nhiên, khi có nhiều tác vụ bất đồng bộ phụ thuộc vào nhau hoặc cần thực hiện theo một trình tự nhất định, việc sử dụng callback lồng nhau có thể dẫn đến cấu trúc mã khó đọc, khó hiểu và khó bảo trì, hay còn gọi là "callback hell".

// Ví dụ callback hell (tưởng tượng)
getData(function(a) {
  processData(a, function(b) {
    saveData(b, function(c) {
      console.log("Successfully processed and saved data:", c);
    }, function(err) {
      console.error("Save error:", err);
    });
  }, function(err) {
    console.error("Process error:", err);
  });
}, function(err) {
  console.error("Get data error:", err);
});

Bạn thấy đấy, cấu trúc tam giác này nhanh chóng trở nên phức tạp. Đây chính là lúc Promises xuất hiện để giải quyết vấn đề này.

Promises Là Gì?

Một Promise trong JavaScript là một đối tượng đại diện cho kết quả cuối cùng của một thao tác bất đồng bộ -- kết quả này có thể có sẵn bây giờ, có thể có trong tương lai, hoặc không bao giờ có.

Hiểu đơn giản, Promise giống như một lời hứa:

  • Nó hứa sẽ hoàn thành một việc gì đó trong tương lai.
  • Khi hoàn thành, nó sẽ cho bạn biết kết quả: hoặc là thành công (fulfilled/resolved) và trả về một giá trị, hoặc là thất bại (rejected) và trả về lý do thất bại (một lỗi).

Một Promise luôn ở một trong ba trạng thái (state):

  1. pending: Trạng thái ban đầu, chưa hoàn thành cũng chưa thất bại.
  2. fulfilled (hay resolved): Thao tác đã hoàn thành thành công.
  3. rejected: Thao tác đã thất bại.

Quan trọng là: một Promise chỉ có thể chuyển từ trạng thái pending sang fulfilled hoặc rejected một lần duy nhất. Sau khi đã chuyển sang fulfilled hoặc rejected (gọi chung là settled), trạng thái của Promise sẽ không bao giờ thay đổi nữa.

Tạo Một Promise

Bạn có thể tạo một Promise mới bằng cách sử dụng hàm tạo new Promise(). Hàm tạo này nhận một hàm thực thi (executor function) làm đối số. Hàm thực thi này nhận hai đối số khác là resolvereject (thường là tên gọi thông convention):

const myPromise = new Promise((resolve, reject) => {
  // Bên trong đây là nơi thực hiện tác vụ bất đồng bộ
  // Sau khi tác vụ hoàn thành:
  // - Nếu thành công: gọi resolve(value)
  // - Nếu thất bại: gọi reject(reason)

  const success = true; // Giả định kết quả tác vụ

  if (success) {
    // Thành công, gọi resolve với giá trị kết quả
    setTimeout(() => { // Mô phỏng tác vụ mất thời gian
      resolve("Thao tác thành công!");
    }, 1000); // Hoàn thành sau 1 giây
  } else {
    // Thất bại, gọi reject với lý do lỗi
    setTimeout(() => { // Mô phỏng tác vụ mất thời gian
      reject("Thao tác thất bại!");
    }, 1000); // Thất bại sau 1 giây
  }
});
  • Hàm thực thi ((resolve, reject) => { ... }) chạy ngay lập tức khi new Promise được tạo.
  • resolve: Hàm này được gọi khi tác vụ bất đồng bộ thành công. Bạn truyền giá trị kết quả vào hàm resolve. Việc gọi resolve làm cho Promise chuyển sang trạng thái fulfilled.
  • reject: Hàm này được gọi khi tác vụ bất đồng bộ thất bại. Bạn truyền lý do lỗi (thường là một đối tượng Error) vào hàm reject. Việc gọi reject làm cho Promise chuyển sang trạng thái rejected.

Trong ví dụ trên, chúng ta sử dụng setTimeout để mô phỏng một tác vụ bất đồng bộ mất 1 giây. Tùy thuộc vào giá trị của biến success, Promise sẽ được resolve hoặc reject.

Sử Dụng (Consume) Một Promise

Sau khi có một Promise, làm thế nào để biết khi nào nó hoàn thành và xử lý kết quả? Chúng ta sử dụng các phương thức của Promise: .then(), .catch(), và .finally().

.then(): Xử lý khi Promise thành công

Phương thức .then() được sử dụng để đăng ký hàm callback sẽ được gọi khi Promise chuyển sang trạng thái fulfilled. Nó nhận một hàm làm đối số, hàm này sẽ nhận giá trị được truyền vào resolve().

myPromise.then((result) => {
  console.log("Promise resolved with:", result); // Output: "Promise resolved with: Thao tác thành công!"
});
  • .then() được gọi trên đối tượng myPromise.
  • Nếu myPromise được resolve, hàm callback (result) => { ... } sẽ được thực thi với result là giá trị bạn đã truyền vào resolve().

.then() cũng có thể nhận đối số thứ hai là một hàm callback để xử lý khi Promise bị reject. Tuy nhiên, cách này không được khuyến khích bằng việc sử dụng .catch().

.catch(): Xử lý khi Promise thất bại

Phương thức .catch() là cách chuẩn và rõ ràng nhất để xử lý khi Promise chuyển sang trạng thái rejected. Nó là cách viết tắt của .then(null, onRejected). .catch() nhận một hàm làm đối số, hàm này sẽ nhận lý do lỗi được truyền vào reject().

// Tạo một Promise bị reject để minh họa
const anotherPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject(new Error("Có gì đó sai sai rồi!"));
  }, 1000);
});

anotherPromise
  .then(result => {
    console.log("This won't be called:", result);
  })
  .catch(error => {
    console.error("Promise rejected with error:", error); // Output: "Promise rejected with error: Error: Có gì đó sai sai rồi!"
  });
  • Nếu anotherPromise bị reject, hàm callback trong .catch() sẽ được thực thi với error là lý do lỗi bạn đã truyền vào reject().
  • Hàm callback trong .then() không được gọi trong trường hợp này.
.finally(): Thực thi dù thành công hay thất bại

Phương thức .finally() được sử dụng để đăng ký một hàm callback sẽ được thực thi khi Promise settled (chuyển sang trạng thái fulfilled hoặc rejected). Nó hữu ích cho các tác vụ "dọn dẹp", chẳng hạn như ẩn một spinner loading, bất kể thao tác thành công hay thất bại.

const thirdPromise = new Promise((resolve, reject) => {
  const success = Math.random() > 0.5; // Ngẫu nhiên thành công hoặc thất bại
  setTimeout(() => {
    if (success) {
      resolve("Hoàn thành!");
    } else {
      reject("Thất bại!");
    }
  }, 1000);
});

thirdPromise
  .then(result => {
    console.log("Success handler:", result);
  })
  .catch(error => {
    console.error("Error handler:", error);
  })
  .finally(() => {
    console.log("Promise settled (finally handler)."); // Sẽ luôn chạy sau khi Promise hoàn thành
  });
  • Hàm callback trong .finally() không nhận bất kỳ đối số nào.
  • Nó luôn chạy sau khi .then() hoặc .catch() (hoặc cả hai nếu có logic phức tạp hơn) đã hoàn thành.

Chuỗi Promise (Promise Chaining)

Một trong những lợi ích lớn nhất của Promises so với callback là khả năng xây dựng chuỗi (chaining) các tác vụ bất đồng bộ một cách dễ đọc và dễ quản lý. Điều này có được là nhờ vào việc phương thức .then() (và .catch()) luôn trả về một Promise mới.

Khi bạn trả về một giá trị từ hàm callback trong .then(), giá trị đó sẽ trở thành kết quả của Promise mới được trả về bởi .then() đó. Promise .then() tiếp theo trong chuỗi sẽ nhận giá trị này làm đầu vào.

Khi bạn trả về một Promise khác từ hàm callback trong .then(), Promise mới được trả về bởi .then() đó sẽ chờ Promise bạn vừa trả về hoàn thành. Khi Promise đó hoàn thành (resolve hoặc reject), Promise tiếp theo trong chuỗi sẽ tiếp tục xử lý tương ứng.

Ví dụ về chuỗi Promise:

// Hàm mô phỏng tác vụ bất đồng bộ trả về Promise
function asyncTask(value) {
  console.log("Executing task with value:", value);
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const newValue = value * 2;
      console.log("Task completed, new value:", newValue);
      resolve(newValue); // Trả về giá trị cho Promise tiếp theo
    }, 500);
  });
}

// Bắt đầu chuỗi với một Promise đã được giải quyết (Promise.resolve) hoặc một Promise mới
Promise.resolve(5) // Bắt đầu với giá trị 5
  .then(asyncTask) // .then sẽ nhận 5, gọi asyncTask(5), chờ asyncTask trả về Promise
  .then(asyncTask) // .then này sẽ nhận kết quả từ asyncTask(5) (là 10), gọi asyncTask(10)
  .then(finalResult => { // .then này sẽ nhận kết quả từ asyncTask(10) (là 20)
    console.log("Final result:", finalResult); // Output: Final result: 20
  })
  .catch(error => {
    console.error("An error occurred in the chain:", error);
  });

// Output dự kiến theo thứ tự thời gian:
// Executing task with value: 5
// Task completed, new value: 10
// Executing task with value: 10
// Task completed, new value: 20
// Final result: 20
  • Promise.resolve(5) tạo ra một Promise đã ở trạng thái fulfilled với giá trị là 5.
  • .then(asyncTask) nhận giá trị 5, gọi hàm asyncTask với đối số 5. Hàm này trả về một Promise. .then đầu tiên chờ Promise này hoàn thành.
  • Khi Promise từ asyncTask(5) hoàn thành và resolve(10), .then thứ hai nhận giá trị 10, gọi asyncTask với đối số 10. Hàm này trả về một Promise khác. .then thứ hai chờ Promise này hoàn thành.
  • Khi Promise từ asyncTask(10) hoàn thành và resolve(20), .then cuối cùng nhận giá trị 20 và in ra kết quả cuối cùng.
  • Nếu bất kỳ Promise nào trong chuỗi bị reject, chuỗi sẽ bỏ qua các .then() còn lại và nhảy thẳng đến .catch() gần nhất trong chuỗi.

Xử Lý Lỗi Trong Chuỗi Promise

Việc xử lý lỗi với .catch() trong chuỗi Promise rất mạnh mẽ. Khi một Promise trong chuỗi bị rejected, hoặc khi một lỗi (exception) được ném ra trong bất kỳ hàm callback .then() nào, luồng xử lý sẽ ngay lập tức chuyển đến .catch() gần nhất trong chuỗi.

Promise.resolve("Start")
  .then(value => {
    console.log(value); // Output: Start
    return "First step completed";
  })
  .then(value => {
    console.log(value); // Output: First step completed
    throw new Error("Something went wrong in the second step!"); // Ném ra lỗi
    return "Second step completed"; // Dòng này sẽ không chạy
  })
  .then(value => {
    console.log(value); // Dòng này sẽ không chạy
    return "Third step completed";
  })
  .catch(error => {
    console.error("Caught an error:", error.message); // Output: Caught an error: Something went wrong in the second step!
    // Có thể xử lý lỗi ở đây, hoặc ném lỗi mới để catch tiếp theo xử lý
    // throw new Error("Handling failed too!");
  })
  .then(() => {
    console.log("This .then runs after catch!"); // Output: This .then runs after catch! (nếu catch không ném lỗi mới)
  })
  .finally(() => {
    console.log("Cleanup complete."); // Output: Cleanup complete.
  });
  • Lỗi ném ra trong .then thứ hai ngay lập tức làm cho Promise được trả về bởi .then đó bị rejected.
  • Luồng xử lý bỏ qua .then thứ ba và nhảy đến .catch().
  • Hàm callback trong .catch() được thực thi với đối tượng lỗi.
  • Nếu hàm callback trong .catch() không ném ra lỗi mới, Promise được trả về bởi .catch() sẽ được resolved (với giá trị undefined nếu không trả về gì cụ thể), và các .then() sau .catch() vẫn sẽ chạy. Điều này cho phép bạn tiếp tục chuỗi xử lý sau khi đã "bắt" và xử lý lỗi.

Các Phương Thức Tiện Ích Của Promise

Đối tượng Promise toàn cục cung cấp một số phương thức tĩnh hữu ích để làm việc với nhiều Promises cùng lúc:

  • Promise.all(iterable): Trả về một Promise mới. Promise này sẽ resolve khi tất cả các Promises trong iterable (ví dụ: một mảng) đều resolve. Giá trị trả về là một mảng chứa kết quả của từng Promise theo đúng thứ tự. Nếu bất kỳ Promise nào trong iterable bị reject, Promise.all sẽ ngay lập tức reject với lý do lỗi của Promise đầu tiên bị reject.

    const p1 = Promise.resolve(3);
    const p2 = 1337; // Giá trị không phải Promise cũng được chấp nhận
    const p3 = new Promise((resolve, reject) => {
      setTimeout(() => resolve('foo'), 100);
    });
    
    Promise.all([p1, p2, p3])
      .then(values => {
        console.log(values); // Output: [3, 1337, "foo"]
      })
      .catch(error => {
        console.error("One of the promises failed:", error);
      });
    
  • Promise.race(iterable): Trả về một Promise mới. Promise này sẽ settle (tức là resolve hoặc reject) ngay khi Promise đầu tiên trong iterable settle. Kết quả hoặc lỗi sẽ là của Promise đầu tiên đó. Hữu ích khi bạn muốn thực hiện nhiều tác vụ và chỉ cần kết quả của tác vụ nào hoàn thành nhanh nhất.

    const p4 = new Promise((resolve, reject) => setTimeout(() => resolve('Quick'), 100));
    const p5 = new Promise((resolve, reject) => setTimeout(() => reject('Slow Error'), 500));
    const p6 = new Promise((resolve, reject) => setTimeout(() => resolve('Slower'), 800));
    
    Promise.race([p4, p5, p6])
      .then(result => {
        console.log(result); // Output: "Quick" (p4 resolve trước)
      })
      .catch(error => {
        console.error(error); // Sẽ không chạy trong trường hợp này vì p4 resolve trước p5 reject
      });
    
  • Promise.any(iterable): Trả về một Promise mới. Promise này sẽ resolve ngay khi Promise đầu tiên trong iterable resolve. Nếu tất cả các Promises trong iterable đều bị reject, Promise.any sẽ reject với một AggregateError chứa tất cả các lý do lỗi.

    const p7 = new Promise((resolve, reject) => setTimeout(() => reject('Error A'), 200));
    const p8 = new Promise((resolve, reject) => setTimeout(() => resolve('Success B'), 100));
    const p9 = new Promise((resolve, reject) => setTimeout(() => resolve('Success C'), 300));
    
    Promise.any([p7, p8, p9])
      .then(result => {
        console.log(result); // Output: "Success B" (p8 resolve trước p9)
      })
      .catch(error => {
        console.error(error); // Sẽ chỉ chạy nếu TẤT CẢ reject
      });
    
  • Promise.allSettled(iterable): Trả về một Promise mới. Promise này sẽ resolve khi tất cả các Promises trong iterable đều đã settled (dù là fulfilled hay rejected). Giá trị trả về là một mảng các đối tượng, mỗi đối tượng mô tả kết quả của từng Promise (bao gồm trạng thái và giá trị/lý do).

    const p10 = Promise.resolve('Success!');
    const p11 = Promise.reject('Failure!');
    const p12 = new Promise(resolve => setTimeout(() => resolve('Delayed Success!'), 50));
    
    Promise.allSettled([p10, p11, p12])
      .then(results => {
        console.log(results);
        // Output:
        // [
        //   { status: 'fulfilled', value: 'Success!' },
        //   { status: 'rejected', reason: 'Failure!' },
        //   { status: 'fulfilled', value: 'Delayed Success!' }
        // ]
      });
    

    Promise.allSettled rất hữu ích khi bạn muốn biết kết quả của tất cả các tác vụ bất đồng bộ, ngay cả khi một số trong số chúng thất bại.

Promises và Async/Await

Bạn có thể đã nghe về asyncawait. Điều quan trọng cần nhớ là async/await chỉ là cú pháp "đường" (syntactic sugar) được xây dựng dựa trên Promises. Mọi hàm async đều trả về một Promise, và từ khóa await chỉ có thể được sử dụng bên trong một hàm async để tạm dừng việc thực thi cho đến khi một Promise được giải quyết.

Hiểu rõ Promises là nền tảng vững chắc để làm việc hiệu quả với async/await, công cụ giúp code bất đồng bộ của bạn trở nên giống code đồng bộ, dễ đọc và dễ viết hơn nữa. Chúng ta sẽ tìm hiểu sâu hơn về async/await trong các bài sau.

// Ví dụ fetch API với Promises
fetch('https://rickandmortyapi.com/api/character/1')
  .then(response => {
    if (!response.ok) { // Kiểm tra lỗi HTTP
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json(); // response.json() cũng trả về một Promise
  })
  .then(data => {
    console.log("Character name:", data.name);
  })
  .catch(error => {
    console.error("Fetch failed:", error);
  });

// Ví dụ fetch API với async/await (tương đương với code trên)
async function fetchCharacter() {
  try {
    const response = await fetch('https://rickandmortyapi.com/api/character/1');
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const data = await response.json();
    console.log("Character name (async/await):", data.name);
  } catch (error) {
    console.error("Fetch failed (async/await):", error);
  }
}
fetchCharacter();

So sánh hai ví dụ trên, bạn có thể thấy code sử dụng async/await trông gọn gàng và dễ hiểu hơn nhiều, đặc biệt là khi cần xử lý nhiều bước bất đồng bộ tuần tự. Tuy nhiên, cả hai đều dựa trên cơ chế Promise underlying.

Tại Sao Nên Sử Dụng Promises?

Tóm lại, Promises mang lại những lợi ích đáng kể so với việc chỉ sử dụng callback thuần túy:

  • Tránh "Callback Hell": Cấu trúc chuỗi .then() giúp làm phẳng cấu trúc mã, dễ đọc và quản lý hơn.
  • Xử lý lỗi tập trung: .catch() cung cấp một cơ chế mạnh mẽ để xử lý lỗi cho toàn bộ chuỗi Promise.
  • Khả năng compose (tổ hợp): Các phương thức như Promise.all(), Promise.race(),... giúp dễ dàng kết hợp nhiều tác vụ bất đồng bộ.
  • Trạng thái rõ ràng: Promise có các trạng thái được định nghĩa rõ ràng (pending, fulfilled, rejected) giúp theo dõi luồng xử lý.
  • Tương thích với async/await: Làm quen với Promises là bước đệm cần thiết để sử dụng async/await hiệu quả.

Comments

There are no comments at the moment.