优化PWA状态管理

This commit is contained in:
jxxghp
2025-07-07 11:28:57 +08:00
parent b15672d593
commit fca4afb606
2 changed files with 204 additions and 171 deletions

View File

@@ -43,18 +43,26 @@ async function saveStateToCache(request: Request): Promise<Response> {
const state = await request.json() const state = await request.json()
const cache = await caches.open(STATE_CACHE_NAME) const cache = await caches.open(STATE_CACHE_NAME)
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({ await cache.put(
...state, STATE_ENDPOINT,
timestamp: Date.now() new Response(
}))) JSON.stringify({
...state,
timestamp: Date.now(),
}),
),
)
return new Response(JSON.stringify({ success: true })) return new Response(JSON.stringify({ success: true }))
} catch (error) { } catch (error) {
console.error('Failed to save state to cache:', error) console.error('Failed to save state to cache:', error)
return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }), { return new Response(
status: 500, JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }),
headers: { 'Content-Type': 'application/json' } {
}) status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
} }
} }
@@ -67,18 +75,18 @@ async function getStateFromCache(): Promise<Response> {
if (response) { if (response) {
const state = await response.json() const state = await response.json()
return new Response(JSON.stringify(state), { return new Response(JSON.stringify(state), {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}) })
} }
return new Response(JSON.stringify({}), { return new Response(JSON.stringify({}), {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}) })
} catch (error) { } catch (error) {
console.error('Failed to get state from cache:', error) console.error('Failed to get state from cache:', error)
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), { return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}) })
} }
} }
@@ -127,9 +135,9 @@ async function updateBadge(count: number) {
if ('setAppBadge' in navigator) { if ('setAppBadge' in navigator) {
try { try {
if (count > 0) { if (count > 0) {
await navigator.setAppBadge(count) await navigator.setAppBadge!(count)
} else { } else {
await navigator.clearAppBadge() await navigator.clearAppBadge!()
} }
} catch (error) { } catch (error) {
console.error('Failed to update app badge:', error) console.error('Failed to update app badge:', error)
@@ -141,7 +149,7 @@ async function updateBadge(count: number) {
async function clearBadge() { async function clearBadge() {
if ('clearAppBadge' in navigator) { if ('clearAppBadge' in navigator) {
try { try {
await navigator.clearAppBadge() await navigator.clearAppBadge!()
await setStoredUnreadCount(0) await setStoredUnreadCount(0)
} catch (error) { } catch (error) {
console.error('Failed to clear app badge:', error) console.error('Failed to clear app badge:', error)
@@ -159,8 +167,7 @@ self.addEventListener('install', event => {
const existingState = await cache.match(STATE_ENDPOINT) const existingState = await cache.match(STATE_ENDPOINT)
if (existingState) { if (existingState) {
// 预热状态数据 // 预热状态数据(无需处理,仅确保缓存可用)
const state = await existingState.json()
} }
} catch (error) { } catch (error) {
// 静默处理错误 // 静默处理错误
@@ -168,7 +175,7 @@ self.addEventListener('install', event => {
// 强制等待中的Service Worker立即成为活动的Service Worker // 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting() self.skipWaiting()
})() })(),
) )
}) })
@@ -188,7 +195,7 @@ self.addEventListener('activate', event => {
if (cacheName.includes('old-') || cacheName.includes('deprecated-')) { if (cacheName.includes('old-') || cacheName.includes('deprecated-')) {
return caches.delete(cacheName) return caches.delete(cacheName)
} }
}) }),
) )
})(), })(),
) )
@@ -283,9 +290,9 @@ self.addEventListener('push', function (event) {
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)]) await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
})(), })(),
) )
} catch (e) { } catch (e) {
// 静默处理错误 // 静默处理错误
} }
}) })
// 监听通知点击事件 // 监听通知点击事件
@@ -300,7 +307,6 @@ self.addEventListener('notificationclick', function (event) {
// 监听来自主应用的消息,用于清除徽章或更新徽章数量 // 监听来自主应用的消息,用于清除徽章或更新徽章数量
self.addEventListener('message', function (event) { self.addEventListener('message', function (event) {
if (event.data && event.data.type === 'CLEAR_BADGE') { if (event.data && event.data.type === 'CLEAR_BADGE') {
// 清除徽章 // 清除徽章
clearBadge() clearBadge()
@@ -333,11 +339,13 @@ self.addEventListener('message', function (event) {
} else if (event.data && event.data.type === 'SAVE_PWA_STATE') { } else if (event.data && event.data.type === 'SAVE_PWA_STATE') {
// 保存PWA状态 // 保存PWA状态
const state = event.data.state || {} const state = event.data.state || {}
saveStateToCache(new Request(STATE_ENDPOINT, { saveStateToCache(
method: 'POST', new Request(STATE_ENDPOINT, {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify(state) headers: { 'Content-Type': 'application/json' },
})) body: JSON.stringify(state),
}),
)
.then(response => response.json()) .then(response => response.json())
.then(result => { .then(result => {
event.ports[0]?.postMessage({ success: result.success }) event.ports[0]?.postMessage({ success: result.success })

View File

@@ -51,14 +51,13 @@ export interface PWAContext {
* 只在需要时收集状态,不进行实时监听 * 只在需要时收集状态,不进行实时监听
*/ */
export class StateCollector { export class StateCollector {
static collectScrollPositions(): ScrollPosition[] { static collectScrollPositions(): ScrollPosition[] {
const positions: ScrollPosition[] = [] const positions: ScrollPosition[] = []
positions.push({ positions.push({
x: window.scrollX, x: window.scrollX,
y: window.scrollY, y: window.scrollY,
element: 'window' element: 'window',
}) })
const scrollContainers = [ const scrollContainers = [
@@ -68,17 +67,17 @@ export class StateCollector {
'.perfect-scrollbar', '.perfect-scrollbar',
'[data-simplebar]', '[data-simplebar]',
'.overflow-auto', '.overflow-auto',
'.overflow-y-auto' '.overflow-y-auto',
] ]
scrollContainers.forEach(selector => { scrollContainers.forEach(selector => {
const elements = document.querySelectorAll(selector) const elements = document.querySelectorAll(selector)
elements.forEach((element) => { elements.forEach(element => {
if (element.scrollTop > 0 || element.scrollLeft > 0) { if (element.scrollTop > 0 || element.scrollLeft > 0) {
positions.push({ positions.push({
x: element.scrollLeft, x: element.scrollLeft,
y: element.scrollTop, y: element.scrollTop,
element: this.generateElementSelector(element) element: this.generateElementSelector(element),
}) })
} }
}) })
@@ -101,7 +100,7 @@ export class StateCollector {
'.drawer', '.drawer',
'.v-navigation-drawer', '.v-navigation-drawer',
'[role="dialog"]', '[role="dialog"]',
'[role="alertdialog"]' '[role="alertdialog"]',
] ]
modalSelectors.forEach(selector => { modalSelectors.forEach(selector => {
@@ -111,7 +110,7 @@ export class StateCollector {
states.push({ states.push({
id: this.getModalId(element), id: this.getModalId(element),
isOpen: true, isOpen: true,
data: this.extractModalData(element) data: this.extractModalData(element),
}) })
} }
}) })
@@ -137,8 +136,10 @@ export class StateCollector {
value: inputElement.value, value: inputElement.value,
type: inputElement.type, type: inputElement.type,
checked: inputElement.checked, checked: inputElement.checked,
selectedIndex: inputElement.tagName === 'SELECT' ? selectedIndex:
(inputElement as unknown as HTMLSelectElement).selectedIndex : undefined inputElement.tagName === 'SELECT'
? (inputElement as unknown as HTMLSelectElement).selectedIndex
: undefined,
}) })
} }
}) })
@@ -161,9 +162,11 @@ export class StateCollector {
static restoreModalStates(states: ModalState[]): void { static restoreModalStates(states: ModalState[]): void {
states.forEach(state => { states.forEach(state => {
window.dispatchEvent(new CustomEvent('restoreModalState', { window.dispatchEvent(
detail: state new CustomEvent('restoreModalState', {
})) detail: state,
}),
)
}) })
} }
@@ -192,18 +195,22 @@ export class StateCollector {
private static isModalOpen(element: Element): boolean { private static isModalOpen(element: Element): boolean {
const computedStyle = window.getComputedStyle(element) const computedStyle = window.getComputedStyle(element)
return computedStyle.display !== 'none' && return (
computedStyle.visibility !== 'hidden' && computedStyle.display !== 'none' &&
computedStyle.opacity !== '0' && computedStyle.visibility !== 'hidden' &&
!element.hasAttribute('hidden') && computedStyle.opacity !== '0' &&
element.getAttribute('aria-hidden') !== 'true' !element.hasAttribute('hidden') &&
element.getAttribute('aria-hidden') !== 'true'
)
} }
private static getModalId(element: Element): string { private static getModalId(element: Element): string {
return element.id || return (
element.getAttribute('data-modal-id') || element.id ||
element.className.replace(/\s+/g, '-') || element.getAttribute('data-modal-id') ||
`modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` element.className.replace(/\s+/g, '-') ||
`modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
)
} }
private static extractModalData(element: Element): any { private static extractModalData(element: Element): any {
@@ -222,10 +229,10 @@ export class StateCollector {
const scrollableElements = element.querySelectorAll('[class*="overflow"], .v-card-text') const scrollableElements = element.querySelectorAll('[class*="overflow"], .v-card-text')
if (scrollableElements.length > 0) { if (scrollableElements.length > 0) {
data.scrollPositions = Array.from(scrollableElements).map((el) => ({ data.scrollPositions = Array.from(scrollableElements).map(el => ({
selector: this.generateElementSelector(el), selector: this.generateElementSelector(el),
x: el.scrollLeft, x: el.scrollLeft,
y: el.scrollTop y: el.scrollTop,
})) }))
} }
@@ -259,9 +266,8 @@ export class StateCollector {
// Use nth-child instead of nth-of-type, but only when necessary // Use nth-child instead of nth-of-type, but only when necessary
const parent = current.parentElement const parent = current.parentElement
if (parent) { if (parent) {
const siblings = Array.from(parent.children).filter(child => const siblings = Array.from(parent.children).filter(
child.tagName === current.tagName && child => child.tagName === current.tagName && child.className === current.className,
child.className === current.className
) )
if (siblings.length > 1) { if (siblings.length > 1) {
@@ -323,17 +329,23 @@ export class PWAStateManager {
saveState(state: PWAState): void { saveState(state: PWAState): void {
try { try {
// 主要状态存储到localStorage // 主要状态存储到localStorage
localStorage.setItem(this.storageKey, JSON.stringify({ localStorage.setItem(
...state, this.storageKey,
timestamp: Date.now() JSON.stringify({
})) ...state,
timestamp: Date.now(),
}),
)
// 临时状态存储到sessionStorage // 临时状态存储到sessionStorage
sessionStorage.setItem(this.sessionKey, JSON.stringify({ sessionStorage.setItem(
scrollPosition: state.scrollPosition, this.sessionKey,
activeTab: state.appData?.activeTab, JSON.stringify({
formData: state.formData scrollPosition: state.scrollPosition,
})) activeTab: state.appData?.activeTab,
formData: state.formData,
}),
)
} catch (error) { } catch (error) {
console.error('状态保存失败:', error) console.error('状态保存失败:', error)
} }
@@ -352,7 +364,7 @@ export class PWAStateManager {
return { return {
...state, ...state,
...sessionData, ...sessionData,
isRestored: true isRestored: true,
} }
} }
} catch (error) { } catch (error) {
@@ -362,7 +374,8 @@ export class PWAStateManager {
} }
// 清除过期状态 // 清除过期状态
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void { // 24小时 clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void {
// 24小时
try { try {
const savedState = localStorage.getItem(this.storageKey) const savedState = localStorage.getItem(this.storageKey)
if (savedState) { if (savedState) {
@@ -393,7 +406,7 @@ export class PWAIndexedDBManager {
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result) request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => { request.onupgradeneeded = event => {
const db = (event.target as IDBOpenDBRequest).result const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(this.storeName)) { if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' }) db.createObjectStore(this.storeName, { keyPath: 'id' })
@@ -411,7 +424,7 @@ export class PWAIndexedDBManager {
await store.put({ await store.put({
id: 'appState', id: 'appState',
data: state, data: state,
timestamp: Date.now() timestamp: Date.now(),
}) })
} catch (error) { } catch (error) {
console.error('IndexedDB保存失败:', error) console.error('IndexedDB保存失败:', error)
@@ -447,10 +460,11 @@ export class ServiceWorkerStateSync {
async saveState(state: PWAState): Promise<boolean> { async saveState(state: PWAState): Promise<boolean> {
try { try {
// 通过Service Worker的fetch拦截器保存状态
const response = await fetch(this.stateEndpoint, { const response = await fetch(this.stateEndpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state) body: JSON.stringify(state),
}) })
const result = await response.json() const result = await response.json()
@@ -474,17 +488,20 @@ export class ServiceWorkerStateSync {
// 使用MessageChannel与Service Worker通信 // 使用MessageChannel与Service Worker通信
async saveStateViaMessage(state: PWAState): Promise<boolean> { async saveStateViaMessage(state: PWAState): Promise<boolean> {
return new Promise((resolve) => { return new Promise(resolve => {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const channel = new MessageChannel() const channel = new MessageChannel()
channel.port1.onmessage = (event) => { channel.port1.onmessage = event => {
resolve(event.data.success) resolve(event.data.success)
} }
navigator.serviceWorker.controller.postMessage({ navigator.serviceWorker.controller.postMessage(
type: 'SAVE_PWA_STATE', {
state type: 'SAVE_PWA_STATE',
}, [channel.port2]) state,
},
[channel.port2],
)
} else { } else {
resolve(false) resolve(false)
} }
@@ -492,16 +509,19 @@ export class ServiceWorkerStateSync {
} }
async loadStateViaMessage(): Promise<PWAState | null> { async loadStateViaMessage(): Promise<PWAState | null> {
return new Promise((resolve) => { return new Promise(resolve => {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const channel = new MessageChannel() const channel = new MessageChannel()
channel.port1.onmessage = (event) => { channel.port1.onmessage = event => {
resolve(event.data.state || null) resolve(event.data.state || null)
} }
navigator.serviceWorker.controller.postMessage({ navigator.serviceWorker.controller.postMessage(
type: 'GET_PWA_STATE' {
}, [channel.port2]) type: 'GET_PWA_STATE',
},
[channel.port2],
)
} else { } else {
resolve(null) resolve(null)
} }
@@ -649,7 +669,7 @@ export class VisibilityStateManager {
scrollPositions: [{ x: window.scrollX, y: window.scrollY, element: 'window' }], scrollPositions: [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
orientation: window.orientation || 0, orientation: window.orientation || 0,
timestamp: Date.now(), timestamp: Date.now(),
appData: this.getAppSpecificState() appData: this.getAppSpecificState(),
} }
} }
@@ -663,16 +683,18 @@ export class VisibilityStateManager {
} }
// 触发状态恢复完成事件 // 触发状态恢复完成事件
window.dispatchEvent(new CustomEvent('pwaStateRestored', { window.dispatchEvent(
detail: { state } new CustomEvent('pwaStateRestored', {
})) detail: { state },
}),
)
} }
private getAppSpecificState(): any { private getAppSpecificState(): any {
// 获取应用特定状态 // 获取应用特定状态
return { return {
formData: this.getFormData(), formData: this.getFormData(),
userSelections: this.getUserSelections() userSelections: this.getUserSelections(),
} }
} }
@@ -716,7 +738,7 @@ export class VisibilityStateManager {
private getUserSelections(): any { private getUserSelections(): any {
return { return {
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id), selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
activeTab: document.querySelector('.tab.active')?.id activeTab: document.querySelector('.tab.active')?.id,
} }
} }
@@ -756,7 +778,7 @@ export class PWAStateController {
this.visibilityManager = new VisibilityStateManager(this.stateManager) this.visibilityManager = new VisibilityStateManager(this.stateManager)
this.restoreDecision = new StateRestoreDecision() this.restoreDecision = new StateRestoreDecision()
this.stateRestorePromise = new Promise((resolve) => { this.stateRestorePromise = new Promise(resolve => {
this.stateRestoreResolve = resolve this.stateRestoreResolve = resolve
}) })
@@ -783,7 +805,7 @@ export class PWAStateController {
const currentContext: PWAContext = { const currentContext: PWAContext = {
url: window.location.href, url: window.location.href,
orientation: window.orientation || 0, orientation: window.orientation || 0,
timestamp: Date.now() timestamp: Date.now(),
} }
// 尝试从多个来源恢复状态 // 尝试从多个来源恢复状态
@@ -791,7 +813,7 @@ export class PWAStateController {
() => this.stateManager.restoreState(), () => this.stateManager.restoreState(),
() => this.indexedDBManager.restoreState(), () => this.indexedDBManager.restoreState(),
() => this.swStateSync.loadState(), () => this.swStateSync.loadState(),
() => this.swStateSync.loadStateViaMessage() () => this.swStateSync.loadStateViaMessage(),
] ]
for (const source of sources) { for (const source of sources) {
@@ -823,19 +845,20 @@ export class PWAStateController {
const state: PWAState = { const state: PWAState = {
url: window.location.href, url: window.location.href,
scrollPosition: window.scrollY, scrollPosition: window.scrollY,
scrollPositions: scrollPositions.length > 0 ? scrollPositions : [{ x: window.scrollX, y: window.scrollY, element: 'window' }], scrollPositions:
scrollPositions.length > 0 ? scrollPositions : [{ x: window.scrollX, y: window.scrollY, element: 'window' }],
orientation: window.orientation || 0, orientation: window.orientation || 0,
timestamp: Date.now(), timestamp: Date.now(),
appData: this.getAppSpecificState(), appData: this.getAppSpecificState(),
modalStates: modalStates.length > 0 ? modalStates : undefined, modalStates: modalStates.length > 0 ? modalStates : undefined,
formFields: formFields.length > 0 ? formFields : undefined formFields: formFields.length > 0 ? formFields : undefined,
} }
await Promise.allSettled([ await Promise.allSettled([
this.stateManager.saveState(state), this.stateManager.saveState(state),
this.indexedDBManager.saveState(state), this.indexedDBManager.saveState(state),
this.swStateSync.saveState(state), this.swStateSync.saveState(state),
this.swStateSync.saveStateViaMessage(state) this.swStateSync.saveStateViaMessage(state),
]) ])
} }
@@ -848,7 +871,7 @@ export class PWAStateController {
} else if (state.scrollPosition && urlMatches) { } else if (state.scrollPosition && urlMatches) {
window.scrollTo({ window.scrollTo({
top: state.scrollPosition, top: state.scrollPosition,
behavior: 'auto' behavior: 'auto',
}) })
} }
@@ -882,12 +905,12 @@ export class PWAStateController {
routerState: { routerState: {
currentRoute: window.location.pathname, currentRoute: window.location.pathname,
query: window.location.search, query: window.location.search,
hash: window.location.hash hash: window.location.hash,
}, },
uiState: { uiState: {
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null, sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
darkMode: document.documentElement.getAttribute('data-theme') === 'dark' darkMode: document.documentElement.getAttribute('data-theme') === 'dark',
} },
} }
} }
@@ -896,9 +919,11 @@ export class PWAStateController {
} }
private dispatchStateRestoreEvent(state: PWAState): void { private dispatchStateRestoreEvent(state: PWAState): void {
window.dispatchEvent(new CustomEvent('pwaStateRestored', { window.dispatchEvent(
detail: { state } new CustomEvent('pwaStateRestored', {
})) detail: { state },
}),
)
} }
destroy(): void { destroy(): void {