第二十五章:PWA 与离线支持

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第二十五章:PWA 与离线支持

1. 什么是 PWA

渐进式 Web 应用(PWA)是一种使用 Web 技术构建的应用程序,具备接近原生应用的能力:

| 特性 | 传统 SPA | PWA | |------|---------|-----| | 离线可用 | ❌ 需要网络 | ✅ Service Worker 缓存 | | 安装到桌面 | ❌ | ✅ manifest.json | | 推送通知 | ❌ | ✅ Push API | | 后台同步 | ❌ | ✅ Background Sync | | 快速启动 | 依赖网络 | ✅ 缓存优先策略 | | 应用外壳 | ❌ | ✅ 独立窗口、无地址栏 |

┌──────────────────────────────────────────────────┐
│                  PWA Architecture                  │
│                                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐ │
│  │ Manifest  │  │ SW Cache │  │ IndexedDB        │ │
│  │ (安装元)  │  │ (离线资源)│  │ (本地数据)       │ │
│  └──────────┘  └──────────┘  └──────────────────┘ │
│       │              │                │            │
│       ▼              ▼                ▼            │
│  ┌──────────────────────────────────────────────┐  │
│  │            Dioxus SPA Application             │  │
│  └──────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘

2. manifest.json

2.1 基本配置

// public/manifest.json
{
  "name": "Blog SSR",
  "short_name": "Blog",
  "description": "个人博客 - Rust/Dioxus 全栈开发",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#eff1f5",
  "theme_color": "#1e66f5",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/assets/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/assets/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "categories": ["blog", "technology"],
  "lang": "zh-CN",
  "dir": "auto"
}

2.2 在 index.html 中引用

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1e66f5" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png" />

2.3 注入 PWA 元数据

#[component]
fn App() -> Element {
    use_effect(move || {
        let head = use_head();

        // manifest
        head.links.push(HeadLink {
            rel: "manifest".into(),
            href: "/manifest.json".into(),
            sizes: None,
            media: None,
        });

        // 主题色(随暗夜模式变化)
        head.meta.push(HeadMeta {
            name: Some("theme-color".into()),
            property: None,
            content: Some("#1e66f5".into()),
        });
    });

    rsx! { Router::<Route> {} }
}

3. Service Worker

3.1 注册 Service Worker

// 在 main.rs 中注册
fn register_service_worker() {
    let window = web_sys::window().unwrap();
    if let Ok(Some(navigator)) = window.navigator().service_worker() {
        let registration = navigator.register("/sw.js");
        // 处理注册结果
    }
}

fn main() {
    register_service_worker();
    dioxus::launch(App);
}

3.2 基础 Service Worker

// public/sw.js
const CACHE_NAME = 'rabitlogic-blog-v1';
const STATIC_ASSETS = [
    '/',
    '/index.html',
    '/assets/main.css',
    '/assets/tailwind.css',
    '/assets/icons/icon-192.png',
    '/assets/icons/icon-512.png',
    '/wasm/dioxus-web.js',
    '/wasm/dioxus-web_bg.wasm',
];

// 安装:缓存静态资源
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
            return cache.addAll(STATIC_ASSETS);
        })
    );
    // 立即激活,不等待旧 SW 关闭
    self.skipWaiting();
});

// 激活:清理旧缓存
self.addEventListener('activate', (event) => {
    event.waitUntil(
        caches.keys().then((keys) => {
            return Promise.all(
                keys
                    .filter((key) => key !== CACHE_NAME)
                    .map((key) => caches.delete(key))
            );
        })
    );
    // 接管所有页面
    self.clients.claim();
});

// 请求拦截:缓存优先策略
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // API 请求:网络优先,缓存后备
    if (url.pathname.startsWith('/api/')) {
        event.respondWith(networkFirst(event.request));
        return;
    }

    // 静态资源:缓存优先
    if (STATIC_ASSETS.includes(url.pathname) ||
        url.pathname.startsWith('/assets/')) {
        event.respondWith(cacheFirst(event.request));
        return;
    }

    // 页面导航:网络优先
    event.respondWith(networkFirst(event.request));
});

// 策略:缓存优先
async function cacheFirst(request) {
    const cached = await caches.match(request);
    return cached || fetch(request);
}

