Progressive Web Apps 2026: Offline-First với Service Worker, Workbox và Vue.js
Posted on: 4/26/2026 12:29:09 PM
Table of contents
- 1. PWA là gì và tại sao quan trọng?
- 2. Thiết lập PWA với Vue 3 và Vite
- 3. Service Worker — Bộ não của PWA
- 4. Chiến lược Caching
- 5. Web App Manifest — Biến web thành "app"
- 6. Custom Install Prompt với Vue 3
- 7. Push Notification
- 8. Background Sync — Xử lý offline form submit
- 9. PWA với Nuxt 4
- 10. So sánh PWA vs Native App vs Hybrid
- 11. Performance Checklist cho PWA
- 12. Kết luận
- Nguồn tham khảo
Người dùng kỳ vọng ứng dụng web hoạt động mượt như app native — tải nhanh, chạy offline, gửi push notification và cài đặt trực tiếp từ trình duyệt mà không qua App Store. Progressive Web Apps (PWA) biến kỳ vọng đó thành hiện thực, và năm 2026, mọi trình duyệt lớn đều hỗ trợ đầy đủ các API cốt lõi: Service Worker, Web App Manifest và Web Push — kể cả Safari trên iOS.
1. PWA là gì và tại sao quan trọng?
PWA là ứng dụng web được tăng cường bằng ba công nghệ cốt lõi: Service Worker (proxy mạng chạy nền, cho phép offline và caching), Web App Manifest (file JSON mô tả metadata để trình duyệt cho phép "cài đặt"), và HTTPS (bắt buộc vì Service Worker can thiệp vào network request). Kết hợp lại, chúng tạo ra trải nghiệm app-like mà không cần native code.
flowchart TB
A["Người dùng truy cập Web App"] --> B{"Service Worker
đã cài?"}
B -->|"Chưa"| C["Tải trang bình thường
+ Đăng ký SW"]
B -->|"Rồi"| D["SW intercept request"]
D --> E{"Có trong Cache?"}
E -->|"Có"| F["Trả từ Cache
(instant load)"]
E -->|"Không"| G["Fetch từ Network"]
G --> H["Cache response mới"]
C --> I{"Đủ điều kiện
installable?"}
I -->|"Có"| J["Hiển thị Install Prompt"]
J --> K["Cài PWA lên Home Screen"]
style D fill:#e94560,stroke:#fff,color:#fff
style F fill:#4CAF50,stroke:#fff,color:#fff
style K fill:#2c3e50,stroke:#fff,color:#fff
2. Thiết lập PWA với Vue 3 và Vite
2.1. Cài đặt vite-plugin-pwa
npm install -D vite-plugin-pwa2.2. Cấu hình trong vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My Vue PWA',
short_name: 'VuePWA',
description: 'Vue 3 Progressive Web App',
theme_color: '#e94560',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-512x512.png', sizes: '512x512',
type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }
}
}
]
}
})
]
})registerType: 'autoUpdate' vs 'prompt'
autoUpdate tự động cập nhật Service Worker khi có phiên bản mới — user không cần làm gì. prompt hiển thị thông báo cho user chọn cập nhật hay không — phù hợp khi bạn muốn kiểm soát UX chặt hơn (ví dụ app tài chính, nơi reload đột ngột có thể gây mất form data).
3. Service Worker — Bộ não của PWA
Service Worker là JavaScript chạy ở background thread, độc lập với trang web. Nó đóng vai trò proxy mạng — mọi HTTP request từ ứng dụng đều đi qua Service Worker trước khi ra internet, cho phép bạn quyết định: trả từ cache, fetch từ network, hay kết hợp cả hai.
3.1. Vòng đời Service Worker
flowchart LR
A["Register"] --> B["Install"]
B --> C["Activate"]
C --> D["Running
(intercept fetch)"]
D --> E{"Có SW mới?"}
E -->|"Có"| F["Install mới
(waiting)"]
F --> G["Activate mới
(thay thế cũ)"]
G --> D
E -->|"Không"| D
style B fill:#e94560,stroke:#fff,color:#fff
style C fill:#4CAF50,stroke:#fff,color:#fff
style D fill:#2c3e50,stroke:#fff,color:#fff
3.2. Custom Service Worker trong Vue
// src/sw.ts — custom service worker
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate }
from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
declare let self: ServiceWorkerGlobalScope
// Precache tất cả assets từ build
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// API calls: Network First (ưu tiên data mới nhất)
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 })
]
})
)
// Font files: Cache First (ít thay đổi)
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'font-cache',
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 })
]
})
)
// Trang HTML: Stale While Revalidate (hiện cache cũ, cập nhật nền)
registerRoute(
({ request }) => request.mode === 'navigate',
new StaleWhileRevalidate({ cacheName: 'pages-cache' })
)4. Chiến lược Caching
Chọn đúng caching strategy là yếu tố quyết định trải nghiệm PWA. Mỗi strategy phù hợp với một loại resource khác nhau:
| Strategy | Cách hoạt động | Dùng cho | Trade-off |
|---|---|---|---|
| Cache First | Kiểm tra cache trước, fetch network nếu cache miss | Ảnh, font, CSS/JS đã hash | Nhanh nhất, nhưng data có thể cũ |
| Network First | Fetch network trước, fallback cache nếu offline | API response, data động | Data luôn mới, nhưng chậm hơn |
| Stale While Revalidate | Trả cache ngay, đồng thời fetch network cập nhật cache | Trang HTML, danh mục sản phẩm | Cân bằng tốc độ và freshness |
| Network Only | Luôn fetch network, không dùng cache | Analytics, payment API | Không hoạt động offline |
| Cache Only | Luôn trả từ cache, không fetch network | Precached assets | Nhanh nhất, không bao giờ cập nhật |
flowchart LR
subgraph CF["Cache First"]
A1["Request"] --> A2{"Cache?"}
A2 -->|"Hit"| A3["Return Cache"]
A2 -->|"Miss"| A4["Fetch Network"]
end
subgraph NF["Network First"]
B1["Request"] --> B2["Fetch Network"]
B2 -->|"OK"| B3["Return + Cache"]
B2 -->|"Fail"| B4["Return Cache"]
end
subgraph SWR["Stale While Revalidate"]
C1["Request"] --> C2["Return Cache"]
C1 --> C3["Fetch Network"]
C3 --> C4["Update Cache"]
end
style A3 fill:#4CAF50,stroke:#fff,color:#fff
style B3 fill:#4CAF50,stroke:#fff,color:#fff
style C2 fill:#4CAF50,stroke:#fff,color:#fff
5. Web App Manifest — Biến web thành "app"
Web App Manifest là file JSON cho trình duyệt biết ứng dụng nên hiển thị thế nào khi được "cài đặt" — tên, icon, màu theme, display mode, và orientation. Đây là yêu cầu bắt buộc để trình duyệt hiện install prompt:
{
"name": "Vue E-Commerce PWA",
"short_name": "VueShop",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#e94560",
"description": "Mua sắm mọi lúc, kể cả khi offline",
"categories": ["shopping", "lifestyle"],
"orientation": "any",
"icons": [
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512x512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screenshots/home.png", "sizes": "1280x720", "type": "image/png",
"form_factor": "wide" },
{ "src": "/screenshots/home-mobile.png", "sizes": "750x1334",
"type": "image/png", "form_factor": "narrow" }
]
}Điều kiện để trình duyệt hiện Install Prompt
Chrome yêu cầu: HTTPS, Service Worker đã register, Web App Manifest có name, icons (ít nhất 192px và 512px), start_url, display (standalone/fullscreen). Safari trên iOS hỗ trợ Add to Home Screen nhưng không hiện prompt tự động — user phải dùng Share → Add to Home Screen.
6. Custom Install Prompt với Vue 3
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const deferredPrompt = ref<BeforeInstallPromptEvent | null>(null)
const showInstallBanner = ref(false)
onMounted(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e as BeforeInstallPromptEvent
showInstallBanner.value = true
})
window.addEventListener('appinstalled', () => {
showInstallBanner.value = false
deferredPrompt.value = null
})
})
async function installApp() {
if (!deferredPrompt.value) return
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
showInstallBanner.value = false
}
deferredPrompt.value = null
}
</script>
<template>
<Transition name="slide-up">
<div v-if="showInstallBanner" class="install-banner">
<p>Cài ứng dụng để trải nghiệm nhanh hơn, offline</p>
<button @click="installApp">Cài đặt</button>
<button @click="showInstallBanner = false">Để sau</button>
</div>
</Transition>
</template>7. Push Notification
Push Notification là kênh duy nhất có thể tiếp cận user ngay cả khi tab đã đóng. Nó hoạt động thông qua Push API kết hợp Service Worker:
sequenceDiagram
participant U as User (Browser)
participant SW as Service Worker
participant S as Your Server
participant P as Push Service
(FCM / APNs)
U->>SW: Notification.requestPermission()
SW->>P: PushManager.subscribe()
P-->>SW: PushSubscription (endpoint + keys)
SW->>S: POST /api/push/subscribe
S->>S: Lưu subscription
Note over S: Khi có event mới...
S->>P: POST endpoint (payload + VAPID)
P->>SW: Push event
SW->>U: self.registration.showNotification()
7.1. Đăng ký Push Subscription
// composables/usePushNotification.ts
export function usePushNotification() {
async function subscribe() {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return null
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY
)
})
// Gửi subscription lên server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
})
return subscription
}
return { subscribe }
}7.2. Nhận Push trong Service Worker
// Trong service worker
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {
title: 'Thông báo mới',
body: 'Bạn có cập nhật mới'
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: { url: data.url ?? '/' },
actions: [
{ action: 'open', title: 'Xem ngay' },
{ action: 'dismiss', title: 'Bỏ qua' }
]
})
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.action === 'open' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
)
}
})7.3. Gửi Push từ .NET Backend
// ASP.NET Core — gửi push notification
using WebPush;
public class PushNotificationService
{
private readonly VapidDetails _vapid;
public PushNotificationService(IConfiguration config)
{
_vapid = new VapidDetails(
"mailto:admin@example.com",
config["Vapid:PublicKey"]!,
config["Vapid:PrivateKey"]!
);
}
public async Task SendAsync(PushSubscription subscription,
string title, string body, string url)
{
var client = new WebPushClient();
var payload = JsonSerializer.Serialize(new { title, body, url });
await client.SendNotificationAsync(subscription, payload, _vapid);
}
}8. Background Sync — Xử lý offline form submit
Khi user submit form mà đang offline, Background Sync sẽ queue request và tự động gửi lại khi có mạng — user không cần thao tác lại:
// Trong Vue component
async function submitOrder(order: Order) {
try {
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
})
} catch {
// Offline: đăng ký sync task
const registration = await navigator.serviceWorker.ready
await registration.sync.register('sync-orders')
// Lưu data vào IndexedDB để SW đọc sau
await saveToIndexedDB('pending-orders', order)
}
}
// Trong Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncPendingOrders())
}
})
async function syncPendingOrders() {
const orders = await getFromIndexedDB('pending-orders')
for (const order of orders) {
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
})
await removeFromIndexedDB('pending-orders', order.id)
}
}9. PWA với Nuxt 4
Nuxt 4 hỗ trợ PWA thông qua module @vite-pwa/nuxt, kết hợp SSR/SSG với Service Worker một cách tự nhiên:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Nuxt PWA App',
short_name: 'NuxtPWA',
theme_color: '#e94560',
display: 'standalone',
icons: [
{ src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,svg,ico}']
},
client: {
installPrompt: true
}
}
})10. So sánh PWA vs Native App vs Hybrid
| Tiêu chí | PWA | Native App | Hybrid (Capacitor) |
|---|---|---|---|
| Phát triển | 1 codebase (Web) | 2+ codebase (iOS + Android) | 1 codebase + native bridge |
| Phân phối | URL + Install Prompt | App Store / Play Store | App Store + Web |
| Offline | Service Worker cache | Full native storage | Hybrid |
| Push Notification | Web Push (mọi platform 2026) | APNs / FCM native | Native push |
| Hardware Access | Camera, GPS, Bluetooth (hạn chế) | Toàn bộ | Qua plugin |
| Hiệu năng | Tốt (Wasm boost) | Tốt nhất | Gần native |
| Chi phí phát triển | Thấp | Cao | Trung bình |
| Cập nhật | Tức thì (SW update) | Qua App Store review | Hybrid |
Khi nào chọn PWA?
PWA phù hợp nhất khi: ứng dụng chủ yếu hiển thị nội dung (e-commerce, tin tức, SaaS dashboard), cần reach rộng không qua app store, team đã có kỹ năng web (Vue/React), và không yêu cầu deep hardware access (NFC, Bluetooth LE phức tạp). Nếu cần xử lý nặng (game, video editing) hoặc hardware chuyên biệt — native hoặc hybrid là lựa chọn tốt hơn.
11. Performance Checklist cho PWA
- Precache shell: App shell (HTML, CSS, JS chính) phải load từ cache — đảm bảo First Paint dưới 1 giây.
- Lazy load routes: Dùng
defineAsyncComponentvà Vue Router lazy loading — chỉ tải code khi user navigate. - Image optimization: Serve AVIF/WebP, responsive images với
srcset, và lazy loading vớiloading="lazy". - Cache API responses: Dùng NetworkFirst cho data hay thay đổi, CacheFirst cho data ít thay đổi.
- Minimize SW scope: Chỉ cache những gì cần thiết — cache quá nhiều sẽ lãng phí storage và làm chậm SW install.
- Navigation Preload: Bật
navigationPreloadđể network request bắt đầu song song với SW startup, giảm latency cho trang đầu tiên.
12. Kết luận
PWA năm 2026 không còn là thử nghiệm — đó là chiến lược phân phối ứng dụng hiệu quả nhất cho phần lớn use case web. Với Vue 3 + Vite + vite-plugin-pwa, bạn có thể biến bất kỳ Vue app nào thành PWA chỉ trong vài phút, được hưởng lợi từ Service Worker caching, offline capability, push notification, và install prompt — tất cả với một codebase duy nhất.
Bắt đầu ngay
1. npm install -D vite-plugin-pwa → 2. Thêm VitePWA plugin vào vite.config.ts → 3. Tạo icon 192px + 512px → 4. Build và test với Lighthouse. Toàn bộ mất dưới 10 phút cho một Vue app có sẵn.
Nguồn tham khảo
Cloudflare Workers + Durable Objects — Xây dựng ứng dụng Serverless có trạng thái trên Edge
Vitest 4 — Testing Framework nhanh gấp 28 lần Jest cho Vue và Vite
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.