Ngoài SPA – các kiến trúc thay thế cho PWA của bạn

Cùng nói về... kiến trúc nhé?

Tôi sẽ trình bày một chủ đề quan trọng nhưng có thể bị hiểu lầm: Cấu trúc mà bạn sử dụng cho ứng dụng web và cụ thể là cách các quyết định về cấu trúc của bạn có tác dụng khi bạn xây dựng một ứng dụng web tiến bộ.

"Cấu trúc" nghe có vẻ mơ hồ và có thể không rõ ngay lý do tại sao điều này lại quan trọng. Một cách để suy nghĩ về cấu trúc là tự hỏi mình những câu hỏi sau: Khi người dùng truy cập vào một trang trên trang web của tôi, HTML nào sẽ được tải? Sau đó, nội dung gì sẽ được tải khi họ truy cập vào một trang khác?

Câu trả lời cho những câu hỏi đó không phải lúc nào cũng đơn giản và khi bạn bắt đầu suy nghĩ về các ứng dụng web tiến bộ, chúng có thể còn phức tạp hơn nữa. Vì vậy, mục tiêu của tôi là hướng dẫn bạn về một kiến trúc khả thi mà tôi thấy hiệu quả. Trong suốt bài viết này, tôi sẽ gắn nhãn những quyết định mà tôi đưa ra là "phương pháp tiếp cận của tôi" đối với việc xây dựng một ứng dụng web tiến bộ.

Bạn có thể thoải mái sử dụng phương pháp của tôi khi xây dựng PWA của riêng mình, nhưng đồng thời, luôn có các phương án thay thế hợp lệ khác. Tôi hy vọng rằng khi nhìn thấy tất cả các thành phần kết hợp với nhau sẽ truyền cảm hứng cho bạn và bạn sẽ cảm thấy có đủ năng lực để tuỳ chỉnh mô hình này cho phù hợp với nhu cầu của mình.

PWA Stack Overflow

Để cung cấp cho bài viết này, tôi đã tạo PWA PWA. Tôi dành nhiều thời gian đọc và đóng góp cho Stack Overflow và tôi muốn xây dựng một ứng dụng web có thể giúp dễ dàng duyệt qua các câu hỏi thường gặp về một chủ đề nhất định. API này được xây dựng dựa trên API Stack Exchange công khai. Đây là phần mềm nguồn mở và bạn có thể tìm hiểu thêm bằng cách truy cập vào dự án GitHub.

Ứng dụng nhiều trang (MPA)

Trước khi đi vào các chi tiết cụ thể, hãy cùng định nghĩa một số thuật ngữ và giải thích các phần của công nghệ cơ bản. Trước tiên, tôi sẽ tìm hiểu về "Ứng dụng nhiều trang" hay "MPA".

MPA là một cái tên lạ mắt của kiến trúc truyền thống được sử dụng kể từ khi bắt đầu web. Mỗi lần người dùng chuyển đến một URL mới, trình duyệt sẽ dần hiển thị HTML dành riêng cho trang đó. Bạn không nên cố gắng duy trì trạng thái của trang hoặc nội dung ở giữa các lần điều hướng. Mỗi lần bạn truy cập vào một trang mới, bạn sẽ bắt đầu lại từ đầu.

Điều này trái ngược với mô hình ứng dụng một trang (SPA) để xây dựng ứng dụng web, trong đó trình duyệt chạy mã JavaScript để cập nhật trang hiện có khi người dùng truy cập một mục mới. Cả SPA và MPA đều là các mô hình hợp lệ như nhau để sử dụng, nhưng đối với bài đăng này, tôi muốn tìm hiểu các khái niệm PWA trong bối cảnh của một ứng dụng nhiều trang.

Nhanh đáng tin cậy

Bạn đã nghe tôi (và vô số người khác) dùng cụm từ "ứng dụng web tiến bộ" hay PWA. Có thể bạn đã quen thuộc với một số tài liệu nền ở những nơi khác trên trang web này.

Bạn có thể coi PWA là một ứng dụng web cung cấp trải nghiệm người dùng hàng đầu và thực sự xuất hiện nổi bật trên màn hình chính của người dùng. Từ viết tắt "FIRE", là chữ viết tắt của Fast, Integrated, Reliable và Engaging, từ viết tắt "FIRE" tóm tắt tất cả các thuộc tính cần cân nhắc khi xây dựng một PWA.

