This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createVisibilityPoller, type PollingHost } from './useVisibilityPolling'
|
||||
|
||||
function fakeHost() {
|
||||
let hidden = false
|
||||
let intervalFn: (() => void) | null = null
|
||||
let intervalId = 0
|
||||
const listeners: Array<() => void> = []
|
||||
const host: PollingHost = {
|
||||
setInterval: (fn) => {
|
||||
intervalFn = fn
|
||||
return ++intervalId
|
||||
},
|
||||
clearInterval: () => {
|
||||
intervalFn = null
|
||||
},
|
||||
isHidden: () => hidden,
|
||||
addVisibilityListener: (fn) => {
|
||||
listeners.push(fn)
|
||||
},
|
||||
removeVisibilityListener: (fn) => {
|
||||
const index = listeners.indexOf(fn)
|
||||
if (index >= 0) listeners.splice(index, 1)
|
||||
},
|
||||
}
|
||||
return {
|
||||
host,
|
||||
setHidden: (value: boolean) => {
|
||||
hidden = value
|
||||
},
|
||||
fireInterval: () => intervalFn?.(),
|
||||
fireVisibility: () => listeners.forEach((fn) => fn()),
|
||||
hasTimer: () => intervalFn !== null,
|
||||
listenerCount: () => listeners.length,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createVisibilityPoller', () => {
|
||||
it('页面隐藏时不执行', async () => {
|
||||
const h = fakeHost()
|
||||
let calls = 0
|
||||
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
|
||||
h.setHidden(true)
|
||||
await poller.tick()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('回到前台立即补一次', async () => {
|
||||
const h = fakeHost()
|
||||
let calls = 0
|
||||
const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host)
|
||||
poller.attach()
|
||||
h.fireVisibility()
|
||||
await Promise.resolve()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('上一轮未结束时跳过下一轮', async () => {
|
||||
const h = fakeHost()
|
||||
let calls = 0
|
||||
let release: () => void = () => {}
|
||||
const pending = new Promise<void>((resolve) => { release = resolve })
|
||||
const poller = createVisibilityPoller(() => {
|
||||
calls++
|
||||
return calls === 1 ? pending : Promise.resolve()
|
||||
}, 1000, {}, h.host)
|
||||
const first = poller.tick()
|
||||
await Promise.resolve()
|
||||
await poller.tick()
|
||||
expect(calls).toBe(1)
|
||||
release()
|
||||
await first
|
||||
await poller.tick()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('enabled=false 时不执行', async () => {
|
||||
const h = fakeHost()
|
||||
const enabled = ref(false)
|
||||
let calls = 0
|
||||
const poller = createVisibilityPoller(() => { calls++ }, 1000, { enabled }, h.host)
|
||||
await poller.tick()
|
||||
expect(calls).toBe(0)
|
||||
enabled.value = true
|
||||
await poller.tick()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('卸载后不再保留计时器与监听', () => {
|
||||
const h = fakeHost()
|
||||
const poller = createVisibilityPoller(() => {}, 1000, {}, h.host)
|
||||
poller.attach()
|
||||
expect(h.hasTimer()).toBe(true)
|
||||
expect(h.listenerCount()).toBe(1)
|
||||
poller.detach()
|
||||
expect(h.hasTimer()).toBe(false)
|
||||
expect(h.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('onError 被调用且不影响后续轮询', async () => {
|
||||
const h = fakeHost()
|
||||
const errors: unknown[] = []
|
||||
let calls = 0
|
||||
const poller = createVisibilityPoller(() => {
|
||||
calls++
|
||||
if (calls === 1) throw new Error('boom')
|
||||
}, 1000, { onError: (error) => errors.push(error) }, h.host)
|
||||
await poller.tick()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(poller.lastError.value).toBeInstanceOf(Error)
|
||||
await poller.tick()
|
||||
expect(calls).toBe(2)
|
||||
expect(poller.lastError.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,44 +1,115 @@
|
||||
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
export interface VisibilityPollingOptions {
|
||||
/** 为 false 时整轮跳过(例如未选中服务端、弹窗未打开)。 */
|
||||
enabled?: Ref<boolean>
|
||||
/** 挂载后是否立即执行一次。 */
|
||||
immediate?: boolean
|
||||
/** 轮询回调抛错时调用;不传则只记录到 lastError。 */
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/** 注入的宿主接口,便于在无 DOM 环境下测试轮询核心。 */
|
||||
export interface PollingHost {
|
||||
setInterval: (fn: () => void, ms: number) => number
|
||||
clearInterval: (id: number) => void
|
||||
isHidden: () => boolean
|
||||
addVisibilityListener: (fn: () => void) => void
|
||||
removeVisibilityListener: (fn: () => void) => void
|
||||
}
|
||||
|
||||
export interface VisibilityPoller {
|
||||
start: () => void
|
||||
stop: () => void
|
||||
tick: () => Promise<void>
|
||||
attach: () => void
|
||||
detach: () => void
|
||||
busy: Ref<boolean>
|
||||
lastError: Ref<unknown>
|
||||
}
|
||||
|
||||
function browserPollingHost(): PollingHost {
|
||||
return {
|
||||
setInterval: (fn, ms) => window.setInterval(fn, ms),
|
||||
clearInterval: (id) => window.clearInterval(id),
|
||||
isHidden: () => document.visibilityState === 'hidden',
|
||||
addVisibilityListener: (fn) => document.addEventListener('visibilitychange', fn),
|
||||
removeVisibilityListener: (fn) => document.removeEventListener('visibilitychange', fn),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面可见时才轮询:标签页隐藏期间跳过这一轮,回到前台立即补一次。
|
||||
*
|
||||
* 各页原来的写法是「自己 setInterval + 在回调里判断 document.visibilityState」,
|
||||
* 但只有仪表盘这么做了,其余页面在后台标签页里仍在持续打接口(公网反代页每轮 7 个请求)。
|
||||
*
|
||||
* @param fn 每轮执行的回调;由调用方自行保证幂等与「无数据时直接 return」
|
||||
* @param intervalMs 轮询间隔
|
||||
* @param enabled 可选开关,为 false 时整轮跳过(例如未选中服务端)
|
||||
* 可测试的轮询核心:隐藏时跳过、上一轮未结束时跳过、回到前台立即补一次。
|
||||
* 不依赖 Vue 生命周期,宿主能力全部由 host 注入。
|
||||
*/
|
||||
export function useVisibilityPolling(fn: () => void | Promise<void>, intervalMs: number, enabled?: Ref<boolean>) {
|
||||
export function createVisibilityPoller(
|
||||
fn: () => void | Promise<void>,
|
||||
intervalMs: number,
|
||||
options: VisibilityPollingOptions = {},
|
||||
host: PollingHost = browserPollingHost(),
|
||||
): VisibilityPoller {
|
||||
const busy = ref(false)
|
||||
const lastError = ref<unknown>(null)
|
||||
let timer: number | undefined
|
||||
|
||||
const tick = () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
if (enabled && !enabled.value) return
|
||||
void fn()
|
||||
const tick = async () => {
|
||||
if (host.isHidden()) return
|
||||
if (options.enabled && !options.enabled.value) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
await fn()
|
||||
lastError.value = null
|
||||
} catch (error) {
|
||||
lastError.value = error
|
||||
options.onError?.(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (timer !== undefined) return
|
||||
timer = host.setInterval(() => void tick(), intervalMs)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (timer === undefined) return
|
||||
window.clearInterval(timer)
|
||||
host.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
tick() // 回到前台立刻补一次,不用等下一个周期
|
||||
if (host.isHidden()) return
|
||||
void tick()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
timer = window.setInterval(tick, intervalMs)
|
||||
})
|
||||
const attach = () => {
|
||||
host.addVisibilityListener(onVisibilityChange)
|
||||
start()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
const detach = () => {
|
||||
host.removeVisibilityListener(onVisibilityChange)
|
||||
stop()
|
||||
})
|
||||
}
|
||||
|
||||
return { stop }
|
||||
return { start, stop, tick, attach, detach, busy, lastError }
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面可见时才轮询:隐藏期间跳过,回到前台立即补一次,上一轮未结束时跳过下一轮。
|
||||
*/
|
||||
export function useVisibilityPolling(
|
||||
fn: () => void | Promise<void>,
|
||||
intervalMs: number,
|
||||
options: VisibilityPollingOptions = {},
|
||||
): VisibilityPoller {
|
||||
const poller = createVisibilityPoller(fn, intervalMs, options)
|
||||
onMounted(() => {
|
||||
poller.attach()
|
||||
if (options.immediate) void poller.tick()
|
||||
})
|
||||
onBeforeUnmount(() => poller.detach())
|
||||
return poller
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user