Bài 7.3: Fetch API và AJAX

Chào mừng trở lại với chuỗi bài viết của chúng ta về lập trình Front-end hiện đại! Ở các bài trước, chúng ta đã xây dựng nên giao diện tĩnh bằng HTML và làm cho nó trở nên sống động hơn với CSS và JavaScript. Tuy nhiên, thế giới Web thực tế hiếm khi đứng yên. Các ứng dụng hiện đại cần tương tác với máy chủ để lấy dữ liệu mới, gửi thông tin người dùng, mà không cần phải tải lại toàn bộ trang. Đây chính là lúc sức mạnh của việc giao tiếp bất đồng bộ phát huy tác dụng, và AJAX cùng Fetch API là hai công cụ chủ chốt giúp chúng ta làm điều đó.

Bạn đã bao giờ thấy một trang web cập nhật nội dung mới mà không bị nháy trắng hay tải lại toàn bộ chưa? Đó chính là nhờ kỹ thuật này! Thay vì gửi một yêu cầu tới máy chủ, đợi máy chủ xử lý, gửi lại toàn bộ trang HTML mới và trình duyệt phải vẽ lại từ đầu, chúng ta chỉ cần gửi một yêu cầu nhỏ để lấy hoặc gửi dữ liệu, sau đó dùng JavaScript để cập nhật một phần của trang web. Điều này mang lại trải nghiệm người dùng nhanh hơn, mượt mà hơntiết kiệm băng thông đáng kể.

AJAX là gì? Hiểu về khái niệm Bất đồng bộ

Thuật ngữ AJAX (viết tắt của Asynchronous JavaScript and XML) thực chất không phải là một công nghệ duy nhất, mà là một tập hợp các kỹ thuật sử dụng nhiều công nghệ Web khác nhau để tạo ra các ứng dụng Web bất đồng bộ. Cốt lõi của AJAX là khả năng gửi yêu cầu và nhận phản hồi từ máy chủ một cách ngầm (trong nền), không làm gián đoạn hoạt động hiện tại của người dùng trên trang web.

Ban đầu, tên gọi có chữ "XML" vì XML là định dạng dữ liệu phổ biến để trao đổi thông tin. Tuy nhiên, ngày nay, JSON (JavaScript Object Notation) đã trở thành định dạng dữ liệu được ưa chuộng hơn rất nhiều do tính gọn nhẹ và dễ làm việc với JavaScript. Vì vậy, dù tên gọi là AJAX, chúng ta thường trao đổi dữ liệu dưới định dạng JSON.

Công cụ nguyên thủy và "linh hồn" ban đầu của AJAX trong JavaScript là đối tượng XMLHttpRequest (viết tắt là XHR).

Làm việc với XMLHttpRequest (Phiên bản "Cổ điển")

XMLHttpRequest là API gốc được sử dụng để thực hiện các yêu cầu HTTP bất đồng bộ trong trình duyệt. Mặc dù vẫn còn được hỗ trợ và đôi khi bạn vẫn có thể gặp trong các codebase cũ, cú pháp của nó khá dài dòng và dựa trên các callback để xử lý các sự kiện (như khi yêu cầu hoàn thành, khi có lỗi...).

Hãy xem một ví dụ đơn giản về việc lấy dữ liệu từ một API công khai bằng XMLHttpRequest:

// Example 1: The classic XMLHttpRequest
console.log('Đang gửi yêu cầu bằng XHR...');

const xhr = new XMLHttpRequest(); // 1. Tạo một đối tượng XMLHttpRequest

// 2. Cấu hình yêu cầu: Phương thức GET, URL, và true cho chế độ bất đồng bộ
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);

// 3. Định nghĩa hàm xử lý khi yêu cầu hoàn thành thành công
xhr.onload = function() {
  // Kiểm tra mã trạng thái HTTP (2xx là thành công)
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log('Yêu cầu XHR thành công!');
    // Dữ liệu nhận được là chuỗi JSON, cần parse nó thành đối tượng JavaScript
    const data = JSON.parse(xhr.responseText);
    console.log('Dữ liệu nhận được:', data);
  } else {
    // Xử lý các mã lỗi HTTP (ví dụ: 404 Not Found, 500 Internal Server Error)
    console.error('Yêu cầu XHR thất bại với mã trạng thái:', xhr.status);
    console.error('Nội dung lỗi:', xhr.statusText);
  }
};