Trong bài viết này, tôi sẽ tập trung vào một tập hợp con các thuộc tính đó: Nhanh chóngĐáng tin cậy.

Nhanh: Mặc dù "nhanh" mang ý nghĩa khác nhau tuỳ theo ngữ cảnh, nhưng tôi sẽ đề cập đến lợi ích về tốc độ của việc tải càng ít càng tốt từ mạng.

Đáng tin cậy: Nhưng tốc độ thô là chưa đủ. Để tạo cảm giác giống như một PWA, ứng dụng web của bạn phải đáng tin cậy. Trang này cần có đủ khả năng phục hồi để luôn tải nội dung, ngay cả khi chỉ là một trang lỗi được tuỳ chỉnh, bất kể trạng thái mạng là gì.

Nhanh chóng đáng tin cậy: Và cuối cùng, tôi sẽ diễn đạt lại định nghĩa PWA một chút và xem ý nghĩa của việc tạo ra một sản phẩm có tốc độ nhanh và đáng tin cậy. Sẽ không đủ tốt nếu chỉ nhanh và đáng tin cậy chỉ khi bạn đang sử dụng mạng có độ trễ thấp. Tốc độ nhanh một cách đáng tin cậy có nghĩa là tốc độ của ứng dụng web luôn ổn định, bất kể các điều kiện mạng cơ bản.

Bật công nghệ: Service Worker + API Bộ nhớ đệm

PWA đặt ra tiêu chuẩn cao về tốc độ và khả năng phục hồi. May mắn là nền tảng web cung cấp một số khối xây dựng để loại hiệu suất đó trở thành hiện thực. Tôi đang đề cập đến service workerCache Storage API.

Bạn có thể tạo một trình chạy dịch vụ để theo dõi các yêu cầu được gửi đến, truyền một số yêu cầu đến mạng và lưu trữ bản sao của phản hồi để sử dụng sau này, thông qua API Bộ nhớ bộ nhớ đệm.

Một trình chạy dịch vụ sử dụng API Bộ nhớ đệm để lưu bản sao của phản hồi mạng.

Lần tiếp theo ứng dụng web đưa ra yêu cầu tương tự, trình chạy dịch vụ của ứng dụng đó có thể kiểm tra bộ nhớ đệm và chỉ cần trả về phản hồi đã lưu vào bộ nhớ đệm trước đó.

Một trình chạy dịch vụ sử dụng API Bộ nhớ đệm để phản hồi, bỏ qua mạng.

Việc tránh mạng bất cứ khi nào có thể là một phần quan trọng để mang lại hiệu suất nhanh và đáng tin cậy.

JavaScript "định hình"

Một khái niệm khác mà tôi muốn đề cập là đôi khi chúng được gọi là JavaScript " đẳng hình" hoặc "phổ quát". Nói một cách đơn giản, ý tưởng là cùng một mã JavaScript có thể được chia sẻ giữa nhiều môi trường thời gian chạy. Khi tạo PWA, tôi muốn chia sẻ mã JavaScript giữa máy chủ phụ trợ và trình chạy dịch vụ.

Có nhiều phương pháp hợp lệ để chia sẻ mã theo cách này, nhưng cách tiếp cận của tôi là sử dụng các mô-đun ES làm mã nguồn chính thức. Sau đó, tôi đã sao chép và đóng gói các mô-đun đó cho máy chủ và trình chạy dịch vụ bằng cách sử dụng kết hợp BabelRollup. Trong dự án của tôi, các tệp có đuôi tệp .mjs là mã nằm trong mô-đun ES.

Máy chủ

Ghi nhớ các khái niệm và thuật ngữ đó, hãy cùng tìm hiểu cách tôi thực sự tạo PWA Stack Overflow. Tôi sẽ bắt đầu bằng việc trình bày về máy chủ phụ trợ và giải thích cách cấu trúc này phù hợp với cấu trúc tổng thể.

Tôi muốn tìm một sự kết hợp giữa phần phụ trợ động với tính năng lưu trữ tĩnh và phương pháp tiếp cận của tôi là sử dụng nền tảng Firebase.

