Chào mừng bạn đến với Cafedev! Trong bài viết này, chúng ta sẽ khám phá một kỹ thuật quan trọng trong JavaScript: chuỗi promises. Đây là một công cụ mạnh mẽ giúp xử lý các thao tác bất đồng bộ một cách mạch lạc và dễ dàng mở rộng. Từ cơ bản đến nâng cao, Cafedev sẽ hướng dẫn bạn cách sử dụng chuỗi promises để viết mã hiệu quả hơn, giúp mã nguồn của bạn trở nên rõ ràng và dễ bảo trì. Hãy cùng bắt đầu hành trình học tập này nhé!

Chuỗi Promises

Hãy quay lại vấn đề đã đề cập trong chương : chúng ta có một chuỗi các tác vụ bất đồng bộ cần được thực hiện lần lượt — ví dụ, tải các script. Làm thế nào để chúng ta mã hóa nó một cách tốt nhất?
Promises cung cấp một số cách để thực hiện điều đó.

Trong chương này, chúng ta sẽ khám phá chuỗi promises.

Nó trông như thế này:


new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000); // (*)

}).then(function(result) { // (**)

  alert(result); // 1
  return result * 2;

}).then(function(result) { // (***)

  alert(result); // 2
  return result * 2;

}).then(function(result) {

  alert(result); // 4
  return result * 2;

});

Ý tưởng là kết quả được truyền qua chuỗi các hàm xử lý .then.
Dòng chảy ở đây là:

  1. Promise ban đầu được giải quyết trong 1 giây (*),
  2. Sau đó hàm xử lý .then được gọi (**), và hàm này tạo ra một promise mới (được giải quyết với giá trị 2).
  3. Hàm then tiếp theo (***) nhận kết quả của hàm trước, xử lý nó (nhân đôi) và truyền nó cho hàm xử lý tiếp theo.
  4. …và cứ như vậy.

Khi kết quả được truyền qua chuỗi các hàm xử lý, chúng ta có thể thấy một chuỗi các cuộc gọi alert: 1 -> 2 -> 4.

Toàn bộ hoạt động vì mỗi cuộc gọi đến .then trả về một promise mới, để chúng ta có thể gọi .then tiếp theo trên nó.
Khi một hàm xử lý trả về một giá trị, nó trở thành kết quả của promise đó, vì vậy .then tiếp theo được gọi với giá trị đó.

Một lỗi phổ biến của người mới: về mặt kỹ thuật, chúng ta cũng có thể thêm nhiều .then vào một promise duy nhất. Điều này không phải là chuỗi.

Ví dụ:


let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve(1), 1000);
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

promise.then(function(result) {
  alert(result); // 1
  return result * 2;
});

Những gì chúng ta đã làm ở đây chỉ là thêm nhiều hàm xử lý vào một promise. Chúng không truyền kết quả cho nhau; thay vào đó, chúng xử lý độc lập.
Đây là hình ảnh (so sánh với chuỗi trên):


Tất cả các .then trên cùng một promise nhận cùng một kết quả — kết quả của promise đó. Vì vậy, trong đoạn mã trên, tất cả các alert đều hiển thị cùng một giá trị: 1.
Trong thực tế, chúng ta hiếm khi cần nhiều hàm xử lý cho một promise. Việc chuỗi hóa thường được sử dụng nhiều hơn.

1. Trả về promises

Một hàm xử lý, được sử dụng trong .then(handler), có thể tạo ra và trả về một promise.
Trong trường hợp đó, các hàm xử lý tiếp theo sẽ chờ cho promise này hoàn tất và sau đó nhận kết quả của nó.

Ví dụ:


new Promise(function(resolve, reject) {

  setTimeout(() => resolve(1), 1000);

}).then(function(result) {

  alert(result); // 1

*!*
  return new Promise((resolve, reject) => { // (*)
    setTimeout(() => resolve(result * 2), 1000);
  });
*/!*

}).then(function(result) { // (**)

  alert(result); // 2

  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result * 2), 1000);
  });

}).then(function(result) {

  alert(result); // 4

});

đây, .then đầu tiên hiển thị 1 và trả về new Promise(…) ở dòng (*). Sau một giây, promise này được giải quyết, và kết quả (tham số của resolve, ở đây là result * 2) được truyền cho hàm xử lý của .then thứ hai. Hàm xử lý đó ở dòng (**), nó hiển thị 2 và thực hiện hành động tương tự.
Vì vậy, đầu ra là như trong ví dụ trước: 1 -> 2 -> 4, nhưng bây giờ với độ trễ 1 giây giữa các cuộc gọi alert.

Việc trả về promises cho phép chúng ta xây dựng các chuỗi các hành động bất đồng bộ.

2. Ví dụ: loadScript

Hãy sử dụng tính năng này với loadScript đã được chuyển đổi thành promise, định nghĩa trong chương trước, để tải các script lần lượt, theo chuỗi:


