fix: 补齐轮询组合式函数
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import { errorMessage } from '../lib/http'
|
||||
|
||||
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
|
||||
export function usePolling<T>(
|
||||
key: Ref<string>,
|
||||
loader: (key: string, signal: AbortSignal) => Promise<T>,
|
||||
interval: MaybeRefOrGetter<number | false> = 6000
|
||||
) {
|
||||
const data = ref<T | null>(null) as Ref<T | null>
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const updatedAt = ref('')
|
||||
let generation = 0
|
||||
let controller: AbortController | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false
|
||||
|
||||
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
|
||||
function schedule() {
|
||||
const delay = toValue(interval)
|
||||
if (disposed || delay === false) return
|
||||
timer = setTimeout(() => {
|
||||
if (toValue(interval) === false) return
|
||||
if (document.visibilityState === 'hidden') schedule()
|
||||
else void refresh()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
|
||||
async function refresh() {
|
||||
if (disposed) return
|
||||
const current = ++generation
|
||||
clearTimeout(timer)
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await loader(key.value, signal)
|
||||
if (current !== generation || disposed) return
|
||||
data.value = result
|
||||
error.value = ''
|
||||
updatedAt.value = new Date().toISOString()
|
||||
} catch (cause) {
|
||||
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
|
||||
} finally {
|
||||
if (current === generation && !disposed) {
|
||||
loading.value = false
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面可关闭定时刷新;首次读取、切换查询目标和手动刷新仍然有效。
|
||||
watch(
|
||||
() => toValue(interval),
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
if (!loading.value) schedule()
|
||||
}
|
||||
)
|
||||
watch(
|
||||
key,
|
||||
() => {
|
||||
data.value = null
|
||||
error.value = ''
|
||||
updatedAt.value = ''
|
||||
void refresh()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onScopeDispose(() => {
|
||||
disposed = true
|
||||
generation++
|
||||
clearTimeout(timer)
|
||||
controller?.abort()
|
||||
})
|
||||
return { data, loading, error, updatedAt, refresh }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('异步轮询生命周期', () => {
|
||||
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, false))!
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('first')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
await query.refresh()
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('second')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
|
||||
vi.useFakeTimers()
|
||||
const interval = ref<number | false>(1000)
|
||||
const loader = vi.fn<() => Promise<string>>(async () => '数据')
|
||||
const scope = effectScope()
|
||||
scope.run(() => usePolling(ref('p'), loader, interval))
|
||||
await Promise.resolve()
|
||||
interval.value = false
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(1)
|
||||
interval.value = 1000
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(loader).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||
vi.useFakeTimers()
|
||||
const key = ref('first')
|
||||
const finish = new Map<string, (value: string) => void>()
|
||||
const loader = vi.fn<(id: string) => Promise<string>>(
|
||||
(id: string) =>
|
||||
new Promise<string>(resolve => {
|
||||
finish.set(id, resolve)
|
||||
})
|
||||
)
|
||||
const scope = effectScope()
|
||||
const query = scope.run(() => usePolling(key, loader, 1000))!
|
||||
key.value = 'second'
|
||||
await nextTick()
|
||||
finish.get('second')!('新项目')
|
||||
await Promise.resolve()
|
||||
finish.get('first')!('旧项目')
|
||||
await Promise.resolve()
|
||||
expect(query.data.value).toBe('新项目')
|
||||
scope.stop()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(loader).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||
const scope = effectScope()
|
||||
const loader = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce('上次成功数据')
|
||||
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||
const query = scope.run(() => usePolling(ref('p'), loader))!
|
||||
await Promise.resolve()
|
||||
await query.refresh()
|
||||
expect(query.data.value).toBe('上次成功数据')
|
||||
expect(query.error.value).toBe('连接已断开')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user