Firebase Cloud Functions sẽ tự động tạo ra một môi trường dựa trên Nút khi có yêu cầu đến, đồng thời tích hợp với khung Express HTTP phổ biến mà tôi đã quen dùng. Trang này cũng cung cấp dịch vụ lưu trữ có sẵn cho tất cả tài nguyên tĩnh trên trang web của tôi. Hãy xem cách máy chủ xử lý các yêu cầu.

Khi trình duyệt đưa ra yêu cầu điều hướng đối với máy chủ của chúng tôi, trình duyệt đó sẽ trải qua quy trình sau:

Tổng quan về cách tạo phản hồi điều hướng ở phía máy chủ.

Máy chủ định tuyến yêu cầu dựa trên URL và sử dụng logic tạo mẫu để tạo tài liệu HTML hoàn chỉnh. Tôi sử dụng kết hợp dữ liệu từ Stack Exchange API, cũng như một phần các mảnh HTML mà máy chủ lưu trữ cục bộ. Khi trình chạy dịch vụ của chúng tôi biết cách phản hồi, nó có thể bắt đầu truyền HTML trở lại ứng dụng web.

Có hai phần của bức tranh này đáng tìm hiểu chi tiết hơn: định tuyến và tạo mẫu.

Đang định tuyến

Khi nói đến việc định tuyến, giải pháp của tôi là sử dụng cú pháp định tuyến gốc của khung Express. Bạn đủ linh hoạt để so khớp các tiền tố URL đơn giản, cũng như URL chứa các tham số trong đường dẫn. Ở đây, tôi sẽ tạo mối liên kết giữa các tên tuyến mà mẫu Express cơ bản để khớp.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

Sau đó, tôi có thể tham chiếu mục ánh xạ này trực tiếp từ mã của máy chủ. Khi có kết quả trùng khớp cho một mẫu Express nhất định, trình xử lý thích hợp sẽ phản hồi bằng logic tạo mẫu dành riêng cho tuyến trùng khớp.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

Tạo mẫu phía máy chủ

Logic tạo mẫu đó trông như thế nào? Vâng, tôi đã áp dụng phương pháp chắp mảnh HTML từng phần lại với nhau theo trình tự, lần lượt từng mảnh. Mô hình này phù hợp với hoạt động phát trực tuyến.

Máy chủ ngay lập tức gửi lại một số mẫu HTML nguyên mẫu ban đầu và trình duyệt có thể kết xuất một phần trang đó ngay lập tức. Khi phân tách các nguồn dữ liệu còn lại với nhau, máy chủ sẽ truyền trực tuyến các nguồn dữ liệu đó đến trình duyệt cho đến khi tài liệu hoàn tất.

Để hiểu ý của tôi, hãy xem Mã nhanh cho một trong các tuyến của chúng tôi:

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Bằng cách sử dụng phương thức write() của đối tượng response và tham chiếu các mẫu một phần được lưu trữ cục bộ, tôi có thể bắt đầu truyền trực tuyến phản hồi ngay lập tức mà không chặn bất kỳ nguồn dữ liệu bên ngoài nào. Trình duyệt sẽ lấy HTML ban đầu này và hiển thị một giao diện có ý nghĩa cũng như tải thông báo ngay lập tức.

Phần tiếp theo của trang này sử dụng dữ liệu từ API Stack Exchange. Khi nhận được dữ liệu đó, máy chủ của chúng ta cần gửi một yêu cầu mạng. Ứng dụng web không thể hiển thị bất kỳ nội dung nào khác cho đến khi nhận lại phản hồi và xử lý phản hồi đó, nhưng ít nhất người dùng không nhìn chăm chú vào màn hình trống trong khi chờ.

Sau khi nhận được phản hồi từ API Stack Exchange, ứng dụng web sẽ gọi một hàm tạo mẫu tuỳ chỉnh để dịch dữ liệu từ API sang HTML tương ứng.

Ngôn ngữ mẫu

Tạo mẫu có thể là một chủ đề gây tranh cãi đáng ngạc nhiên, và điều tôi đề cập chỉ là một cách tiếp cận trong số nhiều chủ đề. Bạn cần thay thế giải pháp của riêng mình, đặc biệt là nếu bạn có mối liên hệ cũ với một khung mẫu hiện có.

