Service Worker trong TypeScript

Service Worker trong TypeScript

Bài viết này giải thích về Service Worker trong TypeScript.

Chúng tôi sẽ giải thích về Service Worker trong TypeScript, bao gồm cả ví dụ thực tế.

YouTube Video

Service Worker trong TypeScript

Service Worker là một 'proxy yêu cầu' nằm giữa trình duyệt và mạng. Nó cho phép chặn fetch, kiểm soát bộ nhớ đệm, hỗ trợ offline và xử lý nền (đồng bộ, push). Sử dụng TypeScript giúp đảm bảo an toàn kiểu và tăng khả năng bảo trì.

Thiết lập TypeScript

tsconfig.json (Bật các loại WebWorker)

Hãy xem ví dụ về cách bật loại WebWorker trong tsconfig.json.

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • Bằng cách thêm WebWorker vào mảng lib, bạn có thể sử dụng các kiểu như ServiceWorkerGlobalScope.
  • DOMWebWorker có các kiểu dữ liệu khác nhau, vì vậy việc tách riêng cấu hình tsconfig.json cho trình duyệt (ứng dụng chính) và Service Worker là thông lệ phổ biến.
  • Các tệp Service Worker cuối cùng sẽ được xuất ra đường dẫn khớp với phạm vi (scope) (thường là gốc trang web /sw.js).
  • Vì lý do bảo mật, Service Worker chỉ chạy trên HTTPS (hoặc localhost).

Mã đăng ký bên phía trình duyệt

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • Quá trình này sẽ đăng ký một Service Worker. scope đề cập đến phạm vi các đường dẫn mà Service Worker có thể kiểm soát. Ví dụ, nếu bạn đặt /sw.js trực tiếp dưới thư mục gốc và đặt scope là thư mục gốc (/), bạn có thể kiểm soát tất cả tài nguyên của toàn bộ trang web. Ngược lại, nếu bạn chỉ định một thư mục cụ thể như /app/, chỉ những nội dung trong thư mục đó sẽ được kiểm soát.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting biểu thị trạng thái khi một Service Worker mới đã được cài đặt và đang chờ kích hoạt. Ở giai đoạn này, các trang hiện tại vẫn được kiểm soát bởi Service Worker cũ, nên thường sẽ hỏi ý kiến xác nhận người dùng, và sau khi được chấp thuận, gọi skipWaiting() để ngay lập tức kích hoạt Service Worker mới. Điều này cho phép bạn cập nhật các thay đổi mới nhất mà không cần chờ tải lại trang kế tiếp.
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound được kích hoạt khi quá trình cài đặt một Service Worker mới bắt đầu. Khi sự kiện này xảy ra, một worker mới sẽ được đặt trong registration.installing, vì vậy bạn có thể giám sát sự thay đổi trạng thái (statechange) để phát hiện khi cài đặt đã hoàn tất (installed). Hơn nữa, nếu navigator.serviceWorker.controller tồn tại, điều đó có nghĩa là một Service Worker cũ đã kiểm soát trang, vì vậy đây là cơ hội để thông báo cho người dùng về sự tồn tại của phiên bản mới.
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • Sự kiện controllerchange sẽ được gọi tại thời điểm Service Worker mới bắt đầu kiểm soát trang hiện tại. Tải lại trang vào thời điểm này sẽ áp dụng ngay các quy trình và chiến lược bộ nhớ cache mới. Tuy nhiên, việc tự động tải lại có thể làm giảm trải nghiệm người dùng, vì vậy chỉ nên tải lại sau khi đã được sự đồng ý của người dùng.
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • Bằng cách cho Service Worker nhận postMessage({ type: 'SKIP_WAITING' }) từ phía client và sau đó gọi self.skipWaiting(), bạn có thể kích hoạt việc cập nhật.

Khai báo Scope trong sw.ts

Tiếp theo, hãy xem ví dụ về Service Worker thực hiện bộ nhớ đệm app shell điển hình.

Khi sử dụng Service Worker trong TypeScript, việc gán đúng kiểu cho self là rất hữu ích.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • Trong TypeScript, self mặc định được xem là kiểu any, do đó nếu không khai báo kiểu bổ sung, bạn sẽ không có tính năng tự động gợi ý hoặc kiểm tra kiểu cho các API riêng của Service Worker như skipWaiting() hoặc clients.
  • Chỉ định ServiceWorkerGlobalScope giúp có gợi ý tự động, ngăn sử dụng sai và cho phép phát triển an toàn hơn tách biệt với script DOM thông thường.

