jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Service Workers · Phần 5 — Offline-first & app shell

Ghép mọi thứ thành app mở được khi mất mạng: kiến trúc app shell, xử lý navigation request, trang offline fallback, offline cho ảnh/API, và phát hiện trạng thái online/offline cho UX.

Đến đây bạn đã có đủ mảnh ghép: lifecycle (Phần 2), fetch interception (Phần 3), Cache API + strategies (Phần 4). Bài này ta lắp chúng thành thứ thực sự đáng giá: một app offline-first — mở được, điều hướng được, và trông tử tế ngay cả khi người dùng mất mạng hoàn toàn.

“Offline-first” không chỉ là “có cache”. Đó là một tư duy thiết kế: coi mạng là thứ không đáng tin và xây app để luôn có gì đó để hiển thị. Đây chính là điểm phân biệt một PWA thật với một website thường.


App shell là gì?

App shell là bộ khung tối thiểu để app hiển thị: HTML khung, CSS, JS, header, navigation, logo — mọi thứ không phụ thuộc dữ liệu. Ý tưởng: precache app shell lúc install, để lần mở nào cũng tức thì, kể cả offline; phần nội dung động (data) thì load sau theo strategy riêng.

┌─────────────────────────────┐
│  App Shell (precache)        │  ← header, nav, CSS, JS khung
│  ┌───────────────────────┐  │
│  │  Content (runtime)    │  │  ← data load theo network/SWR
│  └───────────────────────┘  │
└─────────────────────────────┘

Tách bạch hai lớp này giúp bạn áp đúng strategy: shell = cache-first (bất biến, có hash), content = network-first/SWR (cần tươi).


Precache app shell

const SHELL_CACHE = 'shell-v1';
const APP_SHELL = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/logo.svg',
  '/offline.html', // trang fallback — sẽ dùng ở dưới
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL))
  );
});

Trái tim của offline: xử lý navigation request

Đây là phần quan trọng nhất của offline-first và cũng hay bị làm sai. Khi người dùng gõ URL, click link, hay reload, trình duyệt gửi một navigation request (request.mode === 'navigate') để tải tài liệu HTML.

Nếu request này thất bại (offline) mà bạn không xử lý → người dùng thấy khủng long Chrome / trang lỗi của trình duyệt. Đó là thất bại lớn nhất của một PWA.

Chiến lược chuẩn cho navigation: network-first, fallback về trang offline.

async function handleNavigation(request) {
  try {
    // Ưu tiên mạng để luôn có nội dung mới nhất.
    const response = await fetch(request);
    return response;
  } catch (error) {
    // Mất mạng → thử cache trang đã lưu, không có thì trả offline.html.
    const cache = await caches.open(SHELL_CACHE);
    const cached = await cache.match(request);
    return cached || cache.match('/offline.html');
  }
}

self.addEventListener('fetch', (event) => {
  const { request } = event;

  if (request.mode === 'navigate') {
    event.respondWith(handleNavigation(request));
    return;
  }

  // ... các nhánh khác cho asset/ảnh/API (Phần 4)
});

Kiểu SPA: một shell cho mọi route

Với Single Page Application (React, Vue…), mọi route đều render từ một file index.html. Khi đó navigation request offline nên fallback về index.html đã precache, để app JS tự render route bằng client-side routing:

async function handleNavigationSPA(request) {
  try {
    return await fetch(request);
  } catch {
    const cache = await caches.open(SHELL_CACHE);
    // Mọi route offline → trả app shell, để JS tự xử lý route.
    return (await cache.match('/index.html')) || cache.match('/offline.html');
  }
}

Phân biệt rõ: với MWA/MPA (nhiều trang HTML thật), bạn cache từng trang và fallback từng trang. Với SPA, một index.html đóng vai shell cho tất cả. Chọn sai sẽ khiến offline hiện sai nội dung.


Trang offline.html đẹp

Trang fallback nên tự chứa (self-contained): inline CSS, không gọi tài nguyên ngoài (vì đang offline!).