Điều hợp lý đối với trường hợp sử dụng của tôi là chỉ dựa vào giá trị cố định của mẫu của JavaScript, với một số logic được chia thành các hàm trợ giúp. Một trong những điều thú vị về việc xây dựng MPA là bạn không phải theo dõi thông tin cập nhật trạng thái và kết xuất lại HTML, vì vậy, phương pháp cơ bản giúp tạo HTML tĩnh đã hiệu quả đối với tôi.

Vì vậy, đây là ví dụ về cách tôi tạo mẫu cho phần HTML động của chỉ mục ứng dụng web. Tương tự như với các tuyến của tôi, logic tạo mẫu được lưu trữ trong mô-đun ES có thể nhập vào cả máy chủ và trình chạy dịch vụ.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

Các hàm mẫu này là JavaScript thuần tuý và bạn nên chia nhỏ logic thành các hàm trợ giúp nhỏ hơn khi thích hợp. Ở đây, tôi chuyển từng mục được trả về trong phản hồi của API vào một hàm như vậy. Hàm này sẽ tạo một phần tử HTML chuẩn với mọi tập hợp thuộc tính thích hợp.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

Đặc biệt, là một thuộc tính dữ liệu mà tôi thêm vào mỗi đường liên kết (data-cache-url) được đặt thành URL của API Stack Exchange mà tôi cần để cho thấy câu hỏi tương ứng. Hãy lưu ý đến điều đó. Tôi sẽ xem lại sau.

Quay lại trình xử lý định tuyến của tôi, sau khi mẫu hoàn tất, tôi truyền phần cuối cùng của HTML trong trang đến trình duyệt và kết thúc luồng. Đây là tín hiệu cho trình duyệt biết rằng quá trình kết xuất tăng dần đã hoàn tất.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Đó là hướng dẫn ngắn gọn về cách thiết lập máy chủ của tôi. Người dùng lần đầu truy cập vào ứng dụng web của tôi sẽ luôn nhận được phản hồi từ máy chủ, nhưng khi khách truy cập quay lại ứng dụng web của tôi, service worker sẽ bắt đầu phản hồi. Hãy cùng tìm hiểu sâu hơn.

Trình chạy dịch vụ

Tổng quan về cách tạo phản hồi điều hướng trong trình chạy dịch vụ.

Sơ đồ này trông sẽ quen thuộc – nhiều phần tương tự mà tôi đã đề cập trước đây được sắp xếp ở đây nhưng hơi khác một chút. Hãy cùng xem qua quy trình yêu cầu, có tính đến trình chạy dịch vụ.

Trình chạy dịch vụ của chúng tôi xử lý yêu cầu điều hướng đến cho một URL nhất định và giống như máy chủ của tôi, trình chạy dịch vụ này sử dụng kết hợp logic định tuyến và logic tạo mẫu để tìm ra cách phản hồi.

Phương pháp này giống như trước, nhưng với các dữ liệu gốc cấp thấp khác, chẳng hạn như fetch()Cache Storage API (API Bộ nhớ bộ nhớ đệm). Tôi sử dụng các nguồn dữ liệu đó để tạo phản hồi HTML mà trình chạy dịch vụ sẽ trả về cho ứng dụng web.

Workbox

Thay vì bắt đầu từ đầu với các dữ liệu gốc cấp thấp, tôi sẽ xây dựng trình chạy dịch vụ trên một tập hợp thư viện cấp cao có tên là Workbox (Hộp công việc). Nền tảng này cung cấp nền tảng vững chắc cho logic tạo phản hồi, định tuyến và lưu vào bộ nhớ đệm của mọi trình chạy dịch vụ.

Đang định tuyến

Cũng giống như mã phía máy chủ, trình chạy dịch vụ của tôi cần biết cách so khớp yêu cầu đến với logic phản hồi thích hợp.

Phương pháp của tôi là dịch từng tuyến Express thành một biểu thức chính quy tương ứng, tận dụng một thư viện hữu ích có tên là regexparam. Sau khi thực hiện bản dịch đó, tôi có thể tận dụng tính năng hỗ trợ tích hợp sẵn của Workbox để định tuyến biểu thức chính quy.

