第二十五章: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