Xây dựng PWA tại Google, phần 1

Những điều mà nhóm Bulletin đã tìm hiểu về trình chạy dịch vụ trong quá trình phát triển một PWA.

Douglas Parker
Douglas Parker
Joel Riley
Joel Riley
Dikla Cohen
Dikla Cohen

Đây là bài đăng đầu tiên trong loạt bài đăng trên blog về những bài học mà nhóm Google Bulletin đã học được trong quá trình xây dựng một PWA hướng đến bên ngoài. Trong những bài đăng này, chúng tôi sẽ chia sẻ một số thách thức mà chúng tôi gặp phải, những phương pháp đã thực hiện để vượt qua những thách thức đó và lời khuyên chung để tránh các cạm bẫy. Đây không phải là thông tin tổng quan đầy đủ về PWA. Mục đích của chúng tôi là chia sẻ những bài học rút ra được từ kinh nghiệm của nhóm chúng tôi.

Trong bài đăng đầu tiên này, chúng ta sẽ tìm hiểu một chút thông tin cơ bản trước, sau đó tìm hiểu tất cả những điều chúng ta đã học được về worker dịch vụ.

Thông tin khái quát

Bulletin đang trong quá trình phát triển từ giữa năm 2017 đến giữa năm 2019.

Lý do chúng tôi chọn xây dựng một PWA

Trước khi đi sâu vào quy trình phát triển, hãy cùng xem xét lý do việc xây dựng một PWA là một lựa chọn hấp dẫn cho dự án này:

  • Có khả năng lặp lại nhanh chóng. Đặc biệt có giá trị vì Bản tin sẽ được thí điểm ở nhiều thị trường.
  • Một cơ sở mã. Người dùng của chúng tôi được phân chia gần như đồng đều giữa Android và iOS. PWA có nghĩa là chúng tôi có thể tạo một ứng dụng web duy nhất hoạt động được trên cả hai nền tảng. Điều này đã làm tăng tốc độ và tác động của nhóm.
  • Được cập nhật nhanh chóng và độc lập với hành vi của người dùng. PWA có thể tự động cập nhật, giúp giảm số lượng ứng dụng đã lỗi thời. Chúng tôi có thể đẩy các thay đổi về phần phụ trợ có thể gây lỗi với thời gian di chuyển rất ngắn cho ứng dụng khách.
  • Dễ dàng tích hợp với các ứng dụng của bên thứ nhất và bên thứ ba. Các hoạt động tích hợp như vậy là yêu cầu đối với ứng dụng. Với PWA, điều này thường chỉ có nghĩa là mở một URL.
  • Loại bỏ phiền hà khi cài đặt ứng dụng.

Khung của chúng tôi

Đối với Bản tin, chúng tôi đã sử dụng Polymer, nhưng mọi khung hiện đại, được hỗ trợ tốt đều sẽ hoạt động.

Những điều chúng tôi tìm hiểu được về trình chạy dịch vụ

Bạn không thể có PWA nếu không có worker dịch vụ. Trình chạy dịch vụ mang lại cho bạn nhiều quyền, chẳng hạn như các chiến lược lưu vào bộ nhớ đệm nâng cao, chức năng ngoại tuyến, đồng bộ hoá ở chế độ nền, v.v. Mặc dù trình chạy dịch vụ làm tăng thêm một số độ phức tạp, nhưng chúng tôi nhận thấy rằng lợi ích của trình chạy dịch vụ lớn hơn độ phức tạp tăng thêm.

Hãy tạo nếu bạn có thể

Tránh viết tập lệnh trình chạy dịch vụ theo cách thủ công. Việc viết trình chạy dịch vụ theo cách thủ công yêu cầu bạn phải quản lý các tài nguyên được lưu vào bộ nhớ đệm và viết lại logic phổ biến cho hầu hết các thư viện trình chạy dịch vụ, chẳng hạn như Workbox.

Tuy nhiên, do ngăn xếp công nghệ nội bộ, chúng tôi không thể sử dụng thư viện để tạo và quản lý worker dịch vụ. Đôi khi, những điều chúng tôi rút ra được dưới đây sẽ phản ánh điều đó. Hãy truy cập vào bài viết Những cạm bẫy đối với trình chạy dịch vụ không được tạo để đọc thêm.

Không phải thư viện nào cũng tương thích với trình chạy dịch vụ

