Làm Progressive Web App cho người mới

Bài viết được sự cho phép của tác giả Lưu Bình An

Giới thiệu

Trong lúc lầm Progressive Web App (PWA), chúng ta cần nhớ những điều sau

  • Không quan trọng user đang dùng trình duyệt gì
  • Hỗ trợ tốt trên nhiều loại thiết bị desktop, mobile, tablet bằng responsive
  • Sử dụng service worker để có thể làm việc offline hoặc khi mạng chậm
  • Có khả năng cài đặt, sử dụng web app manifest và sự kiện beforeinstallprompt để báo user biết có thể install

Ứng dụng sẽ thực hành là trang dự báo thời tiết

Làm Progressive Web App cho người mới

Chúng ta sẽ tìm hiểu các vấn đề Làm sao để tạo và thêm file web app manifest Cung cấp một trải nghiệm cơ bản khi không có mạng Làm sao để làm cho app có thể install Bạn cần gì để follow tuts này

  • Phiên bản mới nhất của Chrome
  • Kiến thức căn bản HTML/CSS/Javascript và Chrome DevTools
  • Đăng ký Dark Sky API để có 1 API key, dữ liệu thời tiết sẽ do API này cung cấp

Sao khi đã có 1 API key, chúng ta có thể kiểm tra xem nó có hoạt động hay không bằng cách mở URL này trên trình duyệt

https://api.darksky.net/forecast/DARKSKY_API_KEY/40.7720232,-73.9732319

Download Source code ví dụ

Vì chúng ta sử dụng Glitch để làm, nên không cần download bộ source về làm gì

  1. Trước hết mở trang https://glitch.com/, đăng ký 1 tài khoản trên đây
  2. Chọn New Project, sau đó chọn vào Clone from Git Repo
  3. Paste đường dẫn https://github.com/googlecodelabs/your-first-pwapp.git vào trong hộp thoại popup rồi click OK
  4. Sau khi repo đã được load về, mở file .env lên và điền vào API key đã đăng ký
  5. Click vào nút Show > In a new window để xem kết quả, nó sẽ theo cấu trúc là <tên project>.glitch.me
  Cải thiện hiệu năng cho JavaScript Web Application chỉ trong vòng vài bước đơn giản
  Tại sao nên chọn Python để lập trình Web App?

Đánh giá bằng Lighthouse

Trước tiên chúng ta đánh giá sơ bộ trang hiện tại bằng công cụ Lighthouse có sẵn của Chrome. Trên DevTool sẽ có một tab là Audit ở cuối. Nó sẽ cho kết quả đánh giá performance, accessibility, PWA và một số thứ linh tinh khác nữa

Làm Progressive Web App cho người mới

Để chạy đánh giá 1. Mở DevTool lên, chọn vào Audits tab 2. Dùng tất cả các thiết đặt mặc định nếu ko có ý kiến gì khác 3. Click Run audit, đợi chút nó sẽ cho kết quả

Làm Progressive Web App cho người mới

Chúng ta tập trung fix mấy cái bị báo đỏ

Thêm web app manifest

Nó là một file JSON cho phép chúng ta config một số thứ khi xuất hiện trên thiết bị của user

  • Mở cửa sổ mới hay không display
  • Trang mặc định sẽ mở (nếu bạn không muốn là trang index) start_url
  • Icon sẽ hiển thị icons
  • Tên short_name
  • Splash screen nameiconscolors
  • Mở ngang hay dọc orientation
  • Tạo một file public/manifest.json rồi copy nội dung sau vào đó
{
  "name": "Weather",
  "short_name": "Weather",
  "icons": [{
    "src": "/images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    }, {
      "src": "/images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    }, {
      "src": "/images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    }, {
      "src": "/images/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }, {
      "src": "/images/icons/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }, {
      "src": "/images/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#3E4EB8",
  "theme_color": "#2F3BA2"
}

Trình duyệt Chrome bắt buộc cung cấp ít nhất 2 icon kích thước 192x192px và 512x512px

Bổ sung file này vào trong public.index.html

<link rel="manifest" href="/manifest.json">

Trên cửa sổ DevTools, chọn tab Application có thể kiểm tra file manifest đã load thành công chưa

Làm Progressive Web App cho người mới

Trình duyệt Safari trên iOS chưa hỗ trợ file manifest này thì có thể bổ sung một số tag sau

<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Weather PWA">
<link rel="apple-touch-icon" href="/images/icons/icon-152x152.png">

Nếu cửa số Audit PWA có thông báo “Does not set an address-bar theme color”, chọn màu thanh address cho phù hợp với màu thương hiệu

<meta name="theme-color" content="#2F3BA2" />

Offline mức căn bản

Bạn có thể tắt mạng, sau đó mở trang google drive, bạn sẽ thấy vẫn xem được danh sách file, folder trong drive, tất nhiên không mở xem chỉnh sửa mấy file này được, nhưng vẫn đảm bảo có những trải nghiệm “không quá tệ”

Service worker chỉ chạy trên trang web truy cập qua giao thức https

Những tính năng mà service worker cung cấp, câng nhắc nó như một dạng “nice-to-have”, đừng quá đặt nặng và xem service worker như đấng cứu thế, và cũng đừng chém xuyên lục địa về những tính năng siêu việt của service worker.

Đăng ký service worker

public/index.html

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
      .then(reg => {
        console.log('Service worker registered.', reg)
      })
  })
}

