Từ Callback, Promise đến Async/Await trong xử lý bất đồng bộ của JavaScript/Node.js

Từ Callback, Promise đến Async/Await trong xử lý bất đồng bộ của JavaScript/Node.js

Tin ngắn hàng ngày dành cho bạn
  • Từ lâu rồi suy nghĩ làm thế nào để tăng sự hiện diện thương hiệu, cũng như người dùng cho blog. Nghĩ đi nghĩ lại thì chỉ có cách chia sẻ lên mạng xã hội hoặc trông chờ họ tìm kiếm, cho đến khi...

    In cái áo này được cái tắc đường khỏi phải lăn tăn, càng đông càng vui vì hàng trăm con mắt nhìn thấy cơ mà 🤓

    (Có tác dụng thật nha 🤭)

    » Xem thêm
  • Một vòng của sự phát triển nhiều dự án khá là thú vị. Tóm tắt lại trong 3 bước: Thấy một cái gì đó phức tạp -> Làm cho nó đơn giản đi -> Thêm thắt tính năng cho đến khi nó phức tạp... -> Quay trở lại vòng lặp mới.

    Tại sao lại như vậy? Để mình lấy 2 ví dụ cho các bạn thấy.

    Markdown ra đời với mục tiêu tạo ra một định dạng văn bản thô "dễ viết, dễ đọc, dễ dàng chuyển thành một dạng gì đó như HTML". Vì thời đó chẳng ai đủ kiên nhẫn mà vừa ngồi viết vừa thêm định dạng cho văn bản hiển thị ở trên web như thế nào. Ấy vậy mà giờ đây người ta đang "nhồi nhét" hoặc tạo ra các biến thể dựa trên markdown để bổ sung thêm nhiều định dạng mới đến mức... chẳng nhớ nổi hết cú pháp.

    React cũng là một ví dụ. Từ thời PHP, việc khát khao tạo ra một cái gì đó tách biệt hẳn giao diện người dùng và phần xử lý logic chính của ứng dụng thành 2 phần riêng biệt cho dễ đọc, dễ viết. Kết quả là các thư viện UI/UX phát triển rất mạnh mẽ, mang lại khả năng tương tác với người dùng rất tốt, còn phần logic ứng dụng thì nằm ở một máy chủ riêng biệt. Bộ đôi Front-end, Back-end cũng từ đấy mà thịnh hành, không thể thiếu anh bồi bàn REST API. Ấy vậy mà giờ đây React trông cũng không khác biệt gì so với PHP là mấy, kéo theo là cả Vue, Svelte... lại cùng quy tất cả về một mối.

    Cơ mà không phải vòng lặp là xấu, ngược lại vòng lặp này mang tính tiến hoá nhiều hơn là "cải lùi". Nhiều khi lại tạo ra được cái hay hơi cái cũ thế là người ta lại dựa trên cái hay đó để tiếp tục lặp. Nói cách khác là chắc lọc tinh hoa từng tí một tí một á 😁

    » Xem thêm
  • Song song với các dự án chính thức thì thi thoảng mình vẫn thấy các dự án "bên lề" nhằm tối ưu hoặc cải tiến ngôn ngữ theo khía cạnh nào đó. Ví dụ nature-lang/nature là một dự án hướng tới cải tiến Go, mang lại một số thay đổi nhằm giúp cho việc sử dụng Go trở nên thân thiện hơn.

    Nhìn lại mới thấy hao hao JavaScript 😆

    » Xem thêm

Vấn đề

JavaScript đã xuất hiện lần đầu tiên cách đây gần 30 năm về trước, là một ngôn ngữ lập trình mới thời đó, chắc hẳn phải trải qua một thời gian nữa thì nó mới thực sự ổn định và được sử dụng rộng rãi. Cho đến bây giờ thì chúng ta không thể phủ nhận được sự thành công của JavaScript, nó xuất hiện ở khắp mọi nơi trong thế giới web. Hơn thế, nó còn "thoát" mình ra khỏi trình duyệt để làm được nhiều điều hơn nữa.

Một sản phẩm của công nghệ thì không thể nào ngừng phát triển. Trải qua rất nhiều năm, JavaScript liên tục lột xác và mang lại nhiều tính năng hữu ích hơn cũng như khắc phục nhiều nhược điểm vốn có của nó. TC39 là một nhóm được tạo ra trong hiệp hội ECMA với một nỗ lực nhằm "chuẩn hóa" JavaScript đồng thời chịu trách nhiệm phát triển nó ngày một lớn mạnh.