loadScript("/article/promise-chaining/one.js")
  .then(function(script) {
    return loadScript("/article/promise-chaining/two.js");
  })
  .then(function(script) {
    return loadScript("/article/promise-chaining/three.js");
  })
  .then(function(script) {
    // use functions declared in scripts
    // to show that they indeed loaded
    one();
    two();
    three();
  });

Đoạn mã này có thể được rút ngắn một chút bằng cách sử dụng các hàm mũi tên:


loadScript("/article/promise-chaining/one.js")
  .then(script => loadScript("/article/promise-chaining/two.js"))
  .then(script => loadScript("/article/promise-chaining/three.js"))
  .then(script => {
    // scripts are loaded, we can use functions declared there
    one();
    two();
    three();
  });

Ở đây, mỗi cuộc gọi loadScript trả về một promise, và .then tiếp theo chạy khi nó được giải quyết. Sau đó, nó khởi tạo việc tải script tiếp theo. Vì vậy, các script được tải lần lượt.
Chúng ta có thể thêm nhiều hành động bất đồng bộ vào chuỗi. Lưu ý rằng mã vẫn là “phẳng” — nó mở rộng xuống dưới, không phải sang bên phải. Không có dấu hiệu của “kim tự tháp tử thần”.

Về mặt kỹ thuật, chúng ta có thể thêm .then trực tiếp vào mỗi loadScript, như thế này:


loadScript("/article/promise-chaining/one.js").then(script1 => {
  loadScript("/article/promise-chaining/two.js").then(script2 => {
    loadScript("/article/promise-chaining/three.js").then(script3 => {
      // this function has access to variables script1, script2 and script3
      one();
      two();
      three();
    });
  });
});

Mã này thực hiện cùng một việc: tải 3 script theo chuỗi. Nhưng nó “mở rộng sang bên phải”. Vì vậy, chúng ta gặp phải vấn đề tương tự như với các callback.
Những người bắt đầu sử dụng promises đôi khi không biết về chaining, vì vậy họ viết theo cách này. Nói chung, chaining là phương pháp được ưu tiên hơn.

Đôi khi việc viết .then trực tiếp là hợp lý, vì hàm lồng ghép có quyền truy cập vào phạm vi ngoài. Trong ví dụ trên, callback lồng ghép nhất có quyền truy cập vào tất cả các biến script1, script2, script3. Nhưng đó là một ngoại lệ hơn là một quy tắc.

Để chính xác, một handler có thể trả về không chính xác là một promise, mà là một đối tượng “thenable” – một đối tượng tùy ý có phương thức .then. Nó sẽ được xử lý giống như một promise.

Ý tưởng là các thư viện bên thứ ba có thể triển khai các đối tượng “tương thích promise” của riêng chúng. Chúng có thể có một tập hợp các phương thức mở rộng, nhưng cũng tương thích với các promise gốc, vì chúng triển khai .then.

Đây là một ví dụ về đối tượng thenable:

run
class Thenable {
  constructor(num) {
    this.num = num;
  }
  then(resolve, reject) {
    alert(resolve); // function() { native code }
    // resolve with this.num*2 after the 1 second
    setTimeout(() => resolve(this.num * 2), 1000); // (**)
  }
}

new Promise(resolve => resolve(1))
  .then(result => {
*!*
    return new Thenable(result); // (*)
*/!*
  })
  .then(alert); // shows 2 after 1000ms

JavaScript kiểm tra đối tượng được trả về bởi handler .then ở dòng (*): nếu nó có một phương thức gọi được tên là then, thì nó gọi phương thức đó cung cấp các hàm gốc resolve, reject làm đối số (tương tự như một executor) và chờ cho đến khi một trong số đó được gọi. Trong ví dụ trên, resolve(2) được gọi sau 1 giây (**). Sau đó kết quả được truyền xuống chuỗi.
Tính năng này cho phép chúng ta tích hợp các đối tượng tùy chỉnh với chuỗi promise mà không cần phải kế thừa từ Promise.

3. Ví dụ lớn hơn: fetch

Trong lập trình frontend, promises thường được sử dụng cho các yêu cầu mạng. Vì vậy, hãy xem một ví dụ mở rộng về điều đó.
Chúng ta sẽ sử dụng phương thức fetch để tải thông tin về người dùng từ máy chủ từ xa. Nó có nhiều tham số tùy chọn được đề cập trong các chương riêng biệt, nhưng cú pháp cơ bản thì khá đơn giản:

let promise = fetch(url);

Điều này thực hiện một yêu cầu mạng đến url và trả về một promise. Promise được giải quyết với một đối tượng response khi máy chủ từ xa phản hồi với các header, nhưng trước khi toàn bộ phản hồi được tải xuống.

Để đọc toàn bộ phản hồi, chúng ta nên gọi phương thức response.text(): nó trả về một promise được giải quyết khi toàn bộ văn bản được tải xuống từ máy chủ từ xa, với văn bản đó là kết quả.