// 4. Định nghĩa hàm xử lý khi có lỗi mạng
xhr.onerror = function() {
  console.error('Đã xảy ra lỗi mạng khi gửi yêu cầu XHR.');
};

// 5. Gửi yêu cầu
xhr.send();

console.log('Đã gọi lệnh gửi yêu cầu XHR (nhưng nó chạy bất đồng bộ)');

Giải thích code XHR:

  • Chúng ta tạo một đối tượng XMLHttpRequest.
  • Sử dụng phương thức open() để thiết lập loại yêu cầu (GET), URL đích và chế độ bất đồng bộ (true).
  • Gán các hàm xử lý cho các sự kiện: onload (khi yêu cầu hoàn thành, dù thành công hay thất bại về mặt HTTP) và onerror (khi có lỗi mạng).
  • Bên trong onload, chúng ta kiểm tra xhr.status để xác định yêu cầu có thực sự thành công ở cấp độ HTTP hay không (các mã 2xx).
  • xhr.responseText chứa dữ liệu phản hồi từ máy chủ dưới dạng chuỗi. Chúng ta dùng JSON.parse() để chuyển nó thành đối tượng JavaScript nếu biết chắc phản hồi là JSON.
  • Cuối cùng, xhr.send() thực thi yêu cầu. Do nó chạy bất đồng bộ, các lệnh console.log sau xhr.send() sẽ chạy trước khi nhận được phản hồi từ máy chủ.

Bạn có thể thấy, ngay cả với một yêu cầu đơn giản, code XHR cũng khá dài dòng và việc quản lý nhiều callback có thể trở nên phức tạp, đặc biệt khi cần thực hiện nhiều yêu cầu liên tiếp (dẫn đến tình trạng "callback hell").

Fetch API: Tương lai của việc gửi yêu cầu HTTP

Để khắc phục những hạn chế của XMLHttpRequest và cung cấp một API hiện đại hơn, dễ sử dụng hơn, dựa trên Promise, Fetch API đã ra đời và nhanh chóng trở thành chuẩn mực mới.

Fetch API cung cấp một giao diện mạnh mẽ và linh hoạt hơn nhiều để làm việc với các yêu cầu HTTP. Lợi thế lớn nhất của nó là việc sử dụng Promise, giúp chúng ta viết code bất đồng bộ mạch lạc và dễ đọc hơn, đặc biệt khi kết hợp với async/await.

Ví dụ cơ bản với Fetch (sử dụng .then())

Hãy tái tạo ví dụ lấy dữ liệu trên bằng Fetch API:

// Example 2: Basic Fetch API (using .then())
console.log('Đang gửi yêu cầu bằng Fetch...');

fetch('https://jsonplaceholder.typicode.com/todos/1') // 1. Gọi fetch với URL đích
  .then(response => {
    console.log('Đã nhận phản hồi từ Fetch.');
    // 2. Fetch chỉ reject Promise khi có lỗi mạng.
    // Các lỗi HTTP (như 404, 500) vẫn coi là thành công ở cấp độ Promise.
    // Cần kiểm tra thuộc tính .ok hoặc .status.
    if (!response.ok) {
      // Nếu có lỗi HTTP, ném ra một lỗi để chuyển xuống .catch()
      throw new Error(`Lỗi HTTP! Mã trạng thái: ${response.status}`);
    }
    // 3. Phản hồi.json() cũng trả về một Promise, parse body thành JSON
    return response.json();
  })
  .then(data => {
    // 4. Dữ liệu JSON đã được parse
    console.log('Yêu cầu Fetch thành công!');
    console.log('Dữ liệu nhận được:', data);
  })
  .catch(error => {
    // 5. Xử lý bất kỳ lỗi nào xảy ra trong quá trình fetch hoặc xử lý Promise
    console.error('Đã xảy ra lỗi khi fetch:', error);
  });

console.log('Đã gọi lệnh gửi yêu cầu Fetch (nhưng nó chạy bất đồng bộ)');

