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

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.

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

Xin chào, tôi tên là Hoài - một anh Dev kể chuyện bằng cách viết ✍️ và làm sản phẩm 🚀. Với nhiều năm kinh nghiệm lập trình, tôi đã đóng góp một phần công sức cho nhiều sản phẩm mang lại giá trị cho người dùng tại nơi đang làm việc, cũng như cho chính bản thân. Sở thích của tôi là đọc, viết, nghiên cứu... Tôi tạo ra trang Blog này với sứ mệnh mang đến những bài viết chất lượng cho độc giả của 2coffee.dev.Hãy theo dõi tôi qua các kênh LinkedIn, Facebook, Instagram, Telegram.

Bạn thấy bài viết này có ích?
Không

Bình luận (0)