Chúng ta thường được biết đến cái tên JavaScript phổ biến hơn so với ECMAScript, trong khi thực tế ECMAScript là một đặc tả, và JavaScript là một thành phẩm thành công của đặc tả này. Phiên bản ổn định lần đầu tiên phải nói đến ECMAScript 2009 (ES5, ES2009) được phát hành vào 2009, mang đến nhiều tính năng góp phần khiến cho nó được phổ biến rộng rãi đến bây giờ. Nhưng sự thay đổi lớn nhất phải nói đến năm 2015, sau khi ES6 được giới thiệu (ES2015) với nhiều cải tiến rất đáng kể. Từ đó cứ sau mỗi một năm, TC39 sẽ cố gắng phát hành một phiên bản ECMAScript tiếp theo để cập nhật thêm tính năng mới cho ngôn ngữ này. Tính đến hiện tại, chúng ta đã có phiên bản mới nhất là ECMAScript 2022 (ES13).

Quay trở lại với JavaScript, môt trong những đặc trưng nổi bật nhất của nó là khả năng xử lý bất đồng bộ, có nghĩa là làm việc với nó không thể không biết xử lý bất đồng bộ. Tuy vậy, trong khoảng thời gian đầu ra mắt, việc xử lý bất đồng bộ có nhiều phần bất tiện. Nắm bắt được điều đó, TC39 đã qua nhiều bản cập nhật để mang đến khả năng xử lý sao cho tối ưu nhất.

Callback, Promise, Async/Await lần lượt là những cách mà TC39 giới thiệu khả năng xử lý bất đồng bộ. Trong bài viết này chúng ta hãy tìm hiểu xem quá trình đó diễn ra như thế nào và có đáng giá hay không nhé!

Bất đồng bộ

Node.js chỉ có một luồng chạy mã JavaScript, điều đó có nghĩa là nó phải có cơ chế thực hiện các tác vụ I/O (vốn không tốn quá nhiều CPU mà phụ thuộc vào tốc độ ổ cứng hoặc mạng...) mà không chặn luồng chính, bằng việc áp dụng xử lý I/O không đồng bộ. Mô hình này cho phép chúng ta thực hiện các tác vụ không đồng bộ mà không cần chờ đợi kết quả trước khi tiếp tục thực hiện các tác vụ khác.

Mô hình I/O không đồng bộ của Node.js mang lại nhiều lợi ích. Đầu tiên, nó tăng cường hiệu suất và sự phản hồi của ứng dụng, đặc biệt là trong các ứng dụng mạng như REST API. Thay vì phải chờ đợi các tác vụ I/O hoàn thành, chúng ta có thể tiếp tục thực hiện các tác vụ khác.

Để hiểu thêm về bất đồng bộ, bạn đọc có thể tham khảo thêm các bài viết:

Vì kết quả sẽ được trả về ở một thời điểm nào đó khi gọi các hàm bất đồng bộ, thế nên phải có cách để xử lý chúng.

Callback

Callback là một hàm được truyền vào một hàm khác để nó được gọi lại sau khi một tác vụ bất đồng bộ hoàn thành. Callback là cách xử lý bất đồng bộ nguyên thủy nhất trong JavaScript. Ví dụ, đọc một tệp tin trong Node.js, chúng ta truyền một callback để xử lý kết quả đọc.

const fs = require('node:fs')