Service worker có scope (phạm vi hoạt động) theo vị trí file, nghĩa là nếu nhét nó vào file rồi đưa file này trong thư mục con scripts nó sẽ không can thiệp được gì những đoạn script nằm ngoài thư mục gốc, nếu đặt ở thư mục gốc, toàn bộ thư mục con sẽ kế thừa được service worker đăng ký ở thư mục gốc

Cache cái gì? Chúng ta có 1 file là public/offline.html, nếu không có mạng, chúng ta sẽ hiển thị file này, cache file này xuống trình duyệt là hợp lý.

public/service-worker.js

const FILES_TO_CACHE = [
  '/offline.html',
]

Tiếp theo, trong sự kiện install, chúng ta đưa file này vào danh sách muốn cache

public/service-worker.js

evt.waitUntil(
  caches.open(CACHE_NAME).then((cache) => {
    console.log('[ServiceWorker] Pre-caching offline page');
    return cache.addAll(FILES_TO_CACHE);
  })
)

Toàn bộ các sự kiện của service worker sẽ được để cập ở phần sau

Mở DevTools, trên tab Application, chọn vào service worker để xem những đứa nào đã đăng ký thành công, như hình bên dưới chưa đăng ký gì cả

Làm Progressive Web App cho người mới

Với đoạn code đăng ký thành công, chúng ta có danh sách các service worker đã đăng ký được, không chỉ một mà có thể đăng ký nhiều nhé

Làm Progressive Web App cho người mới

Chổ status, cái nút tròn xanh xanh cho biết nó đang hoạt động, con số kế bên (34251) sẽ tự động tăng khi update service worker mới

Trên sự kiện activate, chúng ta sẽ thực hiện xóa các cache trước đó

public/service-worker.js

evt.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(keyList.map((key) => {
        if (key !== CACHE_NAME) {
          console.log('[ServiceWorker] Removing old cache', key);
          return caches.delete(key);
        }
      }));
    })
);

Ấn F5 trên cửa sổ để refresh lại trang, con số bên cạnh status sẽ cho biết đã cập nhập mới

Xử lý network request bị fail

Khi gọi fetch, service worker sẽ là thằng đứng giữa, nếu có mạng thì gọi lên server, còn không, thì gọi lại giá trị cache trước đó

Làm Progressive Web App cho người mới

public/service-worker.js

// thêm xử lý sự kiện fetch
if (evt.request.mode !== 'navigate') {
  return;
}
evt.respondWith(
    fetch(evt.request)
        .catch(() => {
          return caches.open(CACHE_NAME)
              .then((cache) => {
                return cache.match('offline.html');
              });
        })
);

Nó giống như khái niệm middleware, incepter của axios vậy thôi.

Đưa đoạn fetch vào bên trong evt.respondWith() sẽ chặn trình duyệt tự ý đi mà không nói với ai, chúng ta sẽ dùng người trung gian để đi nói chuyện với thế giới. Nếu không sử dụng evt.respondWith() thì cache trước đó sẽ chẳng có tác dụng vì hết.

Bên dưới của tab Application, sẽ cho biết những gì đã được cache lại bên dưới trình duyệt