Sau khi nhập mô-đun có biểu thức chính quy, tôi sẽ đăng ký từng biểu thức chính quy với bộ định tuyến của Workbox. Bên trong mỗi tuyến, tôi có thể cung cấp logic tạo mẫu tuỳ chỉnh để tạo phản hồi. Việc tạo mẫu trong trình chạy dịch vụ tốn nhiều công sức hơn so với trong máy chủ phụ trợ của tôi, nhưng Workbox sẽ giúp tôi thực hiện rất nhiều phần việc nặng nhọc.

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

Lưu tài sản tĩnh vào bộ nhớ đệm

Một phần quan trọng của quá trình tạo mẫu là đảm bảo rằng các mẫu HTML một phần của tôi có sẵn trên máy thông qua Cache Storage API và luôn được cập nhật khi tôi triển khai các thay đổi trên ứng dụng web. Việc bảo trì bộ nhớ đệm có thể dễ gặp lỗi khi thực hiện theo cách thủ công, vì vậy tôi chuyển sang Workbox để xử lý việc lưu trước vào bộ nhớ đệm trong quy trình xây dựng.

Tôi sẽ cho Workbox biết cần lưu những URL nào vào bộ nhớ đệm trước bằng cách sử dụng tệp cấu hình, trỏ đến thư mục chứa tất cả tài sản cục bộ của tôi cùng với một tập hợp mẫu cần khớp. CLI của Workbox sẽ tự động đọc tệp này. Quá trình này sẽ run mỗi khi tôi tạo lại trang web.

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox chụp nhanh nội dung của từng tệp và tự động chèn danh sách URL cũng như bản sửa đổi đó vào tệp trình chạy dịch vụ cuối cùng của tôi. Workbox nay có mọi thứ cần thiết để đảm bảo các tệp được lưu trước vào bộ nhớ đệm luôn có sẵn và luôn được cập nhật. Kết quả thu được là một tệp service-worker.js chứa nội dung tương tự như sau:

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

Đối với những người sử dụng quy trình xây dựng phức tạp hơn, Workbox có cả trình bổ trợ webpackmô-đun nút chung, ngoài giao diện dòng lệnh.

Phát trực tiếp

Tiếp theo, tôi muốn trình chạy dịch vụ truyền trực tuyến HTML một phần được lưu trước vào bộ nhớ đệm đó trở lại ứng dụng web ngay lập tức. Đây là một phần quan trọng để đảm bảo khả năng "nhanh chóng đáng tin cậy" – tôi luôn nhận được nội dung có ý nghĩa trên màn hình ngay lập tức. May mắn là nhờ sử dụng Streams API trong trình chạy dịch vụ của chúng tôi, điều đó có thể xảy ra.

Có thể trước đây bạn đã nghe về API Luồng. Đồng nghiệp của tôi, Jake Archibald đã ca ngợi chuỗi bài hát này trong nhiều năm qua. Anh đã đưa ra dự đoán táo bạo rằng năm 2016 sẽ là năm của các luồng phát trực tuyến trên web. Hiện tại, API phát trực tuyến vẫn tuyệt vời như 2 năm trước, nhưng có một sự khác biệt quan trọng.

Mặc dù trước đó chỉ có Chrome hỗ trợ các Luồng, nhưng API Luồng hiện đã được hỗ trợ rộng rãi hơn. Nhìn chung, đây là câu chuyện tích cực và với mã dự phòng phù hợp, hiện nay không có gì cản trở bạn sử dụng luồng trong trình chạy dịch vụ.

Vâng... có thể có một điều ngăn cản bạn, đó là cách API Luồng hoạt động thực sự. Lớp này cho thấy một tập hợp dữ liệu nguyên gốc rất hiệu quả và các nhà phát triển quen thuộc với việc sử dụng có thể tạo các luồng dữ liệu phức tạp như sau:

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

Tuy nhiên, có thể không phải ai cũng hiểu được ý nghĩa đầy đủ của mã này. Thay vì phân tích cú pháp thông qua logic này, hãy nói về phương pháp của tôi đối với việc truyền trực tuyến trình chạy dịch vụ.

Tôi đang sử dụng một trình bao bọc cấp cao, hoàn toàn mới, workbox-streams. Nhờ tính năng này, tôi có thể truyền dữ liệu vào nhiều nguồn truyền trực tuyến, cả từ bộ nhớ đệm và dữ liệu khi bắt đầu chạy có thể đến từ mạng. Workbox xử lý việc điều phối từng nguồn và ghép chúng lại với nhau thành một phản hồi truyền trực tuyến duy nhất.