Service Worker cơ bản (Cài đặt/Kích hoạt/Lấy dữ liệu)

Nó minh họa quản lý phiên bản cache đơn giản, precache khi cài đặt, xóa cache cũ khi kích hoạt và các chiến lược cache khi lấy dữ liệu (ưu tiên cache cho tài nguyên tĩnh, ưu tiên mạng cho API).

sw.ts (Thiết lập tối thiểu + Bộ khung Cache)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • Trong sự kiện install, tài nguyên tĩnh (App Shell) của ứng dụng sẽ được lưu vào bộ nhớ đệm trước. Bằng cách gọi self.skipWaiting(), Service Worker mới sẽ được kích hoạt ngay lập tức, cho phép sử dụng bộ nhớ đệm mới nhất mà không cần chờ truy cập tiếp theo.
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • Trong sự kiện activate, các phiên bản bộ nhớ đệm cũ sẽ bị xóa và Service Worker luôn được cập nhật mới nhất. Ngoài ra, bằng cách gọi self.clients.claim(), Service Worker mới có thể kiểm soát tất cả các client mà không cần đợi trang tải lại.
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • Trong fetch, bạn có thể chặn yêu cầu và kiểm soát phản hồi. Bạn có thể thực hiện các chiến lược như ưu tiên cache hoặc ưu tiên mạng, hữu ích cho hỗ trợ offline và hiệu suất.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • Nếu nhận được SKIP_WAITING, gọi self.skipWaiting() sẽ kích hoạt ngay lập tức Service Worker đang chờ. Kết quả là phiên bản mới sẽ được áp dụng từ lần request tiếp theo mà không cần tải lại trang.

Tổng quan các chiến lược cache thực tế

cache-first

Cache-first kiểm tra cache trước và trả về phản hồi ngay nếu có. Nếu không có, nó lấy dữ liệu từ mạng và lưu vào cache. Điều này phù hợp với các tệp tĩnh.

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • Đoạn mã này minh họa cách triển khai cache-first. Nếu có cache, nó trả về; nếu không, sẽ lấy từ mạng và lưu vào cache. Phù hợp với tài nguyên tĩnh ít thay đổi như hình ảnh hoặc CSS.

network-first

Network-first ưu tiên mạng trước, nếu thất bại sẽ quay về cache. Phù hợp cho API nơi độ mới dữ liệu rất quan trọng.

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • Đoạn mã này minh họa cách triển khai network-first. Nếu nhận được phản hồi mạng, nó lưu vào cache; nếu thất bại, trả về bản đã lưu trong cache. Phù hợp cho tài nguyên cần dữ liệu mới như bài báo hoặc phản hồi API.

stale-while-revalidate

stale-while-revalidate trả về dữ liệu từ bộ nhớ đệm trước và đồng thời cập nhật nó từ mạng trong nền. Điều này cân bằng giữa tốc độ phản hồi và độ mới.

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • Đoạn mã này trả về cache ngay nếu có, đồng thời lấy dữ liệu mới từ mạng ở chế độ nền để cập nhật cache. Mang lại phản hồi nhanh cho người dùng và sử dụng nội dung cập nhật cho lần truy cập tiếp theo, phù hợp với UI hoặc truyền dữ liệu nhẹ.

Tối ưu hóa luồng cập nhật (Thông báo và tải lại an toàn)

Các cập nhật của Service Worker không xảy ra ngay lập tức; phiên bản mới sẽ chờ đến khi tất cả tab hiện tại được đóng lại.

Ở đây, chúng ta xây dựng hệ thống thông báo cho client khi bản mới đã sẵn sàng và tải lại trang an toàn dựa trên thao tác người dùng.

Thông báo cho client từ phía Service Worker khi phiên bản mới đã sẵn sàng.

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • Trong đoạn mã này, notifyClientsUpdated được gọi vào cuối sự kiện activate để thông báo cho tất cả client đã kết nối rằng phiên bản mới đã sẵn sàng. clients.claim() là một phương thức giúp đưa các trang (client) đang mở vào sự kiểm soát của Service Worker mới kích hoạt ngay lập tức. Thông thường, Service Worker chỉ bắt đầu kiểm soát trang ở lần tải lại kế tiếp, nhưng với clients.claim(), bạn có thể kiểm soát lập tức mà không cần tải lại.

