Hãy nói về... kiến trúc?
Tôi sẽ đề cập đến một chủ đề quan trọng nhưng có thể bị hiểu lầm: Kiến trúc mà bạn sử dụng cho ứng dụng web của mình, và cụ thể là cách các quyết định về kiến trúc của bạn phát huy tác dụng khi bạn xây dựng một ứng dụng web tiến bộ.
"Kiến trúc" có thể nghe mơ hồ và có thể không rõ ràng ngay lập tức lý do tại sao điều này lại quan trọng. Một cách để nghĩ về cấu trúc là tự hỏi bản thân 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 nào 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 nghĩ về ứng dụng web tiến bộ, chúng có thể trở nên phức tạp hơn. Vì vậy, mục tiêu của tôi là hướng dẫn bạn một cấu 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 các quyết định mà tôi đưa ra là "phương pháp của tôi" để 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 giải pháp thay thế hợp lệ khác. Tôi hy vọng rằng việc xem cách tất cả các 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ó thể tuỳ chỉnh phần này cho phù hợp với nhu cầu của mình.
Ứng dụng web tiến bộ (PWA) của Stack Overflow
Để đi kèm với bài viết này, tôi đã xây dựng một PWA Stack Overflow. Tôi dành nhiều thời gian để đọc và đóng góp cho Stack Overflow, đồng thời muốn xây dựng một ứng dụng web giúp dễ dàng duyệt xem các câu hỏi thường gặp về một chủ đề nhất định. Ứng dụng này được xây dựng dựa trên Stack Exchange API công khai. Đây là một dự án 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 trên GitHub.
Ứng dụng nhiều trang (MPA)
Trước khi đi vào chi tiết, hãy cùng định nghĩa một số thuật ngữ và giải thích các thành phần của công nghệ cơ bản. Trước tiên, tôi sẽ đề cập đến những gì tôi muốn gọi là "Ứng dụng nhiều trang" hoặc "MPA".
MPA là tên gọi hoa mỹ của kiến trúc truyền thống được sử dụng kể từ khi web ra đời. Mỗi khi 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 đó. Không có nỗ lực nào để duy trì trạng thái của trang hoặc nội dung giữa các thao tác đ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 trang đơn (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 vào một phần mới. Cả SPA và MPA đều là những mô hình hợp lệ để sử dụng, nhưng trong bài đăng này, tôi muốn khám phá các khái niệm về PWA trong bối cảnh của một ứng dụng nhiều trang.
Nhanh và ổn định
Bạn đã nghe tôi (và vô số người khác) sử dụng cụm từ "ứng dụng web tiến bộ" hoặc PWA. Có thể bạn đã quen thuộc với một số tài liệu cơ bả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 mang lại trải nghiệm người dùng hàng đầu và thực sự xứng đáng có một vị trí trên màn hình chính của người dùng. Từ viết tắt "FIRE", viết tắt của Fast (Nhanh), Integrated (Tích hợp), Reliable (Đáng tin cậy) và Engaging (Hấp dẫn), tóm tắt tất cả các thuộc tính cần xem xét khi xây dựng PWA.
Trong bài viết này, tôi sẽ tập trung vào một nhóm nhỏ các thuộc tính đó: Nhanh và Đáng tin cậy.
Nhanh: Mặc dù "nhanh" có nhiều ý nghĩa trong các bối cảnh khác nhau, nhưng tôi sẽ đề cập đến lợi ích về tốc độ của việc tải ít nhất có thể từ mạng.
Đáng tin cậy: Nhưng chỉ tốc độ thôi thì chưa đủ. Để mang lại trải nghiệm như một PWA, ứng dụng web của bạn phải đáng tin cậy. Ứng dụng cần phải đủ linh hoạt để luôn tải được nội dung nào đó, ngay cả khi đó chỉ là một trang lỗi tuỳ chỉnh, bất kể trạng thái của mạng.
Nhanh và đáng tin cậy: Cuối cùng, tôi sẽ diễn đạt lại định nghĩa về PWA một chút và xem xét ý nghĩa của việc xây dựng một ứng dụng nhanh và đáng tin cậy. Chỉ nhanh và đáng tin cậy khi bạn đang sử dụng mạng có độ trễ thấp là chưa đủ. Nhanh một cách đáng tin cậy có nghĩa là tốc độ của ứng dụng web luôn nhất quán, bất kể điều kiện mạng cơ bản.
Công nghệ hỗ trợ: Service Worker + Cache Storage API
PWA đặt ra tiêu chuẩn cao về tốc độ và khả năng thích ứng. May mắn là nền tảng web cung cấp một số khối xây dựng để biến loại hiệu suất đó thành hiện thực. Tôi đang đề cập đến trình chạy dịch vụ và API lưu trữ bộ nhớ đệm.
Bạn có thể tạo một trình chạy dịch vụ để theo dõi các yêu cầu gửi đến, chuyể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 Cache Storage API.
Lần tiếp theo ứng dụng web đưa ra cùng một yêu cầu, trình chạy dịch vụ của ứng dụng có thể kiểm tra bộ nhớ đệm và chỉ trả về phản hồi đã lưu vào bộ nhớ đệm trước đó.
Tránh mạng bất cứ khi nào có thể là một phần quan trọng trong việc cung cấp hiệu suất nhanh đáng tin cậy.
JavaScript "đồng hình"
Một khái niệm nữa mà tôi muốn đề cập là khái niệm đôi khi được gọi là JavaScript "đẳng cấu" hoặc "phổ quát". Nói một cách đơn giản, đó là ý tưởng rằng 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 xây dựng 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 cách hợp lệ để chia sẻ mã theo cách này, nhưng cách của tôi là sử dụng mô-đun ES làm mã nguồn chính thức. Sau đó, tôi chuyển đổi cú phá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 kết hợp Babel và Rollup. Trong dự án của tôi,
các tệp có đuôi tệp .mjs là mã nằm trong một mô-đun ES.
Máy chủ
Hãy ghi nhớ những khái niệm và thuật ngữ đó, chúng ta hãy cùng tìm hiểu cách tôi thực sự xây dựng PWA Stack Overflow. Tôi sẽ bắt đầu bằng việc giới thiệu máy chủ phụ trợ của chúng tôi và giải thích cách máy chủ này phù hợp với cấu trúc tổng thể.
Tôi đang tìm kiếm một giải pháp kết hợp giữa phần phụ trợ động và dịch vụ lưu trữ tĩnh, và cách 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 một môi trường dựa trên Node khi có yêu cầu đến và tích hợp với khung Express HTTP phổ biến mà tôi đã quen thuộc. Nền tảng 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 cùng xem cách máy chủ xử lý các yêu cầu.
Khi trình duyệt gửi yêu cầu điều hướng đến máy chủ của chúng tôi, yêu cầu đó sẽ trải qua quy trình sau:
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 một tài liệu HTML hoàn chỉnh. Tôi sử dụng kết hợp dữ liệu từ API Stack Exchange, cũng như các đoạn HTML một phần mà máy chủ lưu trữ cục bộ. Sau khi trình chạy dịch vụ biết cách phản hồi, trình chạy này có thể bắt đầu truyền trực tuyến HTML trở lại vào ứng dụng web của chúng tôi.
Có hai phần trong bức tranh này đáng để khám phá chi tiết hơn: định tuyến và tạo mẫu.
Định tuyến
Khi nói đến việc định tuyến, phương pháp của tôi là sử dụng cú pháp định tuyến gốc của khung Express. Nó đủ linh hoạt để khớp với các tiền tố URL đơn giản, cũng như các URL bao gồm các tham số như một phần của đường dẫn. Tại đây, tôi tạo một mối liên kết giữa tên tuyến đường và mẫu Express cơ bản để so khớp.
const routes = new Map([
['about', '/about'],
['questions', '/questions/:questionId'],
['index', '/'],
]);
export default routes;
Sau đó, tôi có thể tham chiếu trực tiếp mối liên kết này từ mã của máy chủ. Khi có kết quả 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 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ủ
Và logic tạo mẫu đó trông như thế nào? Tôi đã chọn cách ghép các đoạn HTML từng phần theo trình tự, hết đoạn này đến đoạn khác. Mô hình này rất phù hợp cho việc phát trực tuyến.
Máy chủ sẽ gửi lại ngay một số mã nguyên mẫu HTML ban đầu và trình duyệt có thể kết xuất ngay trang một phần đó. Khi máy chủ tập hợp các nguồn dữ liệu còn lại, 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 rõ hơn, hãy xem mã Express 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 đến các mẫu một phần được lưu trữ trên thiết bị, tôi có thể bắt đầu luồng 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ị ngay một giao diện có ý nghĩa cũng như thông báo đang tải.
Phần tiếp theo của trang sử dụng dữ liệu từ Stack Exchange API. Để lấy dữ liệu đó, máy chủ của chúng ta cần đưa ra 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 được phản hồi và xử lý phản hồi đó, nhưng ít nhất người dùng không phải nhìn chằm chằm vào màn hình trống trong khi chờ đợi.
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ữ tạo mẫu
Tạo mẫu có thể là một chủ đề gây tranh cãi đáng ngạc nhiên và những gì tôi đã làm chỉ là một phương pháp trong số nhiều phương pháp. Bạn nê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 kết cũ với một khung tạo mẫu hiện có.
Điều hợp lý cho trường hợp sử dụng của tôi là chỉ dựa vào mẫu cố định 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ểm hay khi xây dựng MPA là bạn không cần phải theo dõi thông tin cập nhật về trạng thái và kết xuất lại HTML, vì vậy, phương pháp cơ bản tạo ra HTML tĩnh phù hợp với tôi.
Sau đây là ví dụ về cách tôi tạo mẫu cho phần HTML động trong chỉ mục của ứng dụng web. Giống như các tuyến của tôi, logic tạo mẫu được lưu trữ trong một mô-đun ES có thể được 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 logic thành các hàm trợ giúp nhỏ hơn khi thích hợp. Tại đây, tôi truyề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 tất cả các thuộc tính thích hợp được đặt.
function questionCard({id, title}) {
return `<a class="card"
href="/questions/${id}"
data-cache-url="${questionUrl(id)}">${title}</a>`;
}
Điều đặc biệt cần lưu ý là 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 API Stack Exchange mà tôi cần để hiển thị câu hỏi tương ứng. Hãy lưu ý điều này. Tôi sẽ xem lại sau.
Quay lại trình xử lý tuyến đường, sau khi tạo mẫu xong, tôi sẽ truyền trực tuyến phần cuối cùng của HTML của 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à thông tin tóm tắt về cách thiết lập máy chủ của tôi. Người dùng truy cập vào ứng dụng web của tôi lần đầu tiên 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, trình chạy dịch vụ của tôi 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ụ
Sơ đồ này có vẻ quen thuộc – nhiều phần giống nhau mà tôi đã đề cập trước đây đều có ở đây, nhưng được sắp xếp theo cách hơi khác. Hãy cùng tìm hiểu 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ý một 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 này sử dụng kết hợp logic định tuyến và tạo mẫu để tìm ra cách phản hồi.
Cách tiếp cận này giống như trước đây, nhưng có các dữ liệu gốc cấp thấp khác, chẳng hạn như fetch() và Cache Storage API. 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ụ chuyển lại 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ụ của mình dựa trên một tập hợp các thư viện cấp cao có tên là Workbox. Thư viện này cung cấp một nền tảng vững chắc cho logic lưu vào bộ nhớ đệm, định tuyến và tạo phản hồi của bất kỳ trình chạy dịch vụ nào.
Đị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.
Cách tiếp cận của tôi là
dịch
mỗi tuyến Express thành một biểu thức chính quy tương ứng,
bằng cách sử 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 của Workbox cho đị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 đăng ký từng biểu thức chính quy với bộ định tuyến của Workbox. 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ụ phức tạp hơn một chút so với trong máy chủ phụ trợ, nhưng Workbox giúp xử lý nhiều công việc nặng nhọc.
import regExpRoutes from './regexp-routes.mjs';
workbox.routing.registerRoute(
regExpRoutes.get('index')
// Templating logic.
);
Lưu thành phần tĩnh vào bộ nhớ đệm
Một phần quan trọng của câu chuyện về 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 thiết bị thông qua API Bộ nhớ đệm và được cập nhật khi tôi triển khai các thay đổi cho ứng dụng web. Việc bảo trì bộ nhớ đệm có thể dễ xảy ra lỗi khi thực hiện 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 quá trình xây dựng.
Tôi cho Workbox biết những URL cần lưu trước vào bộ nhớ đệm 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 để so khớp. Tệp này được CLI của Workbox tự động đọc, được chạy 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 và 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 hiện 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à được cập nhật. Kết quả 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ợ webpack và mô-đ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 đã lưu 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 để "nhanh một cách đá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à chúng ta có thể làm được điều đó bằng cách sử dụng Streams API trong trình chạy dịch vụ.
Có thể bạn đã từng nghe nói đến Streams API. Đồng nghiệp của tôi, Jake Archibald, đã ca ngợi tính năng này trong nhiều năm. Ông đã đưa ra dự đoán táo bạo rằng năm 2016 sẽ là năm của luồng dữ liệu web. Và Streams API ngày nay cũng tuyệt vời như hai năm trước, nhưng có một điểm khác biệt quan trọng.
Mặc dù chỉ có Chrome hỗ trợ Streams vào thời điểm đó, nhưng API Streams hiện đã được hỗ trợ rộng rãi hơn. Nhìn chung, đây là một tin vui và với mã dự phòng thích hợp, không có gì ngăn cản bạn sử dụng luồng trong trình chạy dịch vụ ngay hôm nay.
Tuy nhiên, có thể có một điều khiến bạn băn khoăn, đó là cách Streams API thực sự hoạt động. Thư viện này cung cấp một tập hợp các dữ liệu gốc rất mạnh mẽ và những nhà phát triển thành thạo trong việc sử dụng thư viện này có thể tạo ra các luồng dữ liệu phức tạp, chẳng hạn như:
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, không phải ai cũng hiểu được toàn bộ ý nghĩa 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 để 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ôi có thể truyền dữ liệu này vào một tổ hợp các nguồn truyền trực tuyến, cả từ bộ nhớ đệm và dữ liệu thời gian chạy có thể đến từ mạng. Workbox đảm nhận việc điều phối các nguồn riêng lẻ và kết hợp chúng thành một phản hồi truyền trực tuyến duy nhất.
Ngoài ra, Workbox tự động phát hiện xem Streams API có được hỗ trợ hay không và khi không được hỗ trợ, Workbox sẽ tạo một phản hồi tương đương, không truyền trực tuyến. Điều này có nghĩa là bạn không cần phải lo lắng về việc viết phương án dự phòng, vì các luồng dữ liệu ngày càng tiến gần đến mức hỗ trợ 100% trình duyệt.
Lưu vào bộ nhớ đệm trong thời gian chạy
Hãy cùng xem cách trình chạy dịch vụ của tôi xử lý dữ liệu thời gian chạy từ API Stack Exchange. Tôi đang sử dụng tính năng hỗ trợ tích hợp của Workbox cho chiến lược lưu vào bộ nhớ đệm cũ trong khi xác thực lại, cùng với thời gian hết hạn để đảm bảo rằng bộ nhớ của ứng dụng web không tăng lên vô hạn.
Tôi thiết lập hai 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ỉ với 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à nếu không có Workbox thì sẽ phải viết hàng trăm dòng mã.
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 đọ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.
Chiến lược còn lại triển khai logic lưu vào bộ nhớ đệm stale-while-revalidate, cùng với thời gian hết hạn của bộ nhớ đệm ít được sử dụng gần đây nhất khi chúng ta đạt đến 50 mục nhập.
Bây giờ, tôi đã có các chiến lược đó, tất cả những gì còn lại là cho Workbox biết cách sử dụng các chiến lược đó để tạo ra một phản hồi hoàn chỉnh, truyền trực tuyến. Tôi truyền vào một mảng các nguồn dưới dạng 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 trực tuyế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 vào bộ nhớ đệm, được đọc trực tiếp từ Cache Storage API, vì vậy, 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ụ của chúng tôi sẽ phản hồi nhanh chóng và đáng tin cậy đối vớ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 sẽ 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 mong đợi.
Chiến lược stale-while-revalidate có nghĩa là nếu tôi có phản hồi được 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 nhập bộ nhớ đệm "trong nền" cho lần yêu cầu tiếp theo.
Cuối cùng, tôi truyền trực tuyến một bản sao được lưu vào bộ nhớ đệm của chân trang và đóng các thẻ HTML cuối cùng để hoàn tất phản hồi.
Chia sẻ mã giúp đồng bộ hoá mọi thứ
Bạn sẽ nhận thấy một số đoạn mã của 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ọ truy cập vào ứng dụng web của tôi lần đầu tiên hay quay lại một trang do trình chạy dịch vụ hiển thị. Đó là điểm hay của JavaScript đẳng cấu.
Cải tiến linh hoạt, tăng dần
Tôi đã hướng dẫn cả máy chủ và trình chạy dịch vụ cho PWA của mình, nhưng còn một phần logic cuối cùng cần đề cập: có một lượng nhỏ JavaScript chạy trên mỗi trang của tôi sau khi các trang đó được truyền trực tuyến đầy đủ.
Mã này dần cải tiến tăng dần trải nghiệm người dùng, nhưng không quan trọng – ứng dụng web vẫn hoạt động nếu không chạy mã này.
Siêu dữ liệu trang
Ứng dụng của tôi sử dụng JavaScript 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 đoạn HTML được lưu vào bộ nhớ đệm ban đầu cho mỗi trang, nên ứng dụng web sẽ có các thẻ chung trong phần đầu của tài liệu. Nhưng 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 siêu dữ liệu theo trang cụ thể.
Trong mã tạo mẫu, tôi sẽ thêm 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 tôi đã tải xong, 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 khác dành riêng cho trang 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 cách tương tự.
Trải nghiệm người dùng khi không có mạng
Một cải tiến tăng dần khác mà tôi đã thêm vào là dùng để thu hút sự chú ý đến các khả năng ngoại tuyến của chúng tôi. Tôi đã xây dựng một 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 Cache Storage API để lấy danh sách tất cả các yêu cầu API đã lưu vào bộ nhớ đệm trước đó, rồi chuyển danh sách đó thành 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 không? Mỗi thuộc tính chứa URL cho yêu cầu API cần thiết để hiển thị một câu hỏi. Tôi có thể tham chiếu chéo các thuộc tính dữ liệu đó với danh sách URL được 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 đến 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 chưa được lưu vào 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 từ các trang đó. Tôi không thực sự vô hiệu hoá các đường liên kết hoặc ngăn người dùng điều hướng.
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
Tôi đã giới thiệu xong về phương pháp xây dựng PWA nhiều trang. Có nhiều yếu tố bạn cần cân nhắc khi đưa ra phương pháp của riêng mình và có thể bạn sẽ đư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 khi xây dựng cho web.
Có một số lỗi thường gặp 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 tránh được một số rắc rối.
Không lưu toàn bộ HTML vào bộ nhớ đệm
Bạn không nên lưu trữ toàn bộ tài liệu HTML trong bộ nhớ đệm. Thứ nhất, việc này gây lãng phí không gian. Nếu ứng dụng web của bạn sử dụng cùng một cấu trúc HTML cơ bản cho mỗi trang, thì bạn sẽ phải lưu trữ nhiều bản sao của cùng một mã đánh dấu.
Quan trọng hơn, nếu bạn triển khai thay đổi đối với 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 tưởng tượng khách truy cập cũ sẽ thất vọng thế nào khi thấy cả trang cũ lẫn trang mới.
Độ lệch của máy chủ/trình chạy dịch vụ
Một vấn đề khác cần tránh là máy chủ và trình chạy dịch vụ của bạn không đồng bộ. Cách tiếp cận của tôi là sử dụng JavaScript đẳng cấu để cùng một mã chạy ở cả hai nơi. Tuỳ thuộc vào cấu trúc máy chủ hiện có, điều đó không phải lúc nào cũng khả thi.
Dù đưa ra quyết định nào về cấu trúc, bạn cũng nên có một số chiến lược để chạy mã định tuyến và mã tạo mẫu tương đương trong máy chủ và trình chạy dịch vụ.
Trường hợp xấu nhất
Bố cục/thiết kế không nhất quán
Điều gì sẽ xảy ra khi bạn bỏ qua những cạm bẫy đó? Tất cả các 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 được lưu vào bộ nhớ đệm có bố cục rất cũ, có thể là 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ệ.
Trường hợp xấu nhất: Định tuyến bị hỏng
Ngoài ra, người dùng có thể gặp phải một URL do máy chủ của bạn xử lý, nhưng không phải là trình chạy dịch vụ của bạn. Một trang web có đầy bố cục zombie 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 đơn độc! Các mẹo sau đây có thể giúp bạn tránh những vấn đề đó:
Sử dụng các thư viện tạo mẫu và định tuyến có cách 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ó điều kiện để di chuyển khỏi máy chủ web và ngôn ngữ tạo mẫu hiện tại.
Tuy nhiên, một số khung định tuyến và tạo mẫu phổ biến có các cách triển khai bằng nhiều ngôn ngữ. Nếu có thể tìm thấy một công cụ hoạt động với JavaScript cũng như ngôn ngữ của máy chủ hiện tại, thì bạn đã tiến thêm một bước để đồng bộ hoá trình chạy dịch vụ và máy chủ.
Ưu tiên các mẫu tuần tự hơn là mẫu lồng nhau
Tiếp theo, bạn nên sử dụng một loạt mẫu tuần tự có thể được truyền trực tuyến lần lượt. Các phần sau của trang có thể 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 phần đầu tiên của HTML nhanh nhất có thể.
Lưu cả nội dung tĩnh và động vào bộ nhớ đệm trong trình chạy dịch vụ
Để đạt hiệu suất tốt nhất, bạn nên lưu trước tất 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 kiểm thử kỹ lưỡng và sẵn sàng cho môi trường thực tế thay vì triển khai mọi thứ từ đầu.
Chỉ chặn trên mạng khi thực sự cần thiết
Và liên quan đến điều đó, bạn chỉ nên chặn trên mạng khi không thể truyền trực tuyế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.