From 869ebf2f2c0233c3e19ee0f2c8fe7e191be1a11c Mon Sep 17 00:00:00 2001 From: GouJ Date: Tue, 22 Sep 2026 18:55:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=BC=8F=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/usePolling.ts | 81 ++++++++++++++++++++++++++ tests/composables/usePolling.test.ts | 87 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/composables/usePolling.ts create mode 100644 tests/composables/usePolling.test.ts diff --git a/src/composables/usePolling.ts b/src/composables/usePolling.ts new file mode 100644 index 0000000..1afed35 --- /dev/null +++ b/src/composables/usePolling.ts @@ -0,0 +1,81 @@ +import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' +import { errorMessage } from '../lib/http' + +/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */ +export function usePolling( + key: Ref, + loader: (key: string, signal: AbortSignal) => Promise, + interval: MaybeRefOrGetter = 6000 +) { + const data = ref(null) as Ref + const loading = ref(false) + const error = ref('') + const updatedAt = ref('') + let generation = 0 + let controller: AbortController | undefined + let timer: ReturnType | 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 } +} + diff --git a/tests/composables/usePolling.test.ts b/tests/composables/usePolling.test.ts new file mode 100644 index 0000000..73a8364 --- /dev/null +++ b/tests/composables/usePolling.test.ts @@ -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>(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(1000) + const loader = vi.fn<() => Promise>(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 void>() + const loader = vi.fn<(id: string) => Promise>( + (id: string) => + new Promise(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>() + .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() + }) +}) +