Ngoài ra, Workbox sẽ tự động phát hiện xem API luồng có được hỗ trợ hay không. Nếu không, Workbox sẽ tạo một phản hồi tương đương không theo luồng. Điều này có nghĩa là bạn không phải lo lắng về việc viết nội dung dự phòng, vì các luồng ở mức độ gần với khả năng hỗ trợ 100% của trình duyệt.

Lưu vào bộ nhớ đệm trong thời gian chạy

Hãy xem cách service worker của tôi xử lý dữ liệu thời gian chạy, từ Stack Exchange API. Tôi đang tận dụng tính năng hỗ trợ tích hợp sẵn của Workbox cho chiến lược lưu vào bộ nhớ đệm đã lỗi thời trong khi xác thực lại, cùng với thời hạn để đảm bảo rằng bộ nhớ của ứng dụng web không tăng lên mà không bị giới hạn.

Tôi đã thiết lập 2 chiến lược trong Workbox để xử lý các nguồn khác nhau sẽ tạo nên phản hồi truyền trực tuyến. Chỉ bằng một vài lệnh gọi hàm và cấu hình, Workbox cho phép chúng ta làm những việc mà thường sẽ cần đến hàng trăm dòng mã viết tay.

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

Chiến lược đầu tiên sẽ đọc dữ liệu đã được lưu trước vào bộ nhớ đệm, chẳng hạn như các mẫu HTML một phần của chúng tôi.

Chiến lược còn lại triển khai logic lưu vào bộ nhớ đệm đã lỗi thời trong khi xác thực lại, cùng với thời hạn bộ nhớ đệm được dùng gần đây nhất sau khi chúng tôi đạt đến 50 mục nhập.

Giờ đây, khi đã áp dụng các chiến lược đó, việc còn lại là cho Workbox biết cách sử dụng các chiến lược này để tạo một phản hồi hoàn chỉnh theo luồng. Tôi truyền vào một mảng nguồn dưới dạng các hàm và mỗi hàm đó sẽ được thực thi ngay lập tức. Workbox lấy kết quả từ mỗi nguồn và truyền kết quả đó đến ứng dụng web theo trình tự, chỉ trì hoãn nếu hàm tiếp theo trong mảng chưa hoàn tất.

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

Hai nguồn đầu tiên là các mẫu một phần được lưu trước trong bộ nhớ đệm được đọc trực tiếp từ Cache Storage API (API Bộ nhớ bộ nhớ đệm), nên các mẫu này sẽ luôn có sẵn ngay lập tức. Điều này đảm bảo rằng quá trình triển khai trình chạy dịch vụ sẽ nhanh chóng phản hồi các yêu cầu, giống như mã phía máy chủ của tôi.

Hàm nguồn tiếp theo của chúng ta tìm nạp dữ liệu từ API Stack Exchange và xử lý phản hồi thành HTML mà ứng dụng web dự kiến.

Chiến lược cũ trong khi xác thực lại có nghĩa là nếu có phản hồi đã lưu vào bộ nhớ đệm trước đó cho lệnh gọi API này, tôi sẽ có thể truyền trực tuyến phản hồi đến trang ngay lập tức, đồng thời cập nhật mục bộ nhớ đệm "ở chế độ nền" cho lần tiếp theo được yêu cầu.

Cuối cùng, tôi truyền trực tuyến bản sao chân trang đã lưu vào bộ nhớ đệm và đóng các thẻ HTML cuối cùng để hoàn tất phản hồi.

Việc chia sẻ mã giúp đồng bộ hoá mọi thứ

Bạn sẽ nhận thấy một số bit nhất định của mã trình chạy dịch vụ trông quen thuộc. HTML một phần và logic tạo mẫu mà trình chạy dịch vụ của tôi sử dụng giống hệt với logic mà trình xử lý phía máy chủ của tôi sử dụng. Việc chia sẻ mã này đảm bảo rằng người dùng có được trải nghiệm nhất quán, cho dù họ lần đầu truy cập vào ứng dụng web của tôi hay quay lại trang do trình chạy dịch vụ kết xuất. Đó là nét đẹp của JavaScript đẳng cấp.

Các tính năng nâng cao linh động, tăng dần

