网站秒变 App!手把手教你搞定 PWA

你有没有想过,为什么有些网站用起来就像 App 一样?点开很快,还能离线访问,甚至还能发通知?没错,这就是传说中的 PWA------渐进式 Web 应用。它是一种让网站"进化"的技术,不需要你下载 App 却能享受到原生应用的体验。很多大厂,比如 Twitter、Instagram、甚至阿里巴巴,都用 PWA 提升了移动端用户体验和留存率。其实实现 PWA 并不复杂,只需要搞懂几个核心概念:manifest.jsonService Worker 和缓存策略。只要你熟悉基本的前端技术,基本几步就能给自己的网站"变身"。

PWA 简介

渐进式 Web 应用(Progressive Web App,PWA)是一种结合 Web 和原生应用优势的技术,旨在提供快速、可靠、可交互的用户体验。PWA 的核心特性包括:

  • 渐进增强:在支持 PWA 的浏览器中提供增强功能,老旧浏览器仍可正常访问。
  • 离线支持:通过 Service Worker 缓存资源,实现离线或弱网环境下的可用性。
  • 可安装:通过 Web App Manifest 提供原生应用的安装体验,支持桌面和移动设备。
  • 推送通知:使用 Web Push API 实现消息推送,提升用户 engagement。
  • 响应式设计:适配多种设备和屏幕尺寸。
  • 安全性:强制使用 HTTPS 确保数据安全。

PWA 的技术栈

  • Service Worker:浏览器后台线程,管理缓存和网络请求。
  • Web App Manifest:JSON 文件,定义应用元数据(如图标、名称)。
  • Web Push API:与推送服务交互,发送通知。
  • HTTPS:确保安全通信。

前端价值

  • 提升用户体验(快速加载、离线访问)。
  • 降低开发成本(跨平台,无需原生开发)。
  • 提高留存率(推送通知、可安装性)。

本教程基于 2025 年 5 月 26 日的浏览器标准(Chrome 125、Firefox 126、Safari 18 等),涵盖 PWA 的完整实现流程。

PWA 基础

环境准备

技术要求

  • 浏览器:支持 Service Worker 和 Web App Manifest(主流浏览器均支持)。
  • Node.js:用于开发和构建(推荐 18.x 或 20.x)。
  • HTTPS :本地开发可使用 localhost 或自签名证书,生产环境需有效证书。
  • 工具
    • Workbox:Google 提供的 PWA 工具库,简化 Service Worker 配置。
    • Lighthouse:Chrome DevTools 的 PWA 审计工具。

安装工具

  1. Node.js

    bash 复制代码
    nvm install 18
    nvm use 18
  2. Workbox CLI

    bash 复制代码
    npm install -g workbox-cli
  3. HTTPS 开发环境 (使用 mkcert 生成本地证书):

    bash 复制代码
    brew install mkcert
    mkcert localhost

项目初始化

创建一个简单的 Web 项目:

bash 复制代码
mkdir pwa-demo
cd pwa-demo
npm init -y
npm install workbox-cli express

项目结构

java 复制代码
pwa-demo/
├── public/
│   ├── index.html
│   ├── styles.css
│   ├── app.js
│   ├── manifest.json
│   └── sw.js
├── server.js
└── package.json

第一个 PWA

1. 创建 HTML 文件

public/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My PWA</title>
    <link rel="stylesheet" href="/styles.css">
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#007bff">
</head>
<body>
    <h1>Welcome to My PWA</h1>
    <p>This is a Progressive Web App!</p>
    <script src="/app.js"></script>
</body>
</html>

分析

  • viewport 确保响应式。
  • manifest.json 链接 Web App Manifest。
  • theme-color 设置浏览器 UI 颜色。

2. 创建 Web App Manifest

public/manifest.json

json 复制代码
{
    "name": "My PWA",
    "short_name": "PWA",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#ffffff",
    "theme_color": "#007bff",
    "icons": [
        {
            "src": "/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

分析

  • nameshort_name 定义应用名称。
  • start_url 指定启动页面。
  • display: standalone 提供原生应用体验。
  • icons 提供不同尺寸的图标。

生成图标

使用工具(如 favicon.io)生成 192x192 和 512x512 的 PNG 图标,放置在 public/

3. 注册 Service Worker

public/sw.js

javascript 复制代码
self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('v1').then(cache => {
            return cache.addAll([
                '/',
                '/index.html',
                '/styles.css',
                '/app.js',
                '/manifest.json',
                '/icon-192.png',
                '/icon-512.png'
            ]);
        })
    );
});

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request).then(response => {
            return response || fetch(event.request).then(fetchResponse => {
                caches.open('v1').then(cache => {
                    cache.put(event.request, fetchResponse.clone());
                });
                return fetchResponse;
            });
        })
    );
});