fs.readFile('readme.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});

Tham số thứ hai truyền vào trong readFile là một hàm có hai tham số errdata. Sau khi readFile có kết quả, nó sẽ gọi hàm đó với tham số data là dữ liệu lấy được hoặc err sẽ chứa lỗi nếu xảy ra. Cũng vì lý do đó mà cách xử lý bất đồng bộ này được gọi là callback, nghĩa là hàm sẽ được gọi lại sau khi có kết quả của hành vi I/O trả về.

Tuy callback là một cách tiếp cận phổ biến, nhưng nó có một số điểm yếu. Vấn đề lớn nhất là callback hell, khi mã trở nên lồng nhau và khó đọc. Điều này xảy ra khi chúng ta cần thực hiện nhiều tác vụ bất đồng bộ liên tiếp và sử dụng callback để xử lý kết quả của chúng.

asyncFunc1((err1, data1) => {
  asyncFunc2((err2, data2) => {
    asyncFunc3((err3, data3) => {
      asyncFunc4((err4, data4) => {
        // ...
      });
    });
  });
});

Promise

ES6 (còn được gọi là ES2015) đưa đến một tiến bộ đáng kể trong việc xử lý bất đồng bộ bằng việc giới thiệu Promise. Promise là một cơ chế mạnh mẽ cho phép chúng ta xử lý các tác vụ bất đồng bộ một cách dễ đọc và dễ hiểu hơn.

Thay vì sử dụng callback, chúng ta có thể sử dụng Promise để tạo ra một chuỗi các tác vụ bất đồng bộ và xử lý kết quả của chúng. Promise cung cấp các phương thức như thencatch để xử lý kết quả thành công và thất bại của một tác vụ.

Ví dụ về một hành vi đọc file nhưng không xử lý bất đồng bộ bằng callback nữa, thay vào đó là Promise:

const fs =  require('node:fs/promises');

fs.readFile("readme.md")
  .then((data) => {
    console.log(data);
  })
  .catch((err) => {
    console.error(err);
  }
);

Ngoài ra, cái hay của Promise là "chaining function", tức là chúng ta có thể "hứng" kết quả của các Promise trước thông quan then và xử lý nó liên tiếp. Nếu tạo ra được những hàm xử lý logic liên tục, chúng ta có thể khắc phục được "callback hell".

fs.readFile("readme.md")
    .then(handleFile1)
    .then(handleFile2)
    .then(handleFile3)
    ...
    .catch()

Lúc này, cộng đồng đón nhận Promise cũng đưa ra một cách mới để biến một hàm không hỗ trợ Promise thành hỗ trợ, dựa vào new Promise.

Ví dụ dưới đây chuyển hàm readFile ở đầu bài thành hàm Promise mà không sử dụng modules sẵn có của Node.

const fs = require('node:fs')

const readFilePromise = (file) => {
  return new Promise((resolve, reject) => {
    fs.readFile(file, (err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  });
}

readFilePromise('readme.md')
  .then((data) => {
    console.log(data);
  })
  .catch((err) => {
    console.log(err);
  }
);

Hàm readFilePromise được tạo ra gói lại hàm readFile vốn xử lý bằng callback. Thay vì trả về kết quả trực tiếp trong callback thì dùng hai hàm rejectresolve để trả về kết quả của Promise. Giờ đây chúng ta có thể sử dụng hàm readFilePromise với thencatch như một Promise.

Ngoài ra, Node.js cung cấp thư viện util.promisify để chuyển đổi các hàm xử lý bất đồng bộ từ callback thành Promise. Điều này giúp giảm bớt công việc và tăng tính tương thích khi sử dụng Promise.

const fs = require('node:fs')
const util = require('util')

const readFile = util.promisify(fs.readFile)

readFile("readme.md")
  .then((data) => {
    console.log(data);
  })
  .catch((err) => {
    console.error(err);
  }
);

Tuy nhiên, Promise cũng không hoàn hảo. Một trong những điểm yếu của Promise là quản lý các Promise trở nên khó khăn và dễ gây ra hiện tượng "callback hell" tương tự callback. Xử lý lỗi có phần khó khăn khi áp dụng "chaining function". Nhận ra điều đó, Async/Await tiếp tục được giới thiệu.

Async/Await

Async/Await là một trong những nỗ lực để viết mã bất đồng bộ như là đồng bộ. Với Async/Await, chúng ta có thể viết mã bất đồng bộ giống như mã đồng bộ thông qua việc sử dụng cú pháp đơn giản hơn và dễ hiểu hơn.

Async/Await dựa trên Promise để xử lý các tác vụ bất đồng bộ. Chúng ta có thể đánh dấu một hàm bằng từ khóa async, sau đó sử dụng từ khóa await bên trong hàm đó để đợi kết quả của một Promise.

Ví dụ, có thể viết một hàm bất đồng bộ để đọc một tệp tin như sau:

const fs =  require('node:fs/promises');

async function readFileAsync(filename) {
  try {
    const data = await fs.readFile(filename);
    console.log(data);
  } catch(err) {
    console.log(err);
  }
}

readFileAsync("readme.txt");

Async/Await cải thiện rõ rệt sự đọc và hiểu của mã, che giấu đi sự phức tạp của Promise, đồng thời giúp cho việc debug cũng trở nên dễ dàng hơn.

Tiếp xúc mạnh mẽ với JavaScript từ năm 2017, tôi được học cách xử lý bất đồng bộ bằng Promise và Async/Await nhiều hơn hẳn. Callback là một cú pháp hết sức lạ lẫm và khó hiểu bởi những bất tiện mà nó mang lại, khiến tôi phải nhiều lần đau đầu tại sao vẫn có người hay thư viện cung cấp cách xử lý bất đồng bộ bằng callback. Chỉ khi đào sâu vào quá khứ thì những nghi ngờ đó mới dần được sáng tỏ, callback đang dần được thay thế bằng Promise. Chính "cha đẻ" của Node.js cũng phải lên tiếng thừa nhận rằng việc không hỗ trợ Promise ngay từ đầu đã khiến Node.js lộn xộn như bây giờ.

Tổng kết

Callback, Promise và cuối cùng là Async/Await, JavaScript đã trải qua một sự tiến hóa đáng kể trong việc xử lý bất đồng bộ. Mô hình I/O không đồng bộ của Node.js đã mang lại nhiều lợi ích cho việc phát triển ứng dụng. Callback là cách tiếp cận nguyên thủy, trong khi Promise và Async/Await mang lại cú pháp đơn giản hơn và mã dễ đọc hơn. Mỗi phương pháp có ưu điểm và nhược điểm riêng, thế nên không nhất thiết phải dùng một trong số chúng.

Cao cấp
Hello

Tôi & khao khát "chơi chữ"

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên ngay!

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên ngay!

Xem tất cả

Đăng ký nhận thông báo bài viết mới

hoặc
* Bản tin tổng hợp được gửi mỗi 1-2 tuần, huỷ bất cứ lúc nào.

Bình luận (0)

Nội dung bình luận...