Tôi đã hướng dẫn qua cả máy chủ và trình chạy dịch vụ cho PWA của mình, nhưng có một logic cuối cùng cần đề cập: có một lượng nhỏ JavaScript chạy trên từng trang sau khi các trang đó được truyền trực tuyến đầy đủ.

Mã này dần cải thiện trải nghiệm người dùng, nhưng không quan trọng – ứng dụng web vẫn sẽ hoạt động nếu không được chạy.

Siêu dữ liệu trang

Ứng dụng của tôi dùng JavaScipt phía máy khách để cập nhật siêu dữ liệu của trang dựa trên phản hồi của API. Vì tôi sử dụng cùng một bit đầu tiên của HTML được lưu vào bộ nhớ đệm cho mỗi trang, nên ứng dụng web sẽ hiện các thẻ chung trong phần đầu tài liệu của tôi. Tuy nhiên, thông qua sự phối hợp giữa mã tạo mẫu và mã phía máy khách, tôi có thể cập nhật tiêu đề của cửa sổ bằng cách sử dụng siêu dữ liệu dành riêng cho trang.

Trong mã tạo mẫu, cách tiếp cận của tôi là đưa vào một thẻ tập lệnh chứa chuỗi thoát đúng cách.

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

Sau đó, khi trang của mình đã tải, tôi sẽ đọc chuỗi đó và cập nhật tiêu đề tài liệu.

if (self._title) {
  document.title = unescape(self._title);
}

Nếu có các siêu dữ liệu cụ thể theo trang khác mà bạn muốn cập nhật trong ứng dụng web của riêng mình, thì bạn có thể làm theo phương pháp tương tự.

Trải nghiệm người dùng ngoại tuyến

Tính năng nâng cao tăng dần khác mà tôi đã thêm được dùng để thu hút sự chú ý đến các chức năng ngoại tuyến của chúng tôi. Tôi đã xây dựng một ứng dụng web tiến bộ (PWA) đáng tin cậy và tôi muốn người dùng biết rằng khi không có mạng, họ vẫn có thể tải các trang đã truy cập trước đó.

Trước tiên, tôi sử dụng API Bộ nhớ đệm để lấy danh sách tất cả các yêu cầu API được lưu vào bộ nhớ đệm trước đây rồi tôi dịch danh sách đó thành một danh sách URL.

Bạn có nhớ các thuộc tính dữ liệu đặc biệt mà tôi đã nói đến, mỗi thuộc tính chứa URL cho yêu cầu API cần để hiển thị câu hỏi không? Tôi có thể tham chiếu chéo các thuộc tính dữ liệu đó với danh sách các URL đã lưu vào bộ nhớ đệm và tạo một mảng gồm tất cả các đường liên kết có câu hỏi không khớp.

Khi trình duyệt chuyển sang trạng thái ngoại tuyến, tôi sẽ lặp lại danh sách các đường liên kết không được lưu trong bộ nhớ đệm và làm mờ những đường liên kết không hoạt động. Xin lưu ý rằng đây chỉ là gợi ý trực quan cho người dùng về những gì họ nên mong đợi trên các trang đó chứ không thực sự vô hiệu hoá các đường liên kết hoặc ngăn người dùng di chuyển.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

Các lỗi phổ biến

Hiện tại, tôi đã tìm hiểu xong phương pháp tạo một ứng dụng web tiến bộ (PWA) nhiều trang. Có nhiều yếu tố mà bạn cần cân nhắc khi tìm ra phương pháp của riêng mình và rồi bạn có thể đưa ra những lựa chọn khác với tôi. Tính linh hoạt đó là một trong những điều tuyệt vời nhất khi xây dựng ứng dụng cho web.

Có một vài sai lầm phổ biến mà bạn có thể gặp phải khi đưa ra quyết định về cấu trúc của riêng mình và tôi muốn giúp bạn khắc phục một số vấn đề.

Không lưu toàn bộ HTML vào bộ nhớ đệm

Tôi khuyên bạn không nên lưu trữ tài liệu HTML hoàn chỉnh trong bộ nhớ đệm của mình. Một là, việc đó gây lãng phí không gian lưu trữ. Nếu ứng dụng web của bạn sử dụng cấu trúc HTML cơ bản giống nhau cho từng trang, bạn sẽ phải lưu trữ bản sao của cùng một mã đánh dấu nhiều lần.