public/app.js

javascript 复制代码
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('Service Worker registered', reg))
        .catch(err => console.error('Service Worker registration failed', err));
}

分析

  • install 事件缓存核心资源。
  • fetch 事件实现"缓存优先,网络更新"策略。
  • app.js 注册 Service Worker。

4. 配置服务器

server.js

javascript 复制代码
const express = require('express');
const path = require('path');
const app = express();

app.use(express.static(path.join(__dirname, 'public')));

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

启动

bash 复制代码
node server.js

5. 测试 PWA

  • 访问 http://localhost:3000
  • 使用 Chrome DevTools 的 Lighthouse 审计:
    • 打开 DevTools(F12)。
    • 切换到 "Lighthouse" 面板。
    • 勾选 "Progressive Web App",生成报告。
  • 结果
    • 确认离线支持:断开网络,刷新页面。
    • 确认可安装性:浏览器显示"安装"提示。

逐步分析

  1. HTML 提供基本结构,Manifest 定义应用元数据。
  2. Service Worker 缓存资源,支持离线访问。
  3. Express 服务器托管静态文件。
  4. Lighthouse 验证 PWA 合规性。

面试题 1:PWA 核心特性

问题:PWA 的核心特性是什么?如何实现离线支持?

答案

  • 特性:离线支持、可安装、推送通知、响应式、安全。
  • 离线支持
    • 使用 Service Worker 缓存资源:

      javascript 复制代码
      self.addEventListener('install', event => {
          event.waitUntil(caches.open('v1').then(cache => cache.addAll(['/', '/index.html'])));
      });
    • 拦截 fetch 请求,返回缓存或网络响应。

PWA 核心功能实现

1. 高级 Service Worker 配置(使用 Workbox)

Workbox 简化 Service Worker 的开发,提供预缓存、运行时缓存等功能。

安装 Workbox

bash 复制代码
npm install workbox-build

生成 Service Worker

workbox-config.js

javascript 复制代码
module.exports = {
    globDirectory: 'public/',
    globPatterns: ['**/*.{html,css,js,png,json}'],
    swDest: 'public/sw.js',
    clientsClaim: true,
    skipWaiting: true
};

生成命令

bash 复制代码
npx workbox generateSW workbox-config.js

生成的 sw.js(简化版)

javascript 复制代码
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');

workbox.precaching.precacheAndRoute([
    { url: '/', revision: '1' },
    { url: '/index.html', revision: '1' },
    // 其他资源
]);

workbox.routing.registerRoute(
    ({ request }) => request.destination === 'image',
    new workbox.strategies.CacheFirst({
        cacheName: 'images',
        plugins: [
            new workbox.expiration.ExpirationPlugin({
                maxEntries: 50,
                maxAgeSeconds: 30 * 24 * 60 * 60 // 30 天
            })
        ]
    })
);

分析

  • precacheAndRoute 预缓存指定资源。
  • CacheFirst 策略优先使用缓存,适合静态资源。
  • ExpirationPlugin 限制缓存大小和有效期。

动态缓存

修改 sw.js

javascript 复制代码
workbox.routing.registerRoute(
    ({ url }) => url.pathname.startsWith('/api/'),
    new workbox.strategies.NetworkFirst({
        cacheName: 'api',
        plugins: [
            new workbox.cacheableResponse.CacheableResponsePlugin({
                statuses: [200]
            })
        ]
    })
);

分析

  • NetworkFirst 优先尝试网络,失败则用缓存。
  • 适合动态 API 数据。

面试题 2:Workbox 缓存策略

问题:Workbox 的常见缓存策略有哪些?适合的场景?

答案

  • CacheFirst:缓存优先,适合静态资源(如图片、CSS)。
  • NetworkFirst:网络优先,适合动态数据(如 API)。
  • StaleWhileRevalidate:返回缓存,同时更新,适合频繁更新的资源。
  • NetworkOnly/CacheOnly:仅网络或仅缓存,适合特殊场景。

2. 推送通知

推送通知通过 Web Push API 实现,需要后端支持。

配置 VAPID 密钥

安装 web-push

bash 复制代码
npm install web-push

生成密钥

javascript 复制代码
const

高级 Service Worker 功能