Hiển thị giao diện cập nhật trên client, và reload nhờ thao tác của người dùng

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • Client nhận SW_UPDATED qua sự kiện message và hiển thị thông báo cập nhật trên UI. Khi người dùng chọn tải lại, window.location.reload() sẽ được thực thi, cập nhật HTML, CSS và các tài nguyên khác trên trang lên phiên bản mới nhất. Điều này đảm bảo rằng bộ nhớ đệm và quyền kiểm soát của Service Worker (sau khi dùng clients.claim()) sẽ được áp dụng cho toàn bộ trang.

Dự phòng khi offline

Chuẩn bị /offline.html cho điều hướng quan trọng và cung cấp giao diện tối giản vẫn truyền tải được ý nghĩa dù không có ảnh hoặc font. Nếu gọi API thất bại, hãy hiển thị trạng thái cache cuối cùng nếu có thể và thử refetch ở chế độ nền để cải thiện UX.

Ví dụ triển khai

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • Precache /offline.html trong sự kiện install để luôn trả về một trang tối thiểu khi không có mạng.
  • Trong sự kiện fetch, bạn có thể theo dõi các yêu cầu chuyển hướng với request.mode === 'navigate' và đặc biệt nhắm mục tiêu tới các lần chuyển trang.
  • Chuyển sang /offline.html khi mạng thất bại, đảm bảo vẫn hiển thị nội dung khi offline.

Giao tiếp giữa client và Service Worker.

Service Worker hoạt động độc lập với vòng đời của trang, nên nhắn tin hai chiều rất quan trọng để thông báo trạng thái và thực thi các lệnh. Việc xác định kiểu cho các tin nhắn giúp tránh gửi sai tin nhắn, hỗ trợ hoàn thiện mã và khiến việc triển khai của bạn chắc chắn hơn.

Ví dụ mã

  1. Định nghĩa các loại tin nhắn
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient là loại tin nhắn gửi từ Service Worker đến client.
  • ClientToSw là loại tin nhắn gửi từ client đến Service Worker.
  • Điều này giúp bạn làm rõ các loại sự kiện có thể trao đổi qua giao tiếp hai chiều.
  1. Xử lý phía Service Worker
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • Service Worker nhận tin nhắn từ client và xử lý tương ứng theo từng loại.
  • Với CLEAR_CACHE, nó xóa cache và thông báo cho tất cả client với CACHE_CLEARED.
  • Với PING, nó trả lời cho client gốc bằng tin nhắn PING kèm timestamp.
  1. Thông báo cho tất cả client từ Service Worker
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • Dùng clients.matchAll để lấy tất cả tab cửa sổ.
  • Bằng cách gửi postMessage cho từng tab, bạn có thể phát broadcast tin nhắn.
  • Điều này có thể dùng để thông báo cập nhật (như SW_UPDATED) và thông báo lỗi.
  1. Xử lý phía client
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • Bằng cách gửi PING từ client và nhận phản hồi từ Service Worker, bạn có thể xác minh rằng giao tiếp hai chiều hoạt động đúng. Điều này giúp kiểm tra trạng thái kết nối và xử lý tin nhắn dễ dàng hơn.
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

Lợi ích của giao tiếp có kiểu

  • Sử dụng tin nhắn có kiểu giúp làm rõ loại tin nhắn có thể gửi và nhận, đồng thời gợi ý tự động và kiểm tra kiểu tăng độ an toàn.
  • postMessage cho phép giao tiếp một-một, và broadcast cho phép một-nhiều.
  • Bạn dễ dàng triển khai các tính năng quan trọng như thông báo cập nhật (SW_UPDATED), quản lý cache (CACHE_CLEARED), kiểm tra sức khỏe kết nối (PING).

Tóm tắt

  • Sử dụng TypeScript mang lại an toàn kiểu cho các lệnh API và giao tiếp của Service Worker, cải thiện hiệu quả và khả năng bảo trì.
  • Hiểu rõ các sự kiện vòng đời install, activate, fetch và chọn chiến lược cache phù hợp (như cache-first hoặc network-first) cho từng tình huống giúp nâng cao trải nghiệm người dùng.
  • Đối với vận hành, việc hiểu về quản lý version cache và luồng cập nhật (updatefound, waiting, SKIP_WAITING, v.v.) là rất quan trọng.
  • Bằng cách áp dụng nhắn tin có kiểu cho giao tiếp giữa client và Service Worker, bạn có thể ngăn ngừa việc thực hiện sai và thiết lập một hệ thống dễ mở rộng, bảo trì lâu dài.

Bạn có thể làm theo bài viết trên bằng cách sử dụng Visual Studio Code trên kênh YouTube của chúng tôi. Vui lòng ghé thăm kênh YouTube.

YouTube Video