Một số thư viện JS đưa ra các giả định không hoạt động như mong đợi khi được chạy bởi một trình chạy dịch vụ. Ví dụ: giả sử window hoặc document có sẵn hoặc sử dụng một API không có sẵn cho worker dịch vụ (XMLHttpRequest, bộ nhớ cục bộ, v.v.). Đảm bảo mọi thư viện quan trọng mà bạn cần cho ứng dụng đều tương thích với worker dịch vụ. Đối với PWA cụ thể này, chúng tôi muốn dùng gapi.js để xác thực, nhưng không thể thực hiện được vì công cụ này không hỗ trợ trình chạy dịch vụ. Tác giả thư viện cũng nên giảm hoặc xoá các giả định không cần thiết về ngữ cảnh JavaScript khi có thể để hỗ trợ các trường hợp sử dụng worker dịch vụ, chẳng hạn như tránh các API không tương thích với worker dịch vụ và tránh trạng thái toàn cục.

Tránh truy cập IndexedDB trong quá trình khởi chạy

Đừng đọc IndexedDB khi khởi chạy tập lệnh worker dịch vụ, nếu không bạn có thể gặp phải tình huống không mong muốn này:

  1. Người dùng có ứng dụng web với IndexedDB (IDB) phiên bản N
  2. Ứng dụng web mới được đẩy bằng IDB phiên bản N+1
  3. Người dùng truy cập PWA, thao tác này sẽ kích hoạt quá trình tải trình chạy dịch vụ mới xuống
  4. Trình chạy dịch vụ mới đọc từ IDB trước khi đăng ký trình xử lý sự kiện install, kích hoạt chu kỳ nâng cấp IDB từ N lên N+1
  5. Vì người dùng có ứng dụng cũ với phiên bản N, nên quá trình nâng cấp trình chạy dịch vụ bị treo vì các kết nối đang hoạt động vẫn mở cho phiên bản cũ của cơ sở dữ liệu
  6. Service worker bị treo và không bao giờ cài đặt

Trong trường hợp của chúng ta, bộ nhớ đệm đã mất hiệu lực khi cài đặt worker dịch vụ, vì vậy, nếu worker dịch vụ không bao giờ được cài đặt, người dùng sẽ không bao giờ nhận được ứng dụng đã cập nhật.

Tăng khả năng phục hồi

Mặc dù các tập lệnh trình chạy dịch vụ chạy ở chế độ nền, nhưng các tập lệnh này cũng có thể bị chấm dứt bất cứ lúc nào, ngay cả khi đang ở giữa các hoạt động I/O (mạng, IDB, v.v.). Mọi quy trình chạy trong thời gian dài đều có thể tiếp tục bất cứ lúc nào.

Trong trường hợp quá trình đồng bộ hoá tải các tệp lớn lên máy chủ và lưu vào IDB, giải pháp của chúng tôi cho việc tải lên bị gián đoạn một phần là tận dụng hệ thống có thể tiếp tục của thư viện tải lên nội bộ, lưu URL tải lên có thể tiếp tục vào IDB trước khi tải lên và sử dụng URL đó để tiếp tục tải lên nếu quá trình tải lên không hoàn tất trong lần đầu tiên. Ngoài ra, trước khi thực hiện bất kỳ thao tác I/O nào chạy trong thời gian dài, trạng thái sẽ được lưu vào IDB để cho biết vị trí của chúng ta trong quy trình cho mỗi bản ghi.

Không phụ thuộc vào trạng thái toàn cục

Vì trình chạy dịch vụ tồn tại trong một ngữ cảnh khác, nên nhiều biểu tượng mà bạn có thể mong đợi sẽ không xuất hiện. Rất nhiều mã của chúng tôi chạy trong cả ngữ cảnh window và ngữ cảnh worker dịch vụ (chẳng hạn như ghi nhật ký, cờ, đồng bộ hoá, v.v.). Mã cần phải có khả năng bảo vệ trước các dịch vụ mà mã đó sử dụng, chẳng hạn như bộ nhớ cục bộ hoặc cookie. Bạn có thể sử dụng globalThis để tham chiếu đến đối tượng toàn cục theo cách sẽ hoạt động trên tất cả ngữ cảnh. Ngoài ra, hãy sử dụng dữ liệu được lưu trữ trong các biến toàn cục một cách tiết kiệm, vì không có gì đảm bảo về thời điểm tập lệnh sẽ bị chấm dứt và trạng thái bị loại bỏ.

Phát triển cục bộ

