Service Workers · Phần 11 — Web App Manifest & Install nâng cao
Mổ xẻ web app manifest đầy đủ: maskable icons, shortcuts, share_target, display_override, launch_handler, file/protocol handlers, rồi điều khiển install bằng beforeinstallprompt và xử lý đặc thù iOS.
Mười phần đầu đưa bạn từ “service worker là gì” tới một PWA capstone chạy được. Mười phần tiếp theo là chuyên sâu — những thứ tách một PWA “pass Lighthouse” khỏi một PWA mà người dùng thật sự cài và dùng hằng ngày. Mở màn là mảnh hay bị làm qua loa nhất: web app manifest và trải nghiệm cài đặt.
Manifest không chỉ là “vài dòng JSON cho icon”. Nó là hợp đồng khai báo app của bạn là một app: cách hiện trên màn hình chính, mở từ chia sẻ, có shortcut, mở file. Phần này đi hết các trường quan trọng và cách kiểm soát luồng cài đặt.
1. Manifest tối thiểu để “installable”
Trình duyệt chỉ coi web app là installable khi có đủ: manifest hợp lệ liên kết qua <link rel="manifest">, name/short_name, start_url, display là standalone/fullscreen/minimal-ui, icon 192px và 512px, phục vụ qua HTTPS, và một service worker có fetch handler.
{
"name": "Pulse Notes",
"short_name": "Pulse",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
]
}
start_url: "/?source=pwa"cho phép bạn đo lượng truy cập đến từ app đã cài trong analytics — một mẹo nhỏ nhưng cực hữu ích để biết PWA có được dùng không.
2. Maskable icons — đừng để icon bị “khoét”
Android bọc icon theo nhiều hình (tròn, squircle, vuông bo góc). Icon thường (purpose: "any") có thể bị cắt mất rìa hoặc lọt thỏm. Cần thêm icon maskable: nội dung nằm trong “safe zone” (vòng tròn ~80% trung tâm), nền phủ kín toàn khung.
"icons": [
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
Kiểm tra bằng maskable.app trước khi ship. Thiếu maskable là cảnh báo Lighthouse phổ biến nhất với người mới làm PWA.
3. shortcuts — menu chuột phải / nhấn giữ icon
shortcuts thêm các lối tắt khi người dùng nhấn giữ icon app (như “Soạn mail mới”, “Tìm kiếm”):
"shortcuts": [
{
"name": "Tạo note mới",
"short_name": "Note mới",
"url": "/new?source=shortcut",
"icons": [{ "src": "/icons/new-96.png", "sizes": "96x96" }]
},
{
"name": "Tìm kiếm",
"url": "/search?source=shortcut"
}
]
Mỗi shortcut là một deep link vào app. Giữ ≤ 4 cái (nhiều nền tảng chỉ hiện 4).
4. share_target — nhận nội dung được chia sẻ
share_target biến PWA thành đích trong menu Share của OS — người dùng chia sẻ ảnh/link/text vào app của bạn:
"share_target": {
"action": "/share-handler",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "media", "accept": ["image/*"] }]
}
}
Service worker bắt POST tới /share-handler và xử lý (lưu file vào IndexedDB, mở UI tạo note…):
// trong sw.js
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method === 'POST' && url.pathname === '/share-handler') {
event.respondWith(handleShare(event.request));
}
});
async function handleShare(request) {
const formData = await request.formData();
const files = formData.getAll('media');
await saveSharedFiles(files); // lưu vào IndexedDB
return Response.redirect('/new?shared=1', 303); // mở UI tạo note
}
5. display_override & launch_handler
display chỉ nhận một giá trị; display_override cho phép xếp hạng ưu tiên, gồm chế độ mới window-controls-overlay (app desktop tự vẽ thanh tiêu đề):
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"]
launch_handler quyết định điều gì xảy ra khi mở app lần nữa trong khi nó đã chạy:
"launch_handler": {
"client_mode": "navigate-existing"
}
navigate-existing tái dùng cửa sổ đang mở và điều hướng nó (thay vì mở cửa sổ mới mỗi lần) — tránh “loạn nhiều cửa sổ” cho app dạng tài liệu.
6. beforeinstallprompt — kiểm soát luồng cài đặt
Mặc định Chrome tự quyết khi nào nhắc cài. Bắt event beforeinstallprompt để hoãn và hiện nút cài của riêng bạn đúng lúc (sau khi user đã thấy giá trị app, không phải ngay khi vào):
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // hoãn prompt mặc định
deferredPrompt = e;
showInstallButton(); // hiện nút "Cài app" của bạn
});
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
analytics.track('pwa_install_prompt', { outcome }); // 'accepted' | 'dismissed'
deferredPrompt = null;
hideInstallButton();
});
window.addEventListener('appinstalled', () => {
analytics.track('pwa_installed');
hideInstallButton();
});
Nguyên tắc UX: đừng nhắc cài ngay lần đầu vào trang. Chờ tín hiệu gắn bó (đã tạo note, đã quay lại lần 2) rồi mới hiện nút. Prompt quá sớm = bị dismiss, và mỗi lần dismiss làm Chrome “phạt” tần suất nhắc lại.
7. iOS Safari — luật chơi khác
iOS không hỗ trợ beforeinstallprompt. Người dùng phải Share → Add to Home Screen thủ công. Bạn cần:
- Phát hiện iOS + chưa cài (
navigator.standalone === false) → hiện hướng dẫn “Bấm Share rồi Add to Home Screen”. - Vẫn cần icon
apple-touch-icon(iOS không đọc maskable từ manifest tốt):
<link rel="apple-touch-icon" href="/icons/apple-touch-180.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
function isIosNeedsInstallHint() {
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = ('standalone' in navigator) && (navigator as Navigator & { standalone?: boolean }).standalone;
return isIos && !isStandalone;
}
Nhớ: push notification trên iOS chỉ hoạt động sau khi app đã được Add to Home Screen (Phần 8) — nên hint cài đặt cũng là điều kiện cho push.
8. Bài tập
1. Vì sao cần icon purpose: "maskable" riêng ngoài icon "any"?
Lời giải
Android bọc icon theo nhiều hình mặt nạ (tròn, squircle…). Icon "any" có thể bị cắt rìa hoặc lọt thỏm. Icon maskable được thiết kế với nội dung nằm trong “safe zone” trung tâm và nền phủ kín, nên trông đúng dù bị khoét theo hình nào. Thiếu maskable là cảnh báo Lighthouse rất phổ biến.
2. Tại sao không nên gọi prompt cài đặt ngay lần đầu người dùng vào trang?
Lời giải
Người dùng chưa thấy giá trị app nên gần như chắc chắn dismiss; mỗi lần dismiss khiến trình duyệt giãn/khoá tần suất nhắc lại. Bắt beforeinstallprompt, hoãn nó, và hiện nút cài sau tín hiệu gắn bó (đã dùng tính năng chính, quay lại lần 2) để tăng tỉ lệ chấp nhận.
3. iOS khác Android thế nào ở luồng cài đặt, và hệ quả là gì?
Lời giải
iOS không có beforeinstallprompt; người dùng phải Share → Add to Home Screen thủ công. Hệ quả: cần phát hiện iOS chưa cài (navigator.standalone) và hiện hướng dẫn, cần apple-touch-icon, và push notification chỉ chạy sau khi đã cài lên home screen.
Nâng cao: Thêm share_target cho app, viết handler trong SW lưu ảnh được chia sẻ vào IndexedDB rồi mở UI tạo note. Test bằng cách chia sẻ một ảnh từ app khác vào PWA của bạn (trên Android).
Tóm tắt
- Installable cần: manifest hợp lệ +
name/short_name+start_url+displaystandalone + icon 192/512 + HTTPS + SW có fetch handler. - Maskable icon (safe zone trung tâm) tránh bị khoét trên Android; test bằng maskable.app.
shortcuts(deep link khi nhấn giữ icon),share_target(nhận nội dung chia sẻ, SW xử lý POST),display_override+launch_handler(navigate-existing).beforeinstallprompt: hoãn rồi hiện nút cài đúng lúc; theo dõiuserChoicevàappinstalledqua analytics.- iOS: không có prompt API — hướng dẫn Add to Home Screen,
apple-touch-icon, và đó là tiền đề cho push.
Phần tiếp theo
Phần 12 — Update flows mastery: xử lý cập nhật như production — skipWaiting an toàn vs prompt, bug “double reload”, lệch phiên bản giữa SW và asset, deploy nguyên tử, và một kill switch tử tế để gỡ SW khi sự cố.