refactor(web): 统一前端轮询组合式函数并发布 v1.5.4
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 16:06:52 +08:00
parent 21efc10536
commit 8481bc3ac8
16 changed files with 287 additions and 211 deletions
@@ -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()
})
})
+96 -25
View File
@@ -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
}