Một thành phần chính của trình chạy dịch vụ là lưu các tài nguyên vào bộ nhớ đệm cục bộ. Tuy nhiên, trong quá trình phát triển, điều này hoàn toàn ngược lại với những gì bạn muốn, đặc biệt là khi các bản cập nhật được thực hiện một cách lười biếng. Bạn vẫn muốn cài đặt worker máy chủ để có thể gỡ lỗi các vấn đề với worker đó hoặc làm việc với các API khác như đồng bộ hoá trong nền hoặc thông báo. Trên Chrome, bạn có thể thực hiện việc này thông qua Công cụ của Chrome cho nhà phát triển bằng cách bật hộp đánh dấu Bypass for network (Bỏ qua cho mạng) (bảng điều khiển Application (Ứng dụng) > ngăn Service workers (Trình chạy dịch vụ)) ngoài việc bật hộp đánh dấu Disable cache (Tắt bộ nhớ đệm) trong bảng điều khiển Network (Mạng) để tắt cả bộ nhớ đệm. Để hỗ trợ nhiều trình duyệt hơn, chúng tôi đã chọn một giải pháp khác bằng cách thêm một cờ để tắt tính năng lưu vào bộ nhớ đệm trong worker dịch vụ (được bật theo mặc định trên các bản dựng dành cho nhà phát triển). Điều này giúp đảm bảo rằng các nhà phát triển luôn nhận được các thay đổi mới nhất mà không gặp vấn đề nào về bộ nhớ đệm. Bạn cũng phải thêm tiêu đề Cache-Control: no-cache để ngăn trình duyệt lưu bất kỳ tài sản nào vào bộ nhớ đệm.

Ngọn hải đăng

Lighthouse cung cấp một số công cụ gỡ lỗi hữu ích cho PWA. Công cụ này quét một trang web và tạo báo cáo về PWA, hiệu suất, khả năng tiếp cận, SEO và các phương pháp hay nhất khác. Bạn nên chạy Lighthouse ở chế độ tích hợp liên tục để được cảnh báo nếu vi phạm một trong các tiêu chí để trở thành PWA. Điều này thực sự đã xảy ra với chúng tôi một lần, khi trình chạy dịch vụ không cài đặt và chúng tôi không nhận ra điều đó trước khi đẩy vào kênh phát hành chính thức. Việc đưa Lighthouse vào quy trình CI sẽ giúp ngăn chặn điều đó.

Ưu tiên khả năng phân phối liên tục

Vì worker dịch vụ có thể tự động cập nhật, nên người dùng không thể giới hạn việc nâng cấp. Điều này làm giảm đáng kể số lượng ứng dụng lỗi thời trong môi trường tự nhiên. Khi người dùng mở ứng dụng của chúng ta, worker dịch vụ sẽ phân phát ứng dụng cũ trong khi tải ứng dụng mới xuống một cách lười biếng. Sau khi tải ứng dụng mới xuống, ứng dụng sẽ nhắc người dùng làm mới trang để truy cập vào các tính năng mới. Ngay cả khi người dùng bỏ qua yêu cầu này, thì vào lần tiếp theo họ làm mới trang, họ sẽ nhận được phiên bản mới của ứng dụng. Do đó, người dùng khá khó từ chối bản cập nhật theo cách tương tự như đối với ứng dụng iOS/Android.

Chúng tôi đã có thể đưa ra các thay đổi có thể gây lỗi trong phần phụ trợ với khoảng thời gian di chuyển rất ngắn cho ứng dụng. Thông thường, chúng tôi sẽ dành một tháng để người dùng cập nhật lên các ứng dụng mới trước khi thực hiện những thay đổi có thể gây lỗi. Vì ứng dụng sẽ phân phát khi đã lỗi thời, nên các ứng dụng cũ có thể tồn tại trong tự nhiên nếu người dùng không mở ứng dụng trong một thời gian dài. Trên iOS, trình chạy dịch vụ sẽ bị loại bỏ sau vài tuần nên trường hợp này sẽ không xảy ra. Đối với Android, bạn có thể giảm thiểu vấn đề này bằng cách không phân phát nội dung đã lỗi thời hoặc hết hạn nội dung theo cách thủ công sau vài tuần. Trên thực tế, chúng tôi chưa bao giờ gặp sự cố từ các ứng dụng cũ. Mức độ nghiêm ngặt mà một nhóm muốn áp dụng tuỳ thuộc vào trường hợp sử dụng cụ thể của họ, nhưng PWA mang lại tính linh hoạt cao hơn đáng kể so với ứng dụng iOS/Android.

Nhận giá trị cookie trong trình chạy dịch vụ