后台同步(Background Sync)

后台同步允许在网络恢复时执行任务,如发送离线保存的数据。

实现后台同步

public/app.js(更新)

javascript 复制代码
async function saveDataOffline(data) {
    if (!navigator.onLine) {
        // 保存到 IndexedDB
        const db = await openDB('pwa-store', 1, {
            upgrade(db) {
                db.createObjectStore('sync-data', { autoIncrement: true });
            }
        });
        await db.put('sync-data', data);
        
        // 注册同步任务
        const reg = await navigator.serviceWorker.ready;
        await reg.sync.register('sync-data');
    } else {
        await fetch('/api/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data)
        });
    }
}

// IndexedDB 封装
function openDB(name, version, upgrade) {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open(name, version);
        request.onupgradeneeded = event => upgrade(event.target.result);
        request.onsuccess = event => resolve(event.target.result);
        request.onerror = () => reject(request.error);
    });
}

public/sw.js(更新)

javascript 复制代码
self.addEventListener('sync', event => {
    if (event.tag === 'sync-data') {
        event.waitUntil(syncData());
    }
});

async function syncData() {
    const db = await openDB('pwa-store', 1);
    const tx = db.transaction('sync-data', 'readonly');
    const store = tx.objectStore('sync-data');
    const data = await store.getAll();
    
    for (const item of data) {
        await fetch('/api/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(item)
        });
        await store.delete(item.id);
    }
}

后端server.js 更新):

javascript 复制代码
app.post('/api/save', (req, res) => {
    // 保存数据到数据库
    console.log('Saved:', req.body);
    res.status(200).send();
});

逐步分析

  1. 离线时,数据存储到 IndexedDB,注册 sync 任务。
  2. Service Worker 监听 sync 事件,网络恢复时发送数据。
  3. 后端接收数据,客户端删除已同步的记录。

前端应用

  • 离线表单提交(如评论、订单)。
  • 消息队列同步(如聊天应用)。

面试题 4:后台同步的优势

问题:后台同步与定时轮询相比有何优势?

答案

  • 优势
    • 仅在网络恢复时触发,节省资源。
    • 由浏览器管理,自动优化时机。
    • 支持离线场景,无需用户在线。
  • 定时轮询:持续请求,消耗流量和电池,离线无效。

周期性同步(Periodic Sync)

周期性同步适合定期更新内容,如新闻 feed。

实现周期性同步

public/app.js(更新)

javascript 复制代码
async function registerPeriodicSync() {
    const reg = await navigator.serviceWorker.ready;
    if ('periodicSync' in reg) {
        const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
        if (status.state === 'granted') {
            await reg.periodicSync.register('update-content', {
                minInterval: 24 * 60 * 60 * 1000 // 每天
            });
        }
    }
}

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js').then(reg => {
        registerPeriodicSync();
    });
}

public/sw.js(更新)

javascript 复制代码
self.addEventListener('periodicsync', event => {
    if (event.tag === 'update-content') {
        event.waitUntil(updateContent());
    }
});

async function updateContent() {
    const response = await fetch('/api/content');
    const data = await response.json();
    await caches.open('content').then(cache => cache.put('/content', new Response(JSON.stringify(data))));
}

后端

javascript 复制代码
app.get('/api/content', (req, res) => {
    res.json({ articles: ['News 1', 'News 2'] });
});

逐步分析

  1. 检查 periodicSync 支持和权限。
  2. 注册每天同步任务。
  3. Service Worker 定期获取内容,缓存到 content 存储。

注意

  • 周期性同步需用户授予权限,浏览器限制执行频率。
  • 目前仅 Chrome 支持,需降级方案(如定时 fetch)。

面试题 5:周期性同步限制

问题:周期性同步的限制是什么?如何降级?

答案

  • 限制

    • 仅部分浏览器支持(Chrome)。
    • 需用户权限。
    • 最小间隔由浏览器决定(通常 24 小时)。
  • 降级

    javascript 复制代码
    if (!('periodicSync' in reg)) {
        setInterval(async () => {
            const response = await fetch('/api/content');
            const data = await response.json();
            // 更新 UI
        }, 24 * 60 * 60 * 1000);
    }

推送通知高级场景

富文本通知与动作按钮

增强通知的用户交互性。

实现富文本通知

public/sw.js(更新)

javascript 复制代码
self.addEventListener('push', event => {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            icon: '/icon-192.png',
            badge: '/badge.png',
            image: '/preview.jpg',
            actions: [
                { action: 'view', title: 'View Details' },
                { action: 'dismiss', title: 'Dismiss' }
            ],
            data: { url: data.url }
        })
    );
});