// 策略:网络优先
async function networkFirst(request) {
    try {
        const response = await fetch(request);
        const cache = await caches.open(CACHE_NAME);
        cache.put(request, response.clone());
        return response;
    } catch (error) {
        const cached = await caches.match(request);
        if (cached) return cached;
        // 离线时返回离线页面
        return caches.match('/offline.html');
    }
}

4. 离线页面

4.1 离线提示组件

#[component]
fn OfflineIndicator() -> Element {
    let online = use_signal(|| web_sys::window()
        .map(|w| w.navigator().on_line())
        .unwrap_or(true)
    );

    // 监听网络状态变化
    use_effect(move || {
        let window = web_sys::window().unwrap();

        let on_online = Closure::wrap(Box::new(move || {
            online.set(true);
            console_log!("[Network] 已恢复在线");
        }) as Box<dyn Fn()>);
        window.set_ononline(Some(on_online.as_ref().unchecked_ref()));
        on_online.forget();

        let on_offline = Closure::wrap(Box::new(move || {
            online.set(false);
            console_log!("[Network] 已离线");
        }) as Box<dyn Fn()>);
        window.set_onoffline(Some(on_offline.as_ref().unchecked_ref()));
        on_offline.forget();
    });

    rsx! {
        if !online() {
            div {
                class: "fixed bottom-4 left-4 z-50 flex items-center gap-2
                        px-3 py-2 rounded-lg shadow-lg text-sm text-white",
                style: "background: #f59e0b;",
                span { "📡" }
                span { "当前处于离线模式,部分功能不可用" }
            }
        }
    }
}

4.2 离线页面