Đôi khi, bạn cần truy cập vào các giá trị cookie trong ngữ cảnh của worker dịch vụ. Trong trường hợp của chúng tôi, chúng tôi cần truy cập vào các giá trị cookie để tạo mã thông báo nhằm xác thực các yêu cầu API của bên thứ nhất. Trong worker dịch vụ, bạn không thể sử dụng các API đồng bộ như document.cookies. Bạn luôn có thể gửi thông báo đến các ứng dụng đang hoạt động (có cửa sổ) từ worker dịch vụ để yêu cầu các giá trị cookie, mặc dù worker dịch vụ có thể chạy ở chế độ nền mà không có ứng dụng nào có cửa sổ, chẳng hạn như trong quá trình đồng bộ hoá ở chế độ nền. Để giải quyết vấn đề này, chúng tôi đã tạo một điểm cuối trên máy chủ giao diện người dùng chỉ cần phản hồi giá trị cookie trở lại cho ứng dụng. Trình chạy dịch vụ đã thực hiện yêu cầu mạng đến điểm cuối này và đọc phản hồi để nhận các giá trị cookie.

Với bản phát hành Cookie Store API, bạn không cần phải sử dụng giải pháp này nữa đối với các trình duyệt hỗ trợ API này, vì API này cung cấp quyền truy cập không đồng bộ vào cookie của trình duyệt và có thể được worker dịch vụ sử dụng trực tiếp.

Những khó khăn đối với các trình thực thi dịch vụ không được tạo

Đảm bảo tập lệnh của worker dịch vụ thay đổi nếu có tệp tĩnh nào được lưu vào bộ nhớ đệm thay đổi

Một mẫu PWA phổ biến là để trình chạy dịch vụ cài đặt tất cả tệp ứng dụng tĩnh trong giai đoạn install, cho phép ứng dụng truy cập trực tiếp vào bộ nhớ đệm API Bộ nhớ đệm cho tất cả các lượt truy cập tiếp theo. Trình chạy dịch vụ chỉ được cài đặt khi trình duyệt phát hiện thấy tập lệnh trình chạy dịch vụ đã thay đổi theo một cách nào đó. Vì vậy, chúng ta phải đảm bảo rằng chính tệp tập lệnh trình chạy dịch vụ đã thay đổi theo một cách nào đó khi tệp được lưu vào bộ nhớ đệm thay đổi. Chúng tôi làm việc này theo cách thủ công bằng cách nhúng một hàm băm của nhóm tệp tài nguyên tĩnh trong tập lệnh trình chạy dịch vụ, vì vậy, mỗi bản phát hành đều tạo ra một tệp JavaScript trình chạy dịch vụ riêng biệt. Các thư viện trình chạy dịch vụ như Workbox sẽ tự động hoá quy trình này cho bạn.

Kiểm thử đơn vị

API của worker hoạt động bằng cách thêm trình nghe sự kiện vào đối tượng toàn cục. Ví dụ:

self.addEventListener('fetch', (evt) => evt.respondWith(fetch('/foo')));

Đây có thể là việc không dễ dàng khi kiểm thử vì bạn cần mô phỏng trình kích hoạt sự kiện, đối tượng sự kiện, chờ lệnh gọi lại respondWith(), sau đó chờ lời hứa, trước khi cuối cùng xác nhận kết quả. Một cách dễ dàng hơn để định cấu trúc đối tượng này là uỷ quyền tất cả phương thức triển khai cho một tệp khác. Tệp này được kiểm thử dễ dàng hơn.

import fetchHandler from './fetch_handler.js';
self.addEventListener('fetch', (evt) => evt.respondWith(fetchHandler(evt)));

Do những khó khăn trong việc kiểm thử đơn vị tập lệnh của worker dịch vụ, chúng tôi đã giữ cho tập lệnh worker dịch vụ cốt lõi ở mức tối giản nhất có thể, chia hầu hết quá trình triển khai thành các mô-đun khác. Vì các tệp đó chỉ là các mô-đun JS tiêu chuẩn, nên chúng có thể dễ dàng kiểm thử đơn vị hơn bằng các thư viện kiểm thử tiêu chuẩn.

Đón xem phần 2 và 3

Trong phần 2 và 3 của loạt bài viết này, chúng ta sẽ thảo luận về việc quản lý nội dung nghe nhìn và các vấn đề dành riêng cho iOS. Nếu bạn muốn hỏi thêm về việc xây dựng PWA tại Google, hãy truy cập vào hồ sơ tác giả để tìm hiểu cách liên hệ với chúng tôi: