fix(pwa): coordinate service worker lifecycle (#569)

This commit is contained in:
InfinityPacer
2026-07-21 17:30:08 +08:00
committed by GitHub
parent 8dc36a2007
commit 65d93b70e4
9 changed files with 409 additions and 78 deletions
@@ -0,0 +1,138 @@
import {
createServiceWorkerCoordinator,
resolveServiceWorkerRegistration,
type ServiceWorkerClient,
} from '@/utils/serviceWorkerCoordinator'
import { describe, expect, it, vi } from 'vitest'
describe('Service Worker coordinator', () => {
it('普通 dev 不注册,dev:pwa 使用模块 Worker', () => {
const documentBaseUrl = 'http://localhost:5174/moviepilot/#/dashboard'
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, false)).toBeNull()
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, true)).toEqual({
scriptUrl: 'http://localhost:5174/moviepilot/dev-sw.js?dev-sw',
scope: '/moviepilot/',
type: 'module',
})
})
it('production 按应用 base 注册 classic Worker', () => {
expect(
resolveServiceWorkerRegistration('/moviepilot/', 'https://example.com/ignored/#/dashboard', false, false),
).toEqual({
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
scope: '/moviepilot/',
type: 'classic',
})
})
it('监听先于注册,重复初始化和更新复用同一个客户端', async () => {
const events: string[] = []
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
const registration = {} as ServiceWorkerRegistration
const client: ServiceWorkerClient = {
addEventListener: vi.fn((_type, listener) => {
events.push('listen')
activatedListener = listener
}),
register: vi.fn(async () => {
events.push('register')
return registration
}),
update: vi.fn(async () => {
events.push('update')
}),
}
const createClient = vi.fn(() => client)
const onUpdateActivated = vi.fn()
const coordinator = createServiceWorkerCoordinator({
registration: {
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
scope: '/moviepilot/',
type: 'classic',
},
createClient,
onUpdateActivated,
getController: () => null,
})
const firstInitialization = coordinator.initialize()
const secondInitialization = coordinator.initialize()
expect(firstInitialization).toBe(secondInitialization)
await expect(firstInitialization).resolves.toBe(registration)
expect(createClient).toHaveBeenCalledOnce()
expect(client.register).toHaveBeenCalledOnce()
expect(events).toEqual(['listen', 'register'])
activatedListener?.({ isUpdate: false })
expect(onUpdateActivated).not.toHaveBeenCalled()
activatedListener?.({ isExternal: true })
expect(onUpdateActivated).toHaveBeenCalledOnce()
activatedListener?.({ isUpdate: true })
expect(onUpdateActivated).toHaveBeenCalledOnce()
await coordinator.update()
expect(createClient).toHaveBeenCalledOnce()
expect(client.register).toHaveBeenCalledOnce()
expect(client.update).toHaveBeenCalledOnce()
expect(events).toEqual(['listen', 'register', 'update'])
})
it('注册期间快速激活时通过 controller 变化补发一次更新通知', async () => {
const previousController = {} as ServiceWorker
const updatedWorker = {} as ServiceWorker
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
const client: ServiceWorkerClient = {
addEventListener: vi.fn((_type, listener) => {
activatedListener = listener
}),
register: vi.fn(async () => ({ active: updatedWorker }) as ServiceWorkerRegistration),
update: vi.fn(),
}
const onUpdateActivated = vi.fn()
const coordinator = createServiceWorkerCoordinator({
registration: {
scriptUrl: 'https://example.com/service-worker.js',
scope: '/',
type: 'classic',
},
createClient: () => client,
onUpdateActivated,
getController: () => previousController,
})
await coordinator.initialize()
activatedListener?.({ isUpdate: true })
expect(onUpdateActivated).toHaveBeenCalledOnce()
})
it('注册失败时只记录一次且不阻断应用启动', async () => {
const error = new Error('register failed')
const onError = vi.fn()
const client: ServiceWorkerClient = {
addEventListener: vi.fn(),
register: vi.fn().mockRejectedValue(error),
update: vi.fn(),
}
const coordinator = createServiceWorkerCoordinator({
registration: {
scriptUrl: 'https://example.com/service-worker.js',
scope: '/',
type: 'classic',
},
createClient: () => client,
onUpdateActivated: vi.fn(),
getController: () => null,
onError,
})
await expect(coordinator.initialize()).resolves.toBeUndefined()
await expect(coordinator.initialize()).resolves.toBeUndefined()
expect(client.register).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalledWith(error)
})
})
+104
View File
@@ -0,0 +1,104 @@
/** Service Worker 在当前运行模式下的注册参数。 */
export interface ServiceWorkerRegistrationConfig {
/** Worker 脚本的同源绝对地址。 */
scriptUrl: string
/** Worker 允许控制的应用路径。 */
scope: string
/** development Worker 使用 ESMproduction Worker 保持 classic。 */
type: WorkerType
}
/** Workbox coordinator 所需的最小客户端契约。 */
export interface ServiceWorkerClient {
addEventListener(type: 'activated', listener: (event: { isExternal?: boolean; isUpdate?: boolean }) => void): void
register(): Promise<ServiceWorkerRegistration | undefined>
update(): Promise<void>
}
interface ServiceWorkerCoordinatorOptions {
/** 普通 dev 返回 nullproduction 与显式 dev:pwa 返回实际注册参数。 */
registration: ServiceWorkerRegistrationConfig | null
/** 创建 Workbox 客户端,由 coordinator 保证全生命周期只调用一次。 */
createClient: (registration: ServiceWorkerRegistrationConfig) => ServiceWorkerClient
/** 已有 Worker 完成升级激活时通知 UI。 */
onUpdateActivated: () => void
/** 返回当前页面 controller,用于补偿注册期间完成的快速激活。 */
getController?: () => ServiceWorker | null
/** 注册失败不阻断应用启动,但必须保留诊断信息。 */
onError?: (error: unknown) => void
}
/**
* 根据 Vite base 和运行模式解析唯一的 Worker URL、scope 与脚本类型。
*/
export function resolveServiceWorkerRegistration(
baseUrl: string,
documentBaseUrl: string,
isDevelopment: boolean,
pwaDevelopmentEnabled: boolean,
): ServiceWorkerRegistrationConfig | null {
if (isDevelopment && !pwaDevelopmentEnabled) return null
const appBaseUrl = new URL(baseUrl, documentBaseUrl)
const workerScript = isDevelopment ? 'dev-sw.js?dev-sw' : 'service-worker.js'
return {
scriptUrl: new URL(workerScript, appBaseUrl).href,
scope: appBaseUrl.pathname,
type: isDevelopment ? 'module' : 'classic',
}
}
/**
* 创建单 owner 的注册协调器;监听器先绑定,随后只执行一次 register。
*/
export function createServiceWorkerCoordinator(options: ServiceWorkerCoordinatorOptions) {
let client: ServiceWorkerClient | null = null
let registrationPromise: Promise<ServiceWorkerRegistration | undefined> | null = null
let updateNotified = false
const notifyUpdateActivated = (): void => {
if (updateNotified) return
updateNotified = true
options.onUpdateActivated()
}
const initialize = (): Promise<ServiceWorkerRegistration | undefined> => {
if (!options.registration) return Promise.resolve(undefined)
if (registrationPromise) return registrationPromise
const controllerBeforeRegistration = options.getController
? options.getController()
: navigator.serviceWorker.controller
client = options.createClient(options.registration)
client.addEventListener('activated', event => {
// 其他页签或注册 60 秒后发现的更新会被 Workbox 标为 external,仍代表当前页面需要刷新。
if (event.isUpdate || event.isExternal) notifyUpdateActivated()
})
registrationPromise = client
.register()
.then(registration => {
// skipWaiting + clients.claim 可能在 Workbox 接上底层监听前完成;active 已变化时仍需通知用户刷新。
if (controllerBeforeRegistration && registration?.active !== controllerBeforeRegistration) {
notifyUpdateActivated()
}
return registration
})
.catch(error => {
options.onError?.(error)
return undefined
})
return registrationPromise
}
const update = async (): Promise<void> => {
await initialize()
await client?.update()
}
return {
initialize,
update,
}
}