Giải thích code Fetch (.then()):

  • Gọi hàm fetch() với URL đích. Hàm này ngay lập tức trả về một Promise.
  • Chúng ta sử dụng .then() để xử lý khi Promise được resolve (thành công ở cấp độ mạng). .then() đầu tiên nhận đối tượng Response.
  • Điểm quan trọng khác biệt so với XHR: Fetch chỉ reject Promise khi có lỗi mạng (ví dụ: mất kết nối, CORS bị chặn). Nó không reject Promise khi máy chủ trả về các mã trạng thái lỗi HTTP như 404 (Not Found) hay 500 (Internal Server Error). Do đó, chúng ta phải tự kiểm tra thuộc tính response.ok (true nếu status code là 2xx) hoặc response.status để phát hiện lỗi HTTP. Nếu có lỗi, chúng ta chủ động dùng throw new Error() để đẩy lỗi xuống khối .catch().
  • Đối tượng Response có các phương thức bất đồng bộ khác nhau để lấy body của phản hồi theo định dạng mong muốn, ví dụ: response.json() (trả về Promise parse body thành JSON), response.text() (trả về Promise parse body thành chuỗi), response.blob(), v.v.
  • .then() thứ hai nhận dữ liệu đã được parse (trong trường hợp này là đối tượng JavaScript từ JSON).
  • .catch() bắt mọi lỗi xảy ra trong chuỗi Promise, bao gồm cả lỗi mạng ban đầu và các lỗi HTTP mà chúng ta chủ động ném ra.

Cú pháp này trông gọn gàng hơn nhiều so với XHR, đặc biệt là khi xử lý các phản hồi bất đồng bộ nhờ cấu trúc Promise.

Làm việc với Đối tượng Response

Đối tượng Response mà chúng ta nhận được trong .then() đầu tiên của Fetch chứa rất nhiều thông tin hữu ích về phản hồi từ máy chủ, không chỉ riêng phần dữ liệu (body). Một số thuộc tính và phương thức phổ biến bao gồm:

  • ok: Boolean, true nếu status nằm trong khoảng 200-299. Rất hữu ích để kiểm tra thành công.
  • status: Số, mã trạng thái HTTP (ví dụ: 200, 404, 500).
  • statusText: Chuỗi, mô tả ngắn gọn về mã trạng thái (ví dụ: "OK", "Not Found").
  • headers: Một đối tượng Headers chứa tất cả các header của phản hồi.
  • url: Chuỗi, URL cuối cùng mà yêu cầu đã được gửi tới (có thể khác với URL gốc nếu có redirect).
  • json(): Phương thức bất đồng bộ trả về Promise phân tích body thành JSON.
  • text(): Phương thức bất đồng bộ trả về Promise phân tích body thành chuỗi văn bản.
  • blob(): Phương thức bất đồng bộ trả về Promise phân tích body thành đối tượng Blob (thường dùng cho file nhị phân).
  • formData(): Phương thức bất đồng bộ trả về Promise phân tích body dưới dạng FormData.
  • arrayBuffer(): Phương thức bất đồng bộ trả về Promise phân tích body thành ArrayBuffer (thường dùng cho dữ liệu nhị phân thô).

Nhớ rằng các phương thức json(), text(), v.v., đều trả về Promise, nên bạn cần dùng .then() tiếp theo hoặc await để lấy được dữ liệu thực sự.

Fetch API với async/await

Cách làm việc với Promise trở nên cực kỳ dễ đọc và gọn gàng hơn nữa khi chúng ta sử dụng cú pháp async/await. Đây là cách hiện đại và được khuyến khích sử dụng nhất hiện nay.

// Example 3: Fetch API with async/await
console.log('Đang gửi yêu cầu bằng Fetch với async/await...');

async function fetchDataAsync() { // 1. Định nghĩa một hàm async
  try { // 2. Sử dụng try...catch để bắt lỗi
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1'); // 3. Dùng await để chờ Promise fetch resolved

    console.log('Đã nhận phản hồi từ Fetch (async/await).');

    if (!response.ok) { // 4. Vẫn kiểm tra lỗi HTTP
      throw new Error(`Lỗi HTTP! Mã trạng thái: ${response.status}`);
    }

    const data = await response.json(); // 5. Dùng await để chờ Promise response.json() resolved
    console.log('Yêu cầu Fetch thành công (async/await)!');
    console.log('Dữ liệu nhận được:', data);

  } catch (error) { // 6. Bắt lỗi (cả lỗi mạng và lỗi HTTP chúng ta ném ra)
    console.error('Đã xảy ra lỗi khi fetch (async/await):', error);
  }
}

fetchDataAsync(); // 7. Gọi hàm async
console.log('Đã gọi hàm fetchDataAsync (nhưng nó chạy bất đồng bộ)');