self.addEventListener('notificationclick', event => {
    event.notification.close();
    if (event.action === 'view') {
        clients.openWindow(event.notification.data.url);
    }
});

后端推送server.js 更新):

javascript 复制代码
app.post('/api/sendNotification', (req, res) => {
    const payload = JSON.stringify({
        title: 'New Article',
        body: 'Check out our latest post!',
        url: 'https://example.com/article',
    });
    Promise.all(subscriptions.map(sub => webPush.sendNotification(sub, payload)))
        .then(() => res.status(200).send())
        .catch(err => res.status(500).send(err));
});

逐步分析

  1. 通知包含图片(image)、徽章(badge)和动作按钮。
  2. notificationclick 事件处理用户交互。
  3. 动作 view 打开指定 URL。

前端应用

  • 电商促销通知。
  • 新闻推送。

数据驱动通知

根据用户数据动态生成通知。

示例

前端订阅app.js 更新):

javascript 复制代码
async function subscribeUser(reg) {
    const publicKey = await fetch('/api/vapidPublicKey').then(res => res.text());
    const subscription = await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(publicKey)
    });
    
    await fetch('/api/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            subscription,
            userId: 'user123' // 假设用户 ID
        })
    });
}

后端server.js 更新):

javascript 复制代码
const subscriptions = new Map();

app.post('/api/subscribe', (req, res) => {
    subscriptions.set(req.body.userId, req.body.subscription);
    res.status(201).send();
});

app.post('/api/sendUserNotification', (req, res) => {
    const { userId, message } = req.body;
    const subscription = subscriptions.get(userId);
    if (subscription) {
        const payload = JSON.stringify({
            title: 'Personalized Message',
            body: message,
            url: '/dashboard'
        });
        webPush.sendNotification(subscription, payload)
            .then(() => res.status(200).send())
            .catch(err => res.status(500).send(err));
    } else {
        res.status(404).send('User not found');
    }
});

测试

bash 复制代码
curl -X POST http://localhost:3000/api/sendUserNotification \
-H "Content-Type: application/json" \
-d '{"userId": "user123", "message": "Your order has shipped!"}'

分析

  • 按用户 ID 存储订阅信息。
  • 后端发送个性化通知。

PWA 安全性

HTTPS 配置

PWA 强制使用 HTTPS,生产环境需有效证书。

使用 Let's Encrypt

安装 Certbot

bash 复制代码
sudo apt install certbot python3-certbot-nginx

获取证书

bash 复制代码
sudo certbot --nginx -d example.com

Nginx 配置

nginx 复制代码
server {
    listen 443 ssl;
    server_name example.com;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    root /var/www/pwa/public;
    
    location / {
        try_files $uri $uri/ /index.html;
    }
}

分析

  • Let's Encrypt 提供免费证书,自动续期。
  • Nginx 重定向 HTTP 到 HTTPS。

权限管理

限制 Service Worker 和通知权限。

检查权限

app.js

javascript 复制代码
async function checkPermissions() {
    const swPermission = await navigator.permissions.query({ name: 'service-worker' });
    const pushPermission = await navigator.permissions.query({ name: 'push', userVisibleOnly: true });
    console.log('Service Worker:', swPermission.state);
    console.log('Push:', pushPermission.state);
}

分析

  • 动态检查权限状态,提示用户授权。

数据加密

加密本地存储的数据。

使用 Web Crypto API

app.js

javascript 复制代码
async function encryptData(data) {
    const key = await crypto.subtle.generateKey(
        { name: 'AES-GCM', length: 256 },
        true,
        ['encrypt', 'decrypt']
    );
    
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const encoded = new TextEncoder().encode(JSON.stringify(data));
    
    const encrypted = await crypto.subtle.encrypt(
        { name: 'AES-GCM', iv },
        key,
        encoded
    );
    
    return { encrypted: new Uint8Array(encrypted), iv, key };
}

分析

  • AES-GCM 加密确保数据安全。
  • 适合离线存储敏感信息。

复杂案例:离线文档编辑器

实现

index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Offline Editor</title>
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#007bff">
</head>
<body>
    <textarea id="editor"></textarea>
    <button id="save">Save</button>
    <script src="/app.js"></script>
</body>
</html>

app.js

javascript 复制代码
import { openDB } from 'idb';