<!DOCTYPE html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Bạn đang offline</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        display: grid;
        place-items: center;
        min-height: 100vh;
        margin: 0;
        background: #0d0d0d;
        color: #eee;
        text-align: center;
      }
      h1 { color: #c8ff00; }
      button {
        margin-top: 1rem;
        padding: 0.6rem 1.2rem;
        background: #c8ff00;
        border: 0;
        border-radius: 6px;
        font-weight: 600;
        cursor: pointer;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>📡 Mất kết nối</h1>
      <p>Bạn đang offline. Hãy kiểm tra mạng rồi thử lại.</p>
      <button onclick="location.reload()">Thử lại</button>
    </main>
  </body>
</html>

Offline cho ảnh và API

App shell mới chỉ là khung. Để trải nghiệm trọn vẹn, ta cần xử lý ảnh và dữ liệu offline.

Ảnh — cache-first, fallback placeholder:

async function handleImage(request) {
  const cache = await caches.open('images-v1');
  const cached = await cache.match(request);
  if (cached) return cached;
  try {
    const response = await fetch(request);
    cache.put(request, response.clone());
    return response;
  } catch {
    return cache.match('/img/placeholder.svg');
  }
}

API — network-first, fallback cache, rồi fallback JSON báo offline:

async function handleApi(request) {
  const cache = await caches.open('api-v1');
  try {
    const response = await fetch(request);
    cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await cache.match(request);
    return (
      cached ||
      new Response(JSON.stringify({ offline: true, data: [] }), {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      })
    );
  }
}

Trả JSON { offline: true } thay vì để fetch ném lỗi giúp UI biết được “đây là dữ liệu offline” và hiển thị banner phù hợp, thay vì crash.


Phát hiện online/offline cho UX

Service worker lo phần dữ liệu; phần giao diện báo trạng thái nằm ở trang chính. Dùng navigator.onLine và hai event:

function updateOnlineStatus() {
  const banner = document.getElementById('offline-banner');
  banner.hidden = navigator.onLine;
}

window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();

Cảnh báo: navigator.onLine chỉ biết máy có kết nối mạng hay không, không biết server có sống không (có thể connect Wi-Fi nhưng không ra Internet). Để chắc chắn, đôi khi cần một “ping” nhẹ tới một endpoint health-check. Nhưng cho phần lớn trường hợp, online/offline event là đủ tốt.


Kiểm thử offline cho đúng

  • DevTools → Network → Offline: cách nhanh nhất. Nhưng nhớ: nó chỉ ngắt mạng của trang, SW vẫn chạy.
  • DevTools → Application → Service Workers → Offline: ngắt cả mạng của service worker — sát thực tế hơn.
  • Tắt Wi-Fi thật + reload: bài kiểm tra cuối cùng. Đảm bảo bạn không thấy khủng long.
  • Test cả kịch bản lần đầu offline (chưa từng vào trang) — lúc này SW chưa cài, app sẽ không offline được. Đây là giới hạn cố hữu: offline chỉ hoạt động sau lần truy cập đầu tiên thành công.

Tóm tắt

  • Offline-first là tư duy: coi mạng không đáng tin, luôn có gì đó để hiển thị.
  • App shell (khung không phụ thuộc data) được precache → mở tức thì, kể cả offline; content load runtime theo strategy riêng.
  • Navigation request (mode === 'navigate') là mấu chốt: network-first, fallback cache/offline.html (MPA) hoặc index.html (SPA) — để không bao giờ thấy trang lỗi trình duyệt.
  • Xử lý riêng ảnh (cache-first + placeholder) và API (network-first + cache + JSON offline).
  • Báo trạng thái bằng navigator.onLine + event online/offline (biết giới hạn của nó).
  • Test offline bằng DevTools tắt mạng thật; nhớ offline chỉ chạy sau lần truy cập đầu.

Bài tập thực hành

Bài 1 — Offline fallback cho navigation (cơ bản)

Thêm offline.html vào app shell và cài handleNavigation. Bật Offline trong DevTools rồi điều hướng tới một URL chưa từng vào.

Hướng dẫn: Bạn phải thấy offline.html thay vì khủng long. Nếu vẫn thấy trang lỗi: kiểm tra (a) offline.html đã nằm trong APP_SHELL chưa, (b) SW đã active và control trang chưa (Phần 2), (c) request.mode === 'navigate' có khớp không. Đây là bug offline phổ biến nhất.

Bài 2 — App shell hoàn chỉnh (trung bình)

Dựng một app nhỏ: header + nav (shell) và một khu vực content load từ /api/posts (giả bằng file JSON). Precache shell, network-first cho API. Test offline.

Hướng dẫn: Online: shell + data hiện đầy đủ. Offline: shell hiện ngay tức thì, data hiện từ cache (nếu đã từng load) hoặc banner “dữ liệu offline”. Mở Application → Cache Storage xác nhận shell-v1api-v1 tách biệt. Cảm nhận sự khác biệt: shell luôn tức thì vì cache-first/precache.

Bài 3 — Banner online/offline + tự retry (nâng cao)

Thêm banner offline ở trang chính. Khi online event bắn, tự động refetch dữ liệu để cập nhật UI mà không cần người dùng reload.

Hướng dẫn: Lắng nghe window.addEventListener('online', refetchData). Trong refetchData, gọi lại API và render lại danh sách. Trải nghiệm “mất mạng → có banner → có mạng lại → tự cập nhật” chính là UX cao cấp của PWA. Lưu ý chống gọi trùng nếu event bắn nhiều lần (debounce nhẹ). Điều này nối tiếp tự nhiên sang Phần 7 (Background Sync) khi ta cần gửi dữ liệu đi lúc có mạng lại.


Phần tiếp theo: Advanced caching & versioning — cache versioning đúng cách, dọn cache cũ khi activate, giới hạn dung lượng và expiration, cùng những cạm bẫy với opaque response và CORS.