Đoạn mã dưới đây thực hiện yêu cầu đến user.json và tải văn bản của nó từ máy chủ:


fetch('/article/promise-chaining/user.json')
  // .then below runs when the remote server responds
  .then(function(response) {
    // response.text() returns a new promise that resolves with the full response text
    // when it loads
    return response.text();
  })
  .then(function(text) {
    // ...and here's the content of the remote file
    alert(text); // {"name": "iliakan", "isAdmin": true}
  });

Đối tượng response trả về từ fetch cũng bao gồm phương thức response.json() để đọc dữ liệu từ xa và phân tích nó dưới dạng JSON. Trong trường hợp của chúng ta, điều đó thậm chí còn tiện lợi hơn, vì vậy hãy chuyển sang sử dụng nó.
Chúng ta cũng sẽ sử dụng các hàm mũi tên để tiết kiệm không gian:


// same as above, but response.json() parses the remote content as JSON
fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => alert(user.name)); // iliakan, got user name

Giờ hãy làm gì đó với người dùng đã tải.
Ví dụ, chúng ta có thể thực hiện một yêu cầu nữa đến GitHub, tải hồ sơ người dùng và hiển thị ảnh đại diện:


// Make a request for user.json
fetch('/article/promise-chaining/user.json')
  // Load it as json
  .then(response => response.json())
  // Make a request to GitHub
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  // Load the response as json
  .then(response => response.json())
  // Show the avatar image (githubUser.avatar_url) for 3 seconds (maybe animate it)
  .then(githubUser => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => img.remove(), 3000); // (*)
  });

Đoạn mã hoạt động; xem các chú thích về chi tiết. Tuy nhiên, có một vấn đề tiềm ẩn trong đó, là lỗi phổ biến cho những người mới bắt đầu sử dụng promises.
Xem dòng (*): làm thế nào chúng ta có thể thực hiện một hành động sau khi ảnh đại diện đã hoàn tất việc hiển thị và bị xóa? Ví dụ, chúng ta muốn hiển thị một biểu mẫu để chỉnh sửa người dùng đó hoặc làm gì khác. Hiện tại, không có cách nào để làm điều đó.

Để làm cho chuỗi có thể mở rộng, chúng ta cần trả về một promise mà sẽ được giải quyết khi ảnh đại diện hoàn tất việc hiển thị.

Như sau:

run
fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
*!*
  .then(githubUser => new Promise(function(resolve, reject) { // (*)
*/!*
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
*!*
      resolve(githubUser); // (**)
*/!*
    }, 3000);
  }))
  // triggers after 3 seconds
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));

Tức là, trình xử lý .then ở dòng (*) giờ đây trả về new Promise, mà chỉ được giải quyết sau khi gọi resolve(githubUser) trong setTimeout (**). .then tiếp theo trong chuỗi sẽ chờ đợi điều đó.

Theo thực tiễn tốt, một hành động bất đồng bộ nên luôn trả về một promise. Điều này cho phép lập kế hoạch các hành động sau đó; ngay cả khi chúng ta không có kế hoạch mở rộng chuỗi ngay bây giờ, chúng ta có thể cần làm điều đó sau này.

Cuối cùng, chúng ta có thể tách mã thành các hàm có thể tái sử dụng:


function loadJson(url) {
  return fetch(url)
    .then(response => response.json());
}

function loadGithubUser(name) {
  return loadJson(`https://api.github.com/users/${name}`);
}

function showAvatar(githubUser) {
  return new Promise(function(resolve, reject) {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = "promise-avatar-example";
    document.body.append(img);

    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

// Use them:
loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => alert(`Finished showing ${githubUser.name}`));
  // ...

4. Tóm tắt

Nếu một trình xử lý .then (hoặc catch/finally, không quan trọng) trả về một promise, phần còn lại của chuỗi sẽ chờ cho đến khi nó được giải quyết. Khi nó được giải quyết, kết quả (hoặc lỗi) của nó sẽ được truyền tiếp.
Đây là bức tranh toàn cảnh:

Kết thúc hành trình học về Promise chaining, bạn đã nắm vững cách xử lý các tác vụ bất đồng bộ một cách hiệu quả trong JavaScript. Từ việc tạo ra các chuỗi Promise cho đến quản lý chúng, bạn có thể tạo ra mã nguồn rõ ràng và dễ bảo trì. Cafedev hy vọng rằng những kiến thức này sẽ giúp bạn xây dựng các ứng dụng web mạnh mẽ và linh hoạt. Hãy tiếp tục khám phá và áp dụng các kỹ thuật này trong dự án của bạn!

Tham khảo thêm: MIỄN PHÍ 100% | Series tự học Javascrypt chi tiết, dễ hiểu từ cơ bản tới nâng cao + Full Bài Tập thực hành nâng cao trình dev

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của CafeDev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!