<!-- public/offline.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>离线 - Blog SSR</title>
  <style>
    body { font-family: sans-serif; display: flex; justify-content: center;
           align-items: center; min-height: 100vh; margin: 0;
           background: #eff1f5; color: #4c4f69; }
    .card { text-align: center; padding: 3rem; }
    .emoji { font-size: 4rem; margin-bottom: 1rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: #9ca0b0; }
  </style>
</head>
<body>
  <div class="card">
    <div class="emoji">📡</div>
    <h1>网络已断开</h1>
    <p>请检查网络连接后重新加载</p>
    <button onclick="location.reload()">重试</button>
  </div>
</body>
</html>

5. 可安装性

5.1 安装提示

#[component]
fn InstallPrompt() -> Element {
    let deferred_prompt = use_signal(|| Option::<JsValue>::None);
    let installed = use_signal(|| false);

    use_effect(move || {
        // 监听 beforeinstallprompt 事件
        let window = web_sys::window().unwrap();
        let handler = Closure::wrap(Box::new(move |e: Event| {
            e.prevent_default();
            deferred_prompt.set(Some(e.into()));
        }) as Box<dyn Fn(_)>);

        // 添加事件监听
        // window.add_event_listener_with_callback("beforeinstallprompt", ...)
        handler.forget();
    });

    rsx! {
        if deferred_prompt().is_some() && !installed() {
            div { class: "fixed bottom-4 right-4 z-50",
                div { class: "rounded-lg shadow-xl p-4",
                    style: "background: var(--card); border: 1px solid var(--border);",
                    div { class: "flex items-center gap-3 mb-3",
                        img { class: "w-10 h-10 rounded", src: "/assets/icons/icon-192.png" }
                        div {
                            p { class: "font-semibold text-sm", "安装 Blog SSR" }
                            p { class: "text-xs", style: "color: var(--tertiary);",
                                "添加到桌面,获得更好的阅读体验" }
                        }
                    }
                    div { class: "flex gap-2",
                        button {
                            class: "flex-1 px-3 py-2 rounded-lg text-sm font-medium",
                            style: "background: var(--primary); color: white;",
                            onclick: move |_| {
                                // 触发安装弹窗
                                // deferred_prompt().prompt()
                                installed.set(true);
                            },
                            "安装"
                        }
                        button {
                            class: "px-3 py-2 rounded-lg text-sm",
                            onclick: move |_| installed.set(true),
                            "稍后"
                        }
                    }
                }
            }
        }
    }
}

6. IndexedDB 本地存储

对于更复杂的离线数据,localStorage 不够用,需要使用 IndexedDB:

// 简单的 IndexedDB 封装
fn use_indexed_db(db_name: &str, store_name: &str) -> IndexedDbHandle {
    // 通过 web-sys 操作 IndexedDB
    // 存储已缓存的文章列表和内容
    todo!()
}

// 离线文章缓存
fn use_offline_cache() {
    // 在线时:浏览文章时自动缓存到 IndexedDB
    // 离线时:从 IndexedDB 读取已缓存的文章
}

7. 后台同步

表单提交等操作在离线时需要排队,在线后自动提交:

// sw.js
self.addEventListener('sync', (event) => {
    if (event.tag === 'sync-comments') {
        event.waitUntil(syncPendingComments());
    }
    if (event.tag === 'sync-articles') {
        event.waitUntil(syncPendingArticles());
    }
});

async function syncPendingComments() {
    const db = await openDB();
    const pending = await db.getAll('pending-comments');
    for (const comment of pending) {
        try {
            await fetch('/api/comments', {
                method: 'POST',
                body: JSON.stringify(comment),
            });
            await db.delete('pending-comments', comment.id);
        } catch (e) {
            console.error('同步失败,稍后重试:', e);
            return; // 下次 sync 事件重试
        }
    }
}

8. 网络状态感知

fn use_network_status() -> Signal<NetworkStatus> {
    let status = use_signal(|| NetworkStatus {
        online: true,
        effective_type: "4g".to_string(),
        save_data: false,
    });

    use_effect(move || {
        let window = web_sys::window().unwrap();
        let navigator = window.navigator();
        let connection = navigator.connection();

        // 监听网络变化
        let handler = Closure::wrap(Box::new(move || {
            let conn = navigator.connection();
            status.set(NetworkStatus {
                online: window.navigator().on_line(),
                effective_type: conn.effective_type(),
                save_data: conn.save_data(),
            });
        }) as Box<dyn Fn()>);

        // connection.onchange = handler
        handler.forget();
    });

    status
}

// 根据网络状态调整行为
fn use_adaptive_loading<T: 'static>(
    fetcher: impl Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = T>>>,
) -> Signal<Option<T>> {
    let net = use_network_status();
    let data = use_signal(|| Option::<T>::None);

    use_effect(move || {
        if net.read().online {
            // 在线:正常加载
            spawn(async move {
                data.set(Some(fetcher().await));
            });
        } else {
            // 离线:从缓存读取
            // data.set(load_from_cache());
        }
    });

    data
}

9. 更新管理

#[component]
fn AppUpdateNotifier() -> Element {
    let update_available = use_signal(|| false);

    use_effect(move || {
        // 监听 Service Worker 更新
        let handler = Closure::wrap(Box::new(move || {
            update_available.set(true);
        }) as Box<dyn Fn()>);

        // navigator.serviceWorker.addEventListener('updatefound', handler)
        handler.forget();
    });

    rsx! {
        if update_available() {
            div { class: "fixed top-4 left-1/2 -translate-x-1/2 z-50",
                div { class: "flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg",
                    style: "background: var(--card); border: 1px solid var(--border);",
                    span { "📦 新版本可用" }
                    button {
                        class: "px-3 py-1 rounded text-sm",
                        style: "background: var(--primary); color: white;",
                        onclick: move |_| {
                            // 触发更新
                            web_sys::window().unwrap().location().reload();
                        },
                        "更新"
                    }
                    button {
                        onclick: move |_| update_available.set(false),
                        "忽略"
                    }
                }
            }
        }
    }
}

10. Lighthouse 检查

PWA 就绪后,使用 Chrome Lighthouse 验证:

| 检查项 | 要求 | 本应用 | |--------|------|--------| | 注册 Service Worker | ✅ 注册成功 | ✅ | | 离线可用 | ✅ 返回 200 | ✅ | | manifest.json | ✅ 完整配置 | ✅ | | 可安装 | ✅ 触发提示 | ✅ | | HTTPS | ✅ 生产环境必须 | ✅ | | 启动速度 | ✅ < 5s | ✅ | | 无 404 | ✅ 所有页面正常 | ✅ |

11. 小结

  • PWA 让 SPA 具备离线访问和类原生体验
  • manifest.json 控制安装信息和启动配置
  • Service Worker 拦截网络请求,实现缓存策略
  • 缓存优先(静态资源) + 网络优先(API)+ 离线页面三套策略
  • beforeinstallprompt 事件触发的安装提示
  • IndexedDB 存储离线数据,localStorage 存轻量配置
  • Background Sync 离线操作在线后自动提交
  • 网络状态感知让应用自适应不同网络条件
  • 更新管理通知用户新版本可用
dioxuspwaofflineservice-workercachemanifest