Giải thích code Fetch (async/await):

  • Chúng ta bọc code trong một hàm được đánh dấu bằng từ khóa async. Điều này cho phép sử dụng await bên trong hàm đó.
  • Khối try...catch là cách tiêu chuẩn để xử lý lỗi trong code async/await. Bất kỳ lỗi nào xảy ra (bao gồm cả việc ném lỗi bằng throw) sẽ được bắt bởi catch.
  • Từ khóa await chỉ có thể sử dụng bên trong hàm async. Nó tạm dừng việc thực thi của hàm async cho đến khi Promise mà nó đứng trước được resolve. Giá trị trả về của Promise sẽ được gán vào biến.
  • Chúng ta await fetch(url) để nhận về đối tượng Response.
  • Chúng ta vẫn cần kiểm tra response.ok hoặc response.status để xử lý các lỗi HTTP. Nếu có lỗi, chúng ta throw một Error mới, và lỗi này sẽ được bắt bởi khối catch.
  • Chúng ta await response.json() để chờ quá trình parse body thành JSON hoàn thành và gán kết quả vào biến data.
  • Nếu mọi thứ thành công, code tiếp tục chạy sau await response.json(). Nếu có lỗi ở bất kỳ bước nào, luồng điều khiển sẽ nhảy ngay đến khối catch.

Cách viết với async/await thường được coi là cách rõ ràng nhất, giúp code bất đồng bộ nhìn gần giống với code đồng bộ thông thường, rất dễ đọc và bảo trì.

Gửi dữ liệu với Fetch (Ví dụ POST request)

Fetch API không chỉ dùng để lấy dữ liệu (GET). Nó cũng rất mạnh mẽ để gửi dữ liệu đến máy chủ bằng các phương thức HTTP khác như POST, PUT, DELETE, v.v. Để làm điều này, chúng ta truyền thêm một đối tượng cấu hình (options object) làm tham số thứ hai cho hàm fetch().

Đối tượng cấu hình này có thể chứa nhiều thuộc tính như method, headers, body, mode, cache, v.v. Phổ biến nhất là method, headers, và body.

// Example 4: Fetch API POST request
console.log('Đang gửi yêu cầu POST bằng Fetch...');

async function postData() {
  const dataToSend = { // Dữ liệu chúng ta muốn gửi (dạng đối tượng JS)
    title: 'Bài viết mới từ Fetch API',
    body: 'Đây là nội dung của bài viết được gửi đi.',
    userId: 99,
  };

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', { // 1. URL đích (endpoint POST)
      method: 'POST', // 2. Phương thức HTTP
      headers: { // 3. Các Header của yêu cầu
        'Content-Type': 'application/json', // Quan trọng: Nói cho máy chủ biết chúng ta đang gửi JSON
        // Có thể thêm các header khác như 'Authorization', 'X-Requested-With', v.v.
      },
      body: JSON.stringify(dataToSend), // 4. Body của yêu cầu: Chuyển đối tượng JS thành chuỗi JSON
    });

    console.log('Đã nhận phản hồi từ yêu cầu POST.');

    if (!response.ok) {
      throw new Error(`Lỗi HTTP khi POST! Mã trạng thái: ${response.status}`);
    }

    const result = await response.json(); // 5. Máy chủ thường phản hồi lại với dữ liệu đã được tạo
    console.log('Yêu cầu POST thành công!');
    console.log('Dữ liệu máy chủ trả về:', result); // Thường chứa ID của resource vừa tạo

  } catch (error) {
    console.error('Đã xảy ra lỗi khi POST:', error);
  }
}

postData(); // Gọi hàm postData async

Giải thích code Fetch (POST):

  • Chúng ta chuẩn bị dữ liệu cần gửi trong một đối tượng JavaScript (dataToSend).
  • Gọi fetch() với URL đích và thêm đối tượng cấu hình { ... } làm tham số thứ hai.
  • method: 'POST' chỉ định đây là yêu cầu POST. Các phương thức khác có thể là 'PUT', 'DELETE', v.v. (mặc định là 'GET').
  • headers: Đây là một đối tượng chứa các header HTTP. Header 'Content-Type': 'application/json'cực kỳ quan trọng khi bạn gửi dữ liệu JSON, nó cho máy chủ biết cách xử lý body của yêu cầu.
  • body: Đây là dữ liệu thực tế bạn muốn gửi. Với Fetch, body phải là một chuỗi, Blob, BufferSource, FormData hoặc URLSearchParams. Vì chúng ta gửi dữ liệu JSON, chúng ta dùng JSON.stringify() để chuyển đối tượng JavaScript dataToSend thành một chuỗi JSON.
  • Các bước xử lý phản hồi và lỗi (await response.json(), kiểm tra response.ok, try...catch) tương tự như yêu cầu GET. Máy chủ khi nhận yêu cầu POST thành công thường sẽ trả về dữ liệu của resource vừa được tạo (ví dụ: bao gồm cả ID mới được gán).
