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

Những mẩu tin ngắn hàng ngày dành cho bạn
  • Hẳn là nhiều người ở đây đã nghe đến kiểu tấn công bảo mật Clickjacking rồi nhỉ. Kẻ tấn công thường nhúng một website (thường là mục tiêu) vào trong một iframe trên website của chúng, sau đó làm mờ hoặc ẩn nó đi rồi đặt vào vị trí các nút bấm trên web, ví dụ "Bấm vào để nhận quà". Đâu ai ngờ rằng phía trên nút bấm đó là một nút bấm khác trong iframe. Khá nguy hiểm!

    Nhưng trình duyệt đã có cách ngăn chặn kiểu tấn công này bằng các quy tắc như tiêu đề X-Frame-Options, frame-ancestors của CSP và SameSite: Lax/Strict của Cookies...

    Mới đây, đã xuất hiện thêm kiểu tấn công mới - "DoubleClickjacking" 😨. Đại ý là "hắn" lợi dụng hành động double click để lừa người dùng bấm vào một nút mà hắn muốn. Chi tiết hơn trong bài viết này: DoubleClickjacking: A New Era of UI Redressing.

    » Xem thêm
  • Mọi người đã nghe nói đến Jujutsu - jj - một dạng quản lý phiên bản cho mã nguồn (version control system) chưa? Có vẻ như nó đang nhận được nhiều sự quan tâm.

    Chờ xíu! Chẳng phải git đã quá tốt rồi sao? Thế thì chế ra thằng jj để làm gì nữa? Cũng hơi khó trả lời nhỉ? Mỗi công cụ sinh ra chắc chắn phải cải thiện hoặc khắc phục được nhược điểm của cái trước. Cho nên jj ắt hẳn phải làm được điều gì đó mà git chưa làm được nên mới nổi lên như vậy.

    Thật ra mình đã nghe nói đến jj từ vài tháng trước rồi, nhưng vào đọc thì toàn kiến thức cao siêu. Hoặc là đang mang nặng cái lối suy nghĩ của git vào trong đầu rồi nên chưa lĩnh hội ra được điều gì cả.

    Mình hay có kiểu cái gì đọc lần 1 mà không hiểu thì đọc tiếp lần 2, lần 2 không hiểu thì đọc tiếp lần 3... đến lần thứ n mà vẫn không hiểu thì bỏ. Cơ mà không phải là từ bỏ mà một thời gian sau đó quay lại đọc tiếp. Đến một lúc nào đó khả năng mình sẽ hiểu ra một ít vấn đề, thế mới tài 😆.

    Thì cái jj này có vẻ như nó đang mở ra được tính linh hoạt trong việc "cam kết" mã. Tưởng tượng bạn đang làm việc trên một dự án, đang ở nhánh này, muốn sang nhánh khác để sửa, nhưng mà lại đang viết dở ở nhánh này, thế là phải stash, rồi checkout, rồi commit, rồi merge hoặc rebase lại vào nhánh cũ... nhìn chung quá trình làm việc với git nghiêm ngặt đến mức cứng nhắc, cần nhiều thao tác để giải quyết một vấn đề, chưa kể cái cây commit (commit-tree) nữa thì ôi thôi, khỏi xem cho đỡ nhức mắt. Thế nên ông jj này đang làm cách nào đó để bạn khỏi cần phải quan tâm đến các nhánh luôn, sửa trực tiếp vào commit. Nghe ảo nhỉ 😂.

    Đấy mới lĩnh hội được đến đấy, hy vọng sau n lần đọc lại nữa mình sẽ viết được một bài chi tiết hơn về công cụ này.

    » Xem thêm
  • Gòi gòi tới công chiện gòi 🤤🤤🤤

    » 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...
Bấm hoặc cuộn mạnh để sang bài mới