Làm Progressive Web App cho người mới

Để giả lập tình trạng mất mạng, quay lại Service workers, click chọn vào checkbox Offline. F5 nếu thấy hình em gấu này là coi như thành công.

Trên cửa sổ Service Workers có một số option khác ngoài Offline

  • Update on reload luôn luôn load lại service worker khi reload
  • Bypass for network bỏ qua service worker, chạy như không có

Làm Progressive Web App cho người mới

Trên điện thoại có thể dùng chế độ máy bay để tắt mạng và xem trang web nó sẽ ra hình thù thế nào.

Service worker life cycle

install

Sự kiện này sẽ bắn ra khi worker chạy, và chỉ được tạo ra một lần cho mỗi service worker

Làm Progressive Web App cho người mới

Thông thường nó được sử dụng để cache mọi thứ cho app

activate

Sau khi install, mỗi lần nó khởi động nó sẽ bắn ra sự kiện activate, tức là bạn truy cập lại trang web đã vào trước đó. Được dùng để xóa cache trước đó

fetch

Sự kiện này cho phép chúng ta can thiệp vào mọi network request, trước khi ra khỏi nhà, phải trình báo ở đây

Xem các trường hợp sử dụng ở đây

Offline mức cao cấp hơn một chút

Ở trên chúng ta chỉ hiển một trang HTML, thông báo với user là mày đang offline, giờ chúng ta sẽ cho nó thấy cái như là có mạng luôn, giống như khi bạn vào Google Drive lúc ko có mạng đó.

Quay lại với ví dụ, app chúng ta sử dụng object caches bên trong window, tuy nhiên không phải browser nào cũng hỗ trợ window.caches, chúng ta sẽ update hàm getForecastFromCache để rơi vào tình huống đó, vẫn ko ảnh hưởng gì tới app

public/scripts/app.js

// kiểm tra trước khi thực hiện những việc tiếp theo
if (!('caches' in window)) {
  return null;
}
const url = `${window.location.origin}/forecast/${coords}`;
return caches.match(url)
    .then((response) => {
      if (response) {
        return response.json();
      }
      return null;
    })
    .catch((err) => {
      console.error('Error getting data from cache', err);
      return null;
    });

Sửa tiếp updateData() public/scripts/app.js

getForecastFromCache(location.geo)
    .then((forecast) => {
      renderForecast(card, forecast);
    });

App sẽ gọi lấy dữ liệu từ 1 trong 2 hàm, thông qua cache hoặc fetch từ API. Với dữ liệu trả về, chúng ta sẽ xử lý để render lại giao diện nếu cần thiết

public/scripts/app.js

// nếu dữ liệu này đã mới rồi thì ko cần update làm gì
if (lastUpdated >= data.currently.time) {
  return;
}

Để App có hình hài lúc mất mạng như lúc có mạng, chúng ta phải cache lại toàn bộ những resource cần thiết khác, ngoài data get từ API

public/service-worker.js

const FILES_TO_CACHE = [
  '/',
  '/index.html',
  '/scripts/app.js',
  '/scripts/install.js',
  '/scripts/luxon-1.11.4.js',
  '/styles/inline.css',
  '/images/add.svg',
  '/images/clear-day.svg',
  '/images/clear-night.svg',
  '/images/cloudy.svg',
  '/images/fog.svg',
  '/images/hail.svg',
  '/images/install.svg',
  '/images/partly-cloudy-day.svg',
  '/images/partly-cloudy-night.svg',
  '/images/rain.svg',
  '/images/refresh.svg',
  '/images/sleet.svg',
  '/images/snow.svg',
  '/images/thunderstorm.svg',
  '/images/tornado.svg',
  '/images/wind.svg',
];

Vì danh sách mấy file này chúng ta thêm bằng tay, nếu sao này chúng ta thay đổi danh sách file này, phải sửa tay tên CACHE_NAME, ví dụ như từ

const CACHE_NAME = 'static-cache-v2';
// đổi khi thay đổi danh sách file muốn cache
const CACHE_NAME = 'static-cache-v3';

Nếu chỉ sửa mỗi cái tên file cũng phải đổi lại tên cache thì quá tốn công, chưa kể phía user phải down lại toàn bộ resource. Workbox do google phát triển sẽ giải quyết dùm chúng ta vấn đề này, trong quá trình build, chỉ những file nào đã thay đổi mới cần update phía người dùng