const dbPromise = openDB('editor-store', 1, {
    upgrade(db) {
        db.createObjectStore('documents', { keyPath: 'id', autoIncrement: true });
    }
});

document.getElementById('save').addEventListener('click', async () => {
    const content = document.getElementById('editor').value;
    const db = await dbPromise;
    await db.put('documents', { content, timestamp: Date.now() });
    
    if (!navigator.onLine) {
        const reg = await navigator.serviceWorker.ready;
        await reg.sync.register('sync-docs');
    } else {
        await syncDocs();
    }
});

async function syncDocs() {
    const db = await dbPromise;
    const docs = await db.getAll('documents');
    for (const doc of docs) {
        await fetch('/api/saveDoc', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(doc)
        });
        await db.delete('documents', doc.id);
    }
}

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js').then(reg => {
        reg.sync.register('sync-docs').catch(() => {});
    });
}

sw.js

javascript 复制代码
self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('v1').then(cache => cache.addAll([
            '/',
            '/index.html',
            '/manifest.json'
        ]))
    );
});

self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request).then(response => response || fetch(event.request))
    );
});

self.addEventListener('sync', event => {
    if (event.tag === 'sync-docs') {
        event.waitUntil(syncDocs());
    }
});

async function syncDocs() {
    // 与 app.js 的 syncDocs 逻辑相同
}

后端server.js 更新):

javascript 复制代码
app.post('/api/saveDoc', (req, res) => {
    // 保存到数据库
    console.log('Document saved:', req.body);
    res.status(200).send();
});

分析

  • IndexedDB 存储离线文档。
  • 后台同步上传文档。
  • Service Worker 确保离线访问。

与 WebAssembly 整合

结合 WebAssembly 提升 PWA 性能。

示例:Markdown 渲染

Rust 代码src/lib.rs):

rust 复制代码
use wasm_bindgen::prelude::*;
use pulldown_cmark::{html, Parser};

#[wasm_bindgen]
pub fn render_markdown(md: &str) -> String {
    let parser = Parser::new(md);
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    html_output
}

编译

bash 复制代码
wasm-pack build --target web

前端app.js 更新):

javascript 复制代码
import init, { render_markdown } from './pkg/markdown.js';

async function initEditor() {
    await init();
    const editor = document.getElementById('editor');
    const preview = document.createElement('div');
    document.body.appendChild(preview);
    
    editor.addEventListener('input', () => {
        preview.innerHTML = render_markdown(editor.value);
    });
}

initEditor();

分析

  • WASM 加速 Markdown 解析。
  • PWA 提供离线支持。

企业级实践(进阶)

微前端与 PWA

Qiankun 配置

javascript 复制代码
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
    {
        name: 'pwaEditor',
        entry: '//localhost:3001',
        container: '#editorContainer',
        activeRule: '/editor'
    }
]);

start();

Service Worker(子应用):

javascript 复制代码
self.addEventListener('install', event => {
    event.waitUntil(
        caches.open('editor-v1').then(cache => cache.addAll([
            '/editor',
            '/editor/index.html'
        ]))
    );
});

分析

  • 微前端分离 PWA 模块。
  • 各模块独立缓存。

Serverless 部署

使用 AWS Amplify

bash 复制代码
amplify init
amplify add hosting
amplify publish

分析

  • Serverless 简化 PWA 部署。
  • 自动配置 HTTPS 和 CDN。

Kubernetes 高级部署

自动扩缩容

bash 复制代码
kubectl create -f - <<EOF
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pwa-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pwa-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 80
EOF

分析

  • 根据 CPU 使用率动态调整副本。
  • 确保高流量下的稳定性。
相关推荐
摸鱼仙人~29 分钟前
styled-components:现代React样式解决方案
前端·react.js·前端框架
sasaraku.1 小时前
serviceWorker缓存资源
前端
RadiumAg2 小时前
记一道有趣的面试题
前端·javascript
yangzhi_emo2 小时前
ES6笔记2
开发语言·前端·javascript
yanlele2 小时前
我用爬虫抓取了 25 年 5 月掘金热门面试文章
前端·javascript·面试
中微子4 小时前
React状态管理最佳实践
前端
烛阴4 小时前
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
前端·javascript
小兵张健4 小时前
武汉拿下 23k offer 经历
java·面试·ai编程
中微子4 小时前
JavaScript 事件与 React 合成事件完全指南:从入门到精通
前端
Hexene...4 小时前
【前端Vue】如何实现echarts图表根据父元素宽度自适应大小
前端·vue.js·echarts