mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
Enhance PWA caching strategy with offline support and optimization docs
Co-authored-by: jxxghp <jxxghp@163.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
# PWA 缓存优化指南
|
||||||
|
|
||||||
|
## 📊 当前应用的App Shell模型评估
|
||||||
|
|
||||||
|
### ✅ 符合App Shell模型的方面
|
||||||
|
|
||||||
|
1. **核心架构**
|
||||||
|
- 拥有独立的HTML shell (`index.html`)
|
||||||
|
- 实现了内容与框架的分离
|
||||||
|
- 使用Vue Router进行路由懒加载
|
||||||
|
- 具备完整的PWA manifest配置
|
||||||
|
|
||||||
|
2. **Service Worker实现**
|
||||||
|
- 使用Workbox框架进行缓存管理
|
||||||
|
- 实现了预缓存和运行时缓存
|
||||||
|
- 支持离线检测和状态管理
|
||||||
|
- 实现了推送通知功能
|
||||||
|
|
||||||
|
3. **用户体验优化**
|
||||||
|
- 自定义加载界面
|
||||||
|
- 离线页面支持
|
||||||
|
- 网络状态实时检测
|
||||||
|
- 背景图片预加载
|
||||||
|
|
||||||
|
## 🚀 已实施的优化
|
||||||
|
|
||||||
|
### 1. App Shell缓存策略优化
|
||||||
|
```javascript
|
||||||
|
// 为App Shell HTML使用CacheFirst策略
|
||||||
|
{
|
||||||
|
urlPattern: /^\/$|\/index\.html$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'app-shell-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 10,
|
||||||
|
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 关键资源预缓存
|
||||||
|
- 预缓存`loader.css`和`logo.png`
|
||||||
|
- 确保离线页面始终可用
|
||||||
|
|
||||||
|
### 3. 独立的离线页面
|
||||||
|
- 创建了包含内联CSS的独立离线页面
|
||||||
|
- 自动检测网络恢复并重新加载
|
||||||
|
- 优雅的UI设计,支持深色模式
|
||||||
|
|
||||||
|
## 📈 进一步优化建议
|
||||||
|
|
||||||
|
### 1. **关键CSS内联**
|
||||||
|
建议将关键CSS内联到`index.html`中:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<style>
|
||||||
|
/* 关键路径CSS */
|
||||||
|
:root {
|
||||||
|
--initial-loader-bg: #FFFFFF;
|
||||||
|
--initial-loader-color: #9155FD;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-bg {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--initial-loader-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更多关键CSS... */
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **资源优先级优化**
|
||||||
|
```html
|
||||||
|
<!-- 预连接到关键域名 -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||||
|
|
||||||
|
<!-- 预加载关键字体 -->
|
||||||
|
<link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **缓存版本控制**
|
||||||
|
实现缓存版本控制机制:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在service-worker.ts中
|
||||||
|
const CACHE_VERSION = 'v1.0.0';
|
||||||
|
const CACHE_NAMES = {
|
||||||
|
appShell: `app-shell-${CACHE_VERSION}`,
|
||||||
|
static: `static-resources-${CACHE_VERSION}`,
|
||||||
|
api: `api-cache-${CACHE_VERSION}`,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **智能预取策略**
|
||||||
|
基于用户行为预取资源:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 预取下一个可能访问的页面
|
||||||
|
if ('requestIdleCallback' in window) {
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
const nextPageChunk = '/assets/dashboard-chunk.js';
|
||||||
|
if ('link' in document.createElement('link')) {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'prefetch';
|
||||||
|
link.href = nextPageChunk;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **缓存清理策略**
|
||||||
|
定期清理过期缓存:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 清理超过30天的图片缓存
|
||||||
|
async function cleanupOldCaches() {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
const currentCaches = Object.values(CACHE_NAMES);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
cacheNames.map(cacheName => {
|
||||||
|
if (!currentCaches.includes(cacheName)) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 性能监控建议
|
||||||
|
|
||||||
|
### 1. **缓存命中率监控**
|
||||||
|
```javascript
|
||||||
|
// 记录缓存命中率
|
||||||
|
let cacheHits = 0;
|
||||||
|
let totalRequests = 0;
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
totalRequests++;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(response => {
|
||||||
|
if (response) {
|
||||||
|
cacheHits++;
|
||||||
|
// 发送统计数据
|
||||||
|
self.clients.matchAll().then(clients => {
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'CACHE_STATS',
|
||||||
|
hitRate: (cacheHits / totalRequests * 100).toFixed(2)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **离线使用分析**
|
||||||
|
跟踪用户在离线状态下的行为,优化离线体验。
|
||||||
|
|
||||||
|
## 📱 移动端优化
|
||||||
|
|
||||||
|
### 1. **Add to Home Screen 提示**
|
||||||
|
```javascript
|
||||||
|
let deferredPrompt;
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
|
||||||
|
// 在合适的时机显示安装提示
|
||||||
|
showInstallButton();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **后台同步**
|
||||||
|
使用Background Sync API同步离线操作:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 注册后台同步
|
||||||
|
if ('sync' in self.registration) {
|
||||||
|
self.registration.sync.register('sync-data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理同步事件
|
||||||
|
self.addEventListener('sync', event => {
|
||||||
|
if (event.tag === 'sync-data') {
|
||||||
|
event.waitUntil(syncOfflineData());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 最佳实践总结
|
||||||
|
|
||||||
|
1. **缓存策略选择**
|
||||||
|
- App Shell: CacheFirst
|
||||||
|
- API数据: NetworkFirst (带超时)
|
||||||
|
- 静态资源: StaleWhileRevalidate
|
||||||
|
- 图片资源: CacheFirst (带过期时间)
|
||||||
|
|
||||||
|
2. **缓存大小控制**
|
||||||
|
- 设置合理的maxEntries
|
||||||
|
- 定期清理过期缓存
|
||||||
|
- 监控缓存使用情况
|
||||||
|
|
||||||
|
3. **用户体验**
|
||||||
|
- 提供清晰的离线状态提示
|
||||||
|
- 实现平滑的在线/离线切换
|
||||||
|
- 预加载关键资源
|
||||||
|
|
||||||
|
4. **性能优化**
|
||||||
|
- 使用导航预加载
|
||||||
|
- 实施资源优先级策略
|
||||||
|
- 优化Service Worker启动时间
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>MoviePilot - 离线</title>
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #9155FD;
|
||||||
|
--surface-color: #FFFFFF;
|
||||||
|
--text-color: #333333;
|
||||||
|
--border-color: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--surface-color: #0E1116;
|
||||||
|
--text-color: #FFFFFF;
|
||||||
|
--border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-container {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 0 1px var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
background: rgba(145, 85, 253, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
fill: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 32px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(145, 85, 253, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #EF5350;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="offline-container">
|
||||||
|
<div class="icon-wrapper">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24">
|
||||||
|
<path d="M12,2.03C17.73,2.5 22,7.08 22,12.75C22,13.84 21.79,14.89 21.4,15.86L19.53,14C19.5,13.83 19.5,13.67 19.5,13.5A2.5,2.5 0 0,0 17,11A2.5,2.5 0 0,0 14.5,13.5A2.5,2.5 0 0,0 17,16A2.5,2.5 0 0,0 19.5,13.5C19.5,13.67 19.5,13.83 19.53,14L21.4,15.86C20.04,19.09 16.9,21.47 13.19,21.97L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L12.47,14.5C12.5,14.42 12.5,14.33 12.47,14.25L10.6,12.38C10.18,11.97 9.72,11.59 9.23,11.25L7.36,9.38C6.94,8.96 6.5,8.61 6,8.31V6.64L4.14,4.78C3.6,5.55 3.17,6.4 2.86,7.31L1,5.45V4.46L2.05,3.41C2.5,2.86 3.05,2.41 3.66,2.06L20,18.4L18.73,19.67L12.47,13.41L11.75,20.53C11.83,20.5 11.92,20.5 12,20.5A2.5,2.5 0 0,0 14.5,18A2.5,2.5 0 0,0 12,15.5A2.5,2.5 0 0,0 9.5,18C9.5,18.08 9.5,18.17 9.53,18.25L7.66,16.38C7.25,15.96 6.86,15.5 6.5,15H8.17C8.06,14.7 8,14.35 8,14A3,3 0 0,1 11,11A3,3 0 0,1 14,14C14,14.35 13.94,14.7 13.83,15H15.5C15.14,15.5 14.75,15.96 14.34,16.38L2.46,4.5C3.5,3.17 4.9,2.15 6.5,1.58V3.25C5.43,3.7 4.47,4.33 3.66,5.11L2.61,6.16V8.03C3.16,7.33 3.82,6.73 4.57,6.25V8.31C3.57,9.14 2.75,10.19 2.21,11.39L1,10.18V8.65C1.5,6.16 3.03,4.03 5.11,2.71L6.39,4C8.97,2.73 12.03,2.24 14.97,3.03L16.84,4.9C18.17,5.86 19.25,7.16 19.94,8.68L18.07,6.81C17.07,5.5 15.66,4.5 14,4.04V5.71C15.93,6.17 17.5,7.53 18.33,9.3L16.46,7.43C15.46,6.61 14.2,6.08 12.82,6V7.67C13.69,7.79 14.47,8.11 15.14,8.58L13.27,6.71C12.94,6.66 12.6,6.63 12.25,6.63L10.38,4.76C10.87,4.66 11.37,4.59 11.88,4.56L10,2.68C10.66,2.56 11.33,2.5 12,2.5V2.03Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>您当前处于离线状态</h1>
|
||||||
|
<p>无法连接到 MoviePilot 服务器。请检查您的网络连接后重试。</p>
|
||||||
|
|
||||||
|
<button class="retry-button" onclick="window.location.reload()">
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="status-badge">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span>离线模式</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 监听网络状态变化
|
||||||
|
window.addEventListener('online', function() {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Worker 消息处理
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', function(event) {
|
||||||
|
if (event.data && event.data.type === 'OFFLINE_STATUS' && !event.data.offline) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+24
-3
@@ -54,16 +54,37 @@ export default defineConfig({
|
|||||||
filename: 'service-worker.ts',
|
filename: 'service-worker.ts',
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
||||||
// 确保offline.html被预缓存
|
// 确保关键资源被预缓存
|
||||||
additionalManifestEntries: [
|
additionalManifestEntries: [
|
||||||
{
|
{
|
||||||
url: '/offline.html',
|
url: '/offline.html',
|
||||||
revision: null,
|
revision: null,
|
||||||
},
|
},
|
||||||
|
// 预缓存App Shell关键资源
|
||||||
|
{
|
||||||
|
url: '/loader.css',
|
||||||
|
revision: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/logo.png',
|
||||||
|
revision: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// 启用导航预加载
|
// 启用导航预加载
|
||||||
navigationPreload: true,
|
navigationPreload: true,
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
|
// App Shell缓存 - 优先缓存
|
||||||
|
{
|
||||||
|
urlPattern: /^\/$|\/index\.html$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'app-shell-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 10,
|
||||||
|
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /\.(?:js|css|html)$/,
|
urlPattern: /\.(?:js|css|html)$/,
|
||||||
handler: 'StaleWhileRevalidate',
|
handler: 'StaleWhileRevalidate',
|
||||||
@@ -131,8 +152,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navigateFallback: null,
|
navigateFallback: '/offline.html',
|
||||||
navigateFallbackDenylist: [/.*\/api\/v\d+\/system\/logging.*/, /\/offline\.html$/],
|
navigateFallbackDenylist: [/.*\/api\/.*/, /\/offline\.html$/],
|
||||||
ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^gclid$/],
|
ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^gclid$/],
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user