Quan trọng hơn, nếu bạn triển khai một thay đổi cho cấu trúc HTML dùng chung của trang web, thì mọi trang đã lưu vào bộ nhớ đệm trước đó vẫn sẽ bị kẹt với bố cục cũ. Hãy hình dung sự thất vọng khi một khách truy cập cũ thấy cả trang cũ và trang mới.

Sự trôi của máy chủ / trình chạy dịch vụ

Lỗi sai khác cần tránh liên quan đến việc máy chủ và trình chạy dịch vụ không đồng bộ hoá. Phương pháp tiếp cận của tôi là sử dụng JavaScript đa hình để chạy cùng một mã ở cả hai vị trí. Tuỳ thuộc vào cấu trúc máy chủ hiện có của bạn, điều này không phải lúc nào cũng có thể thực hiện được.

Dù đưa ra quyết định kiến trúc nào, bạn cũng nên có chiến lược để chạy mã định tuyến và tạo mẫu tương đương trong máy chủ và trình chạy dịch vụ của mình.

Tình huống xấu nhất

Bố cục / thiết kế không nhất quán

Điều gì xảy ra khi bạn bỏ qua những lỗi này? Tất cả mọi loại lỗi đều có thể xảy ra, nhưng trường hợp xấu nhất là người dùng cũ truy cập vào một trang đã lưu vào bộ nhớ đệm có bố cục rất lỗi thời, có thể là một trang có văn bản tiêu đề đã lỗi thời hoặc sử dụng tên lớp CSS không còn hợp lệ.

Tình huống xấu nhất: Định tuyến bị hỏng

Ngoài ra, người dùng có thể gặp một URL do máy chủ của bạn xử lý, nhưng không phải do trình chạy dịch vụ của bạn xử lý. Một trang web đầy ắp bố cục thây ma và ngõ cụt không phải là một PWA đáng tin cậy.

Mẹo để thành công

Nhưng bạn không hề đơn độc! Các mẹo sau đây có thể giúp bạn tránh được những sai lầm đó:

Dùng thư viện tạo mẫu và định tuyến có triển khai đa ngôn ngữ

Hãy thử sử dụng các thư viện tạo mẫu và định tuyến có triển khai JavaScript. Tôi biết rằng không phải nhà phát triển nào cũng có lợi ích của việc di chuyển khỏi máy chủ web hiện tại và ngôn ngữ tạo mẫu.

Tuy nhiên, một số khung định tuyến và tạo mẫu phổ biến đã được triển khai bằng nhiều ngôn ngữ. Nếu có thể tìm thấy một API hoạt động với JavaScript cũng như ngôn ngữ của máy chủ hiện tại, bạn đã tiến một bước gần hơn để đồng bộ hoá trình chạy dịch vụ và máy chủ.

Ưu tiên các mẫu tuần tự, thay vì các mẫu lồng nhau

Tiếp theo, bạn nên sử dụng một loạt các mẫu tuần tự có thể được phát trực tuyến lần lượt. Không có vấn đề gì nếu các phần sau của trang sử dụng logic tạo mẫu phức tạp hơn, miễn là bạn có thể truyền trực tuyến trong phần đầu của HTML nhanh nhất có thể.

Lưu cả nội dung tĩnh và động trong trình chạy dịch vụ của bạn

Để có hiệu suất tốt nhất, bạn nên lưu trước tất cả các tài nguyên tĩnh quan trọng của trang web vào bộ nhớ đệm. Bạn cũng nên thiết lập logic lưu vào bộ nhớ đệm trong thời gian chạy để xử lý nội dung động, chẳng hạn như các yêu cầu API. Khi sử dụng Workbox, bạn có thể xây dựng dựa trên các chiến lược đã được thử nghiệm và sẵn sàng phát hành công khai thay vì triển khai tất cả từ đầu.

Chỉ chặn trên mạng khi thực sự cần thiết

Liên quan đến điều đó, bạn chỉ nên chặn trên mạng khi không thể truyền phản hồi từ bộ nhớ đệm. Việc hiển thị ngay phản hồi của API được lưu vào bộ nhớ đệm thường có thể mang lại trải nghiệm người dùng tốt hơn so với việc chờ dữ liệu mới.

Tài nguyên