Để đảm bảo trong quá trình activate, nó ko vô tình xóa dữ liệu của chúng ta, bên trong hàm bắt sự kiện activate

public/service-worker.js

if (key !== CACHE_NAME && key !== DATA_CACHE_NAME) {

Trong sự kiện fetch của service worker, chúng ta cũng thực hiện cập nhập, chúng ta luôn ưu tiên lấy data từ API trước, chỉ khi ko thể gọi được API, chúng ta mới lôi thằng cache ra xài

public/service-worker.js

if (evt.request.url.includes('/forecast/')) {
  console.log('[Service Worker] Fetch (data)', evt.request.url);
  evt.respondWith(
      caches.open(DATA_CACHE_NAME).then((cache) => {
        return fetch(evt.request)
            .then((response) => {
              // nếu lấy được data từ API, xài luôn và lưu xuống cache một bản
              if (response.status === 200) {
                cache.put(evt.request.url, response.clone());
              }
              return response;
            }).catch((err) => {
              // lỗi network thì đưa về cache
              return cache.match(evt.request);
            });
      }));
  return;
}
evt.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(evt.request)
          .then((response) => {
            return response || fetch(evt.request);
          });
    })
);

Chúng ta bỏ phần điều kiện evt.request.mode !== 'navigate' vì không chỉ muốn lấy cache cho network request, chúng ta muốn cache mọi thứ từ HTML, CSS, script, image,…

Giờ gần như chúng ta đã đưa hết những gì cần thiết xuống cache, kiểm tra xem kết quả trên DevTool đã cache thành công chưa nhé

Làm Progressive Web App cho người mới

Thêm phần cài đặt

App thì phải cài đặt được chứ, chúng ta sẽ thêm một nút cho phép user “Cài đặt” vào màn hình điện thoại luôn

Làm Progressive Web App cho người mới

User nào thông minh như mình thì sẽ biết cách vào menu của Chrome (hoặc Safari) rồi chọn Add to Homescreen, tuy nhiên đâu ai thông minh như mình đâu!

Thêm file install.js

public/index.html

<script src="/scripts/install.js"></script>

Điều kiện để user có thể cài đặt được

Làm Progressive Web App cho người mới

Khi mấy điều kiện này đã có đủ, Chrome sẽ bắn ra sự kiện beforeinstallprompt, chúng ta nắm lấy sự kiện này

public/scripts/install.js

window.addEventListener('beforeinstallprompt', saveBeforeInstallPromptEvent);

Bên trong hàm saveBeforeInstallPromptEvent chúng ta cho hiện nút cài đặt, lưu tạm cái sự kiện beforeinstallprompt để dành xài

public/scripts/install.js

deferredInstallPrompt = evt;
installButton.removeAttribute('hidden');

Khi user click vào nút Install, chúng ta gọi lại thằng beforeinstallprompt.prompt(), rồi ẩn nút này đi

public/scripts/install.js

deferredInstallPrompt.prompt();
evt.srcElement.setAttribute('hidden', true);

Cũng có thể bắt lấy lựa chọn mà user đã trả lời

public/scripts/install.js

deferredInstallPrompt.userChoice
    .then((choice) => {
      if (choice.outcome === 'accepted') {
        console.log('User accepted the A2HS prompt', choice);
      } else {
        console.log('User dismissed the A2HS prompt', choice);
      }
      deferredInstallPrompt = null;
    });

Khi thêm vào màn hình chính thành công, hay gọi hoa mỹ là Cài đặt thành công, trình duyệt bắn tiếp một sự kiện khác, chúng ta có thể túm lấy sự kiện này và thông báo gì đó nếu thích

window.addEventListener('appinstalled', logAppInstalled);

Tips: để CSS riêng cho trường hợp web đang chạy dạng “App” như vậy ta dùng display-mode

Code đầy đủ nè

@media all and (display-mode: standalone) {
  body {
    background-color: yellow;
  }
}

Your First Progressive Web App

Bài viết gốc được đăng tải tại Vui Lập Trình

Có thể bạn quan tâm:

Xem thêm Việc làm IT hấp dẫn tại TopDev