Hãy cùng trò chuyện 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: Cấu 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ề cấu trúc đóng vai trò khi bạn đang tạo một ứng dụng web tiến bộ.
"Kiến trúc" có thể nghe không rõ ràng và bạn có thể không hiểu 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 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 được tải? Sau đó, những gì đượ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ề các ứng dụng web tiến bộ, câu trả lời 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 về một cấu trúc có thể hiệu quả mà tôi đã tìm thấy. Trong suốt bài viết này, tôi sẽ gắn nhãn những quyết định mà mình đưa ra là "cách tiếp cận 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 tạo PWA của riêng mình, nhưng đồng thời, luôn có những lựa chọn thay thế hợp lệ khác. Tôi hy vọng rằng khi thấy cách tất cả các phần kết hợp với nhau, bạn sẽ được truyền cảm hứng và cảm thấy có đủ khả năng để tuỳ chỉnh theo nhu cầu của mình.
PWA Stack Overflow
Để minh hoạ cho bài viết này, tôi đã tạo một PWA Stack Overflow. Tôi dành nhiều thời gian để đọc và đóng góp cho Stack Overflow. Tôi muốn tạo một ứng dụng web giúp mọi người 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 API Stack Exchange công khai. Đây là 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 xác định 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ẽ đề 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 khác của cấu trúc truyền thống được sử dụng 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 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 lần điều hướng. Mỗi khi 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) để tạo ứ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 chóng và đáng tin cậy
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. Bạn có thể đã quen thuộc với một số tài liệu cơ bản ở 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 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 (Thu hút)) tóm tắt tất cả các thuộc tính cần cân nhắc khi tạo một 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 tuỳ theo bối cảnh, 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 tốc độ thô là chưa đủ. Để có cảm giác như một PWA, ứng dụng web của bạn phải đáng tin cậy. Ứng dụng cần đủ khả năng phục hồi để luôn tải nội dung, 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 chóng và đáng tin cậy: Cuối cùng, tôi sẽ diễn giải 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 thứ gì đó nhanh chóng 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 đủ. Hoạt động nhanh chóng 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 phục hồi. May mắn thay, 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 service worker và Cache Storage API.
Bạn có thể tạo một worker dịch vụ để theo dõi các yêu cầu đế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.
Vào 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ỉ trả về phản hồi đã được lưu vào bộ nhớ đệm trước đó.
Tránh sử dụng 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 chóng và đáng tin cậy.
JavaScript "đẳng cấu"
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 "đa năng". Nói một cách đơn giản, đây là ý tưởng rằng cùng một mã JavaScript có thể được chia sẻ giữa các môi trường thời gian chạy khác nhau. 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ó rất nhiều phương pháp hợp lệ để chia sẻ mã theo cách này, nhưng phương pháp của tôi là sử dụng các mô-đun ES làm mã nguồn xác định. Sau đó, tôi đã chuyển mã và gói các mô-đun đó cho máy chủ và worker 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 .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 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 cách đề cập đến máy chủ phụ trợ của chúng ta và giải thích cách máy chủ đó phù hợp với cấu trúc tổng thể.
Tôi đang tìm kiếm sự kết hợp giữa một phần phụ trợ động cùng với 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 HTTP Express 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ữ ngay lập tức cho tất cả cá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 một 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, 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ừ Stack Exchange API, cũng như các đoạn HTML một phần mà máy chủ lưu trữ cục bộ. Sau khi biết cách phản hồi, service worker có thể bắt đầu truyền trực tuyến HTML trở lại ứng dụng web của chúng ta.
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, tôi đã sử dụng cú pháp định tuyến gốc của khung Express. Nó đủ linh hoạt để so khớp các tiền tố URL đơn giản, cũng như các URL có chứa tham số trong đường dẫn. Ở đâ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ó một mẫu Express phù hợp, 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 đường phù hợp.
import routes from './lib/routes.mjs';
app.get(routes.get('index'), as>ync (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 một phương pháp ghép các đoạn HTML một 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 với hoạt động phát trực tuyến.
Máy chủ sẽ gửi lại ngay một số mã HTML ban đầu và trình duyệt có thể kết xuất trang một phần đó ngay lập tức. Khi máy chủ ghé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 ý tôi, hãy xem mã Express cho một trong các tuyến đường của chúng ta:
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 cục bộ được lưu trữ một phần, tôi có thể bắt đầu truyề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ị ngay một giao diện có ý nghĩa cùng thông báo tải.
Phần tiếp theo của trang sử dụng dữ liệu từ Stack Exchange API. Để nhận được 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ể kết xuất 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 sẽ không 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ừ Stack Exchange API, ứ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
Việc tạo mẫu có thể là một chủ đề gây tranh cãi đến mức đáng ngạc nhiên và những gì tôi đã làm chỉ là một trong 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 nếu bạn có mối liên hệ với một khung 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 chữ theo 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ểm hay của việc tạo MPA là bạn không phải theo dõi các bản cập nhật trạng thái và kết xuất lại HTML. Vì vậy, một phương pháp cơ bản tạo ra HTML tĩnh đã hiệu quả với tôi.
Sau đây là ví dụ về cách tôi tạo mẫu phần HTML động của chỉ mục ứng dụng web. Giống như các tuyến đường 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à worker dịch vụ.
export function index(tag, items) {
const title = `<h3>Top "${escape(tag)}"< Qu>estions/h3`;
cons<t form = `form me>tho<d=&qu>ot;GET".../form`;
const questionCards = i>tems
.map(item =
questionCard({
id: item.question_id,
title: item.title,
})
)
.join('&<#39;);
const que>stions = `div id<=&qu>ot;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. Ở đây, tôi truyền từng mục được trả về trong phản hồi API vào một hàm như vậy. Hàm này sẽ tạo một phần tử HTML tiêu 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=>"${<qu>estionUrl(id)}"${title}/a`;
}
Một điểm đáng chú ý 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 đó. Tôi sẽ xem lại sau.
Quay lại trình xử lý tuyến đường, sau khi hoàn tất việc tạo mẫu, tôi sẽ truyền trực tuyến phần cuối cùng của HTML 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à phần hướng dẫn nhanh về cách thiết lập máy chủ của tôi. Những 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, service worker sẽ bắt đầu phản hồi. Hãy cùng tìm hiểu kỹ hơn về vấn đề này.
Trình chạy dịch vụ
Sơ đồ này có lẽ không xa lạ với bạn – nhiều phần mà tôi đã đề cập trước đây đều có ở đây, chỉ là được sắp xếp hơi khác một chút. Hãy cùng tìm hiểu quy trình yêu cầu, có tính đến worker dịch vụ.
Service worker 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, nó 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 đây, nhưng có các thành phần cơ bản cấp thấp khác, chẳng hạn như fetch() và Cache Storage API. Tôi sử dụng những nguồn dữ liệu đó để tạo phản hồi HTML mà trình chạy dịch vụ truyền lại cho ứng dụng web.
Workbox
Thay vì bắt đầu từ đầu với các thành phần cơ bản cấp thấp, tôi sẽ tạo trình chạy dịch vụ của mình dựa trên một nhóm thư viện cấp cao có tên là Workbox. Nó cung cấp một nền tảng vững chắc cho mọi logic tạo phản hồi, định tuyến và lưu vào bộ nhớ đệm của trình chạy dịch vụ.
Định tuyến
Giống như mã phía máy chủ của tôi, service worker cần biết cách so khớp một 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 từng tuyến đường 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 của Workbox cho định tuyến bằng 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. Trong mỗi tuyến đường, 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 trên máy chủ phụ trợ của tôi, nhưng Workbox giúp ích rất nhiều trong việc này.
import regExpRoutes from './regexp-routes.mjs';
workbox.routing.registerRoute(
regExpRoutes.get('index')
// Templating logic.
);
Lưu vào bộ nhớ đệm thành phần tĩnh
Một phần quan trọng của câu chuyện về việc tạo mẫu là đảm bảo các mẫu HTML một phần của tôi có sẵn cục bộ thông qua Cache Storage API và luôn được cập nhật khi tôi triển khai các thay đổi cho ứng dụng web. Việc duy trì bộ nhớ đệm có thể dễ xảy ra 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 vào bộ nhớ đệm trước trong quy trình xây dựng của mình.
Tôi cho Workbox biết những URL cần lưu vào bộ nhớ đệm trước bằng cách sử dụng một 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 các mẫu cần so khớp. Tệp này được CLI của Workbox tự động đọc. CLI này sẽ 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 để luôn cung cấp các tệp được lưu vào bộ nhớ đệm trước và luôn cập nhật các tệp đó. 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 một phần HTML được lưu vào bộ nhớ đệm trước đó trở lại ứng dụng web ngay lập tức. Đây là một phần quan trọng của việc có tốc độ "nhanh chóng và đáng tin cậy" – tôi luôn nhận được thông tin có ý nghĩa ngay lập tức trên màn hình. Rất may là việc sử dụng Streams API trong trình chạy dịch vụ của chúng tôi giúp điều đó trở nên khả thi.
Có thể bạn đã nghe nói về Streams API trước đây. Đồ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 vẫn tuyệt vời như cách đây 2 năm, 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, câu chuyện này là tích cực và với mã dự phòng thích hợp, không có gì ngăn cản bạn sử dụng các luồng trong trình chạy dịch vụ của mình ngay hôm nay.
À... có thể có một điều ngăn cản bạn, đó là việc bạn không hiểu rõ cách Streams API thực sự hoạt động. Nó cung cấp một bộ nguyên tắc cơ bản rất mạnh mẽ và những nhà phát triển quen sử dụng bộ nguyên tắc 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);
}
});
},
});
Nhưng không phải ai cũng hiểu hết ý nghĩa của mã này. Thay vì phân tích cú pháp theo logic này, hãy nói về phương pháp của tôi đối với tính năng phát trực tuyến của 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.
Với đối tượng này, tôi có thể truyền đối tượng đó trong nhiều nguồn phát 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 sẽ đả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 duy nhất, truyền trực tuyến.
Ngoài ra, Workbox sẽ tự động phát hiện xem Streams API có được hỗ trợ hay không. 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 phải lo lắng về việc viết các giải pháp dự phòng, vì các luồng phát đang 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 xem service worker của tôi xử lý dữ liệu thời gian chạy như thế nào, từ Stack Exchange API. Tôi đang tận dụng khả 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 bộ nhớ của ứng dụng web không tăng lên vô 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 phát trực tiếp. Trong 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 thì sẽ mất hàng trăm dòng mã được 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 đọc dữ liệu đã được lưu vào bộ nhớ đệm trước, 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 stale-while-revalidate, cùng với thời gian hết hạn của bộ nhớ đệm được dùng gần đây nhất sau khi đạt đến 50 mục.
Giờ đây, khi đã có 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 đó để tạo một phản hồi hoàn chỉnh, truyền trực tuyến. Tôi truyền một mảng nguồn dưới dạng các hàm và mỗi hàm trong số đó 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 vào bộ nhớ đệm trước, được đọc trực tiếp từ Cache Storage API (API Bộ nhớ đệm), nên chúng sẽ luôn có sẵn ngay lập tức. Điều này đảm bảo rằng việc triển khai service worker của chúng tôi sẽ phản hồi các yêu cầu một cách nhanh chóng và đáng tin cậy, 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ừ Stack Exchange API 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 có một phản hồi đã lưu trong 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 "ở chế độ nền" cho lần tiếp theo khi được yêu cầu.
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 thẻ HTML cuối cùng để hoàn tất phản hồi.
Mã chia sẻ giúp mọi thứ luôn đồng bộ
Bạn sẽ nhận thấy một số phần trong mã của worker dịch vụ có vẻ 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 những gì 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ụ kết xuất. Đó là ưu điểm của JavaScript đẳng cấu.
Cải tiến tăng dần, linh hoạt
Tôi đã xem xét cả máy chủ và worker dịch vụ cho PWA của mình, nhưng vẫn còn một chút 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 hoàn toàn.
Đoạn mã này giúp cải thiện dần trải nghiệm người dùng nhưng không phải là yếu tố quan trọng. Ứng dụng web vẫn hoạt động nếu không chạy đoạn 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 một trang dựa trên phản hồi API. Vì tôi sử dụng cùng một đoạn HTML ban đầu được lưu vào bộ nhớ đệm 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ẫ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 dành riêng cho trang.
Trong mã tạo mẫu, cách tiếp cận của tôi là đưa một thẻ tập lệnh chứa chuỗi được thoát đúng cách.
const metadataScript = `<script>
self._title = '${escape(item.title)<}';>
/script`;
Sau đó, khi trang của tôi đã tải, tôi sẽ đọc chuỗi đó và cập nhật tiêu đề của tài liệu.
if (self._title) {
document.title = unescape(self._title);
}
Nếu muốn cập nhật các phần khác của siêu dữ liệu dành riêng cho trang trong ứng dụng web của riêng mình, 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
Tính năng cải tiến 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 đã tạo một PWA đáng tin cậy và 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 đó và tôi chuyển danh sách đó thành danh sách URL.
Bạn còn nhớ những thuộc tính dữ liệu đặc biệt mà tôi đã đề cập 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à một gợi ý trực quan cho người dùng về những gì họ nên mong đợi ở những trang đó – tôi không thực sự tắt 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].filte>r(card = {
return !cachedUrls.includes(card.dataset.cacheUrl);
});
const offlineHandle>r = () = {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '0.3';
}
};
const onli>neHandler = () = {
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 vừa trình bày cách tiếp cận của mình để xây dựng một PWA có nhiều trang. Bạn sẽ phải cân nhắc nhiều yếu tố 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ểm 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ề kiến trúc của riêng mình và tôi muốn giúp bạn tránh được một số khó khăn.
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ẽ lưu trữ các 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 đố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 bị kẹt với bố cục cũ. Hãy tưởng tượng một khách truy cập cũ sẽ cảm thấy khó chịu khi thấy cả trang cũ và trang mới.
Độ lệch của máy chủ / worker dịch vụ
Một điểm cần tránh khác liên quan đến việc máy chủ và worker dịch vụ của bạn không đồng bộ hoá. Phương pháp của tôi là sử dụng JavaScript đẳng cấu để cùng một mã được 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 có thể.
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à tạo mẫu tương đương trong máy chủ và worker dịch vụ của mình.
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 đó? Có thể xảy ra đủ loại lỗi, 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à 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ệ.
Trường hợp xấu nhất: Lỗi định tuyến
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 do trình chạy dịch vụ của bạn. Một trang web có đầy bố cục vô hồn 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 trong việc này! Các mẹo sau đây có thể giúp bạn tránh những cạm bẫy đó:
Sử dụng các thư viện định tuyến và tạo mẫu có nhiều ngôn ngữ triển khai
Hãy thử sử dụng các thư viện định tuyến và tạo mẫu có các phương thứ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 ngôn ngữ 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 để giữ cho worker dịch vụ và máy chủ của bạn luôn đồng bộ.
Ư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 mẫu tuần tự có thể được truyền trực tuyến lần lượt. Không sao 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 phần đầu của HTML càng nhanh càng tốt.
Lưu cả nội dung tĩnh và nội dung động vào bộ nhớ đệm trong service worker
Để đạt được hiệu suất tốt nhất, bạn nên lưu trước vào bộ nhớ đệm tất cả các tài nguyên tĩnh quan trọng của trang web. 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. Việc sử dụng Workbox có nghĩa là 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 sản xuấ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 một phản hồi từ bộ nhớ đệm. Việc hiển thị ngay một phản hồi API được lưu vào bộ nhớ đệm thường có thể mang lại trải nghiệm tốt hơn cho người dùng so với việc chờ dữ liệu mới.