Xử lý lỗi chuyên sâu hơn

Như đã nhắc, việc kiểm tra response.ok hoặc response.statusbắt buộc với Fetch để xử lý các lỗi HTTP. Bạn có thể mở rộng phần xử lý lỗi trong khối catch để cung cấp thông tin chi tiết hơn cho người dùng hoặc log lỗi đầy đủ hơn.

Ví dụ, bạn có thể cố gắng đọc body của phản hồi ngay cả khi có lỗi để xem máy chủ có trả về thông báo lỗi cụ thể nào không:

async function fetchDataWithErrorHandling() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/non-existent-resource'); // URL sẽ gây lỗi 404

    if (!response.ok) {
      console.error(`Lỗi HTTP! Mã trạng thái: ${response.status}`);
      // Thử đọc body của lỗi nếu có
      try {
        const errorBody = await response.text(); // Hoặc response.json() nếu bạn biết chắc format
        console.error('Nội dung lỗi từ máy chủ:', errorBody);
      } catch (bodyError) {
        console.error('Không thể đọc nội dung lỗi từ máy chủ.');
      }
      throw new Error(`Yêu cầu thất bại với mã trạng thái: ${response.status}`); // Ném lỗi để catch bên ngoài xử lý
    }

    const data = await response.json();
    console.log('Dữ liệu nhận được:', data);

  } catch (error) {
    console.error('Đã xảy ra lỗi khi fetch:', error);
  }
}

fetchDataWithErrorHandling();

Trong ví dụ này, nếu response.okfalse, chúng ta log mã trạng thái và cố gắng đọc body của phản hồi để xem có thông báo lỗi chi tiết từ máy chủ hay không trước khi ném lỗi chính ra ngoài.

So sánh ngắn gọn: XHR vs Fetch
Đặc điểm XMLHttpRequest (AJAX "cổ điển") Fetch API
API Dựa trên Event và Callbacks. Dựa trên Promise.
Cú pháp Dài dòng, phân tán (open, set headers, event listeners, send...). Ngắn gọn, dễ đọc hơn, đặc biệt với async/await.
Xử lý lỗi Sự kiện onerror cho lỗi mạng, kiểm tra status trong onload. Promise reject chỉ khi lỗi mạng; phải tự kiểm tra response.ok cho lỗi HTTP.
Body Truy cập qua responseText, responseXML. Truy cập qua các phương thức Promise-based: json(), text(), v.v.
Tính năng Ít linh hoạt hơn cho các yêu cầu phức tạp, không hỗ trợ streaming. Hỗ trợ tốt hơn cho các tính năng HTTP hiện đại, dễ làm việc với Streams.
Tương thích Rất tốt, được hỗ trợ rộng rãi trên mọi trình duyệt cũ/mới. Tốt trên các trình duyệt hiện đại; cần polyfill cho trình duyệt cũ hơn (hiếm khi cần thiết trong phát triển mới).

Trong phát triển Web hiện đại, Fetch API là lựa chọn được khuyến khích do cú pháp Promise-based dễ đọc, dễ bảo trì và khả năng xử lý mạnh mẽ hơn. Tuy nhiên, việc hiểu về AJAX và XMLHttpRequest vẫn quan trọng để nắm bắt lịch sử và nguyên lý hoạt động của việc giao tiếp bất đồng bộ trên Web.

Việc làm chủ Fetch API là một kỹ năng thiết yếu cho bất kỳ nhà phát triển Front-end nào ngày nay, giúp bạn xây dựng các ứng dụng động, responsive và có trải nghiệm người dùng tuyệt vời bằng cách tương tác hiệu quả với các API Backend.

Hãy thực hành với Fetch API trong các dự án của bạn. Thử lấy dữ liệu từ các API công khai, hiển thị chúng trên trang web, hoặc tạo các form gửi dữ liệu lên máy chủ. Đó là cách tốt nhất để nắm vững công cụ mạnh mẽ này!

Comments

There are no comments at the moment.