@greypan/js-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +38 -0
  2. package/.turbo/turbo-test.log +87 -0
  3. package/README.CN.md +5 -0
  4. package/README.md +5 -0
  5. package/dist/_virtual/_rolldown/runtime.js +11 -0
  6. package/dist/fetch/compose.d.ts +3 -0
  7. package/dist/index.d.ts +7 -0
  8. package/dist/index.js +10 -0
  9. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/funnel.js +40 -0
  10. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/funnel.js.map +1 -0
  11. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/isNullish.js +8 -0
  12. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/isNullish.js.map +1 -0
  13. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/lazyDataLastImpl--3B10z3s.js +12 -0
  14. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/lazyDataLastImpl--3B10z3s.js.map +1 -0
  15. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/omitBy.js +14 -0
  16. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/omitBy.js.map +1 -0
  17. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/purry.js +12 -0
  18. package/dist/node_modules/.pnpm/remeda@2.39.0/node_modules/remeda/dist/purry.js.map +1 -0
  19. package/dist/number/index.d.ts +14 -0
  20. package/dist/number/index.js +13 -0
  21. package/dist/number/index.js.map +1 -0
  22. package/dist/paradigm/go.d.ts +12 -0
  23. package/dist/paradigm/go.js +37 -0
  24. package/dist/paradigm/go.js.map +1 -0
  25. package/dist/paradigm/index.d.ts +2 -0
  26. package/dist/paradigm/rust.d.ts +25 -0
  27. package/dist/paradigm/rust.js +42 -0
  28. package/dist/paradigm/rust.js.map +1 -0
  29. package/dist/plugin/batching-emitter.d.ts +4 -0
  30. package/dist/plugin/batching-emitter.js +33 -0
  31. package/dist/plugin/batching-emitter.js.map +1 -0
  32. package/dist/plugin/core.d.ts +10 -0
  33. package/dist/plugin/core.js +23 -0
  34. package/dist/plugin/core.js.map +1 -0
  35. package/dist/plugin/event-emitter.d.ts +4 -0
  36. package/dist/plugin/index.d.ts +2 -0
  37. package/dist/random/index.d.ts +23 -0
  38. package/dist/random/index.js +18 -0
  39. package/dist/random/index.js.map +1 -0
  40. package/dist/timer/controllable-interval.d.ts +34 -0
  41. package/dist/timer/controllable-interval.js +41 -0
  42. package/dist/timer/controllable-interval.js.map +1 -0
  43. package/dist/timer/debounce.d.ts +21 -0
  44. package/dist/timer/debounce.js +30 -0
  45. package/dist/timer/debounce.js.map +1 -0
  46. package/dist/timer/index.d.ts +2 -0
  47. package/dist/url/index.d.ts +9 -0
  48. package/dist/url/index.js +28 -0
  49. package/dist/url/index.js.map +1 -0
  50. package/dist/utility-types/index.d.ts +13 -0
  51. package/package.json +54 -0
  52. package/src/fetch/__tests__/compose.spec.ts +105 -0
  53. package/src/fetch/compose.ts +23 -0
  54. package/src/index.ts +7 -0
  55. package/src/number/__tests__/number.spec.ts +50 -0
  56. package/src/number/index.ts +22 -0
  57. package/src/paradigm/__tests__/go.spec.ts +63 -0
  58. package/src/paradigm/__tests__/rust.spec.ts +68 -0
  59. package/src/paradigm/go.ts +38 -0
  60. package/src/paradigm/index.ts +2 -0
  61. package/src/paradigm/rust.ts +50 -0
  62. package/src/plugin/__tests__/batch-emitter.spec.ts +48 -0
  63. package/src/plugin/__tests__/core.spec.ts +110 -0
  64. package/src/plugin/__tests__/event-emiiter.spec.ts +41 -0
  65. package/src/plugin/batching-emitter.ts +61 -0
  66. package/src/plugin/core.ts +37 -0
  67. package/src/plugin/event-emitter.ts +25 -0
  68. package/src/plugin/index.ts +2 -0
  69. package/src/random/__tests__/random.spec.ts +77 -0
  70. package/src/random/index.ts +37 -0
  71. package/src/timer/__tests__/controllable-interval.spec.ts +51 -0
  72. package/src/timer/controllable-interval.ts +86 -0
  73. package/src/timer/debounce.ts +92 -0
  74. package/src/timer/index.ts +2 -0
  75. package/src/url/README.md +0 -0
  76. package/src/url/__tests__/url.spec.ts +152 -0
  77. package/src/url/index.ts +44 -0
  78. package/src/utility-types/index.ts +19 -0
  79. package/tsconfig.app.json +11 -0
  80. package/tsconfig.json +8 -0
  81. package/tsconfig.node.json +18 -0
  82. package/tsconfig.vitest.json +10 -0
  83. package/vite.config.ts +32 -0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @description Rust 范式,用于统一处理成功/失败返回值
3
+ */
4
+
5
+ export type Ok<T> = {
6
+ readonly ok: true
7
+ readonly value: T
8
+ }
9
+ export type Err<E> = {
10
+ readonly ok: false
11
+ readonly error: E
12
+ }
13
+ /**
14
+ * 考虑到 T 或 E 其中一个为 never 的情况
15
+ * Result<T,never> 等价于 Ok<T>
16
+ * Result<never,E> 等价于 Err<E>
17
+ * 为什么是 [T] extends [never] ? 而不是 T extends never ?:
18
+ * 条件类型是分布式的,防止 T 为联合类型时对每项分别计算再合并结果
19
+ */
20
+ export type Result<T, E> = Ok<T> | Err<E>
21
+
22
+ export function ok<T>(value: T): Ok<T> {
23
+ return { ok: true, value }
24
+ }
25
+
26
+ export function err<E>(error: E): Err<E> {
27
+ return { ok: false, error }
28
+ }
29
+
30
+ export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
31
+ return result.ok
32
+ }
33
+
34
+ export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
35
+ return !result.ok
36
+ }
37
+
38
+ export async function to<T, E = unknown>(promise: Promise<T>): Promise<Result<Awaited<T>, E>> {
39
+ try {
40
+ return ok(await promise)
41
+ } catch (error) {
42
+ return err(error as E)
43
+ }
44
+ }
45
+
46
+ export function unwrap<S extends Result<any, any>>(
47
+ result: S
48
+ ): S extends Ok<infer T> ? T : S extends Err<infer E> ? E : never {
49
+ return result.ok ? result.value : result.error
50
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { defineBatchEmitter } from '..'
4
+
5
+ describe('BatchEmitter 单元测试', () => {
6
+ it('立即返回单个 id,当 delay <= 0', async () => {
7
+ const batchEmitter = defineBatchEmitter().make()
8
+ const { batchEmit } = batchEmitter
9
+ const result = await batchEmit('a', 0)
10
+ expect(result).toEqual(['a'])
11
+ })
12
+
13
+ it('延迟后批量返回多个 id', async () => {
14
+ const batchEmitter = defineBatchEmitter().make()
15
+ const { batchEmit } = batchEmitter
16
+ const p1 = batchEmit('a', 10)
17
+ const p2 = batchEmit('b', 10)
18
+ const p3 = batchEmit('c', 10)
19
+
20
+ const results = await Promise.all([p1, p2, p3])
21
+ // 三个 promise 都应该 resolve 同一个批次
22
+ results.forEach(r => expect(r).toEqual(['a', 'b', 'c']))
23
+ })
24
+
25
+ it('flush 可以同步清空队列并调用 onFlushed', async () => {
26
+ const onFlushed = vi.fn()
27
+ const batchEmitter = defineBatchEmitter(onFlushed).make()
28
+ const { batchEmit, flush } = batchEmitter
29
+
30
+ const p1 = batchEmit(1, 100)
31
+ const p2 = batchEmit(2, 100)
32
+
33
+ await flush()
34
+
35
+ const results = await Promise.all([p1, p2])
36
+ expect(results[0]).toEqual([1, 2])
37
+ expect(results[1]).toEqual([1, 2])
38
+ expect(onFlushed).toHaveBeenCalledWith([1, 2])
39
+ })
40
+
41
+ it('flush 在队列为空时不做任何事', async () => {
42
+ const onFlushed = vi.fn()
43
+ const batchEmitter = defineBatchEmitter(onFlushed).make()
44
+ const { flush } = batchEmitter
45
+ await flush()
46
+ expect(onFlushed).not.toHaveBeenCalled()
47
+ })
48
+ })
@@ -0,0 +1,110 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { definePlugin, type PluginMade } from '../core'
4
+
5
+ describe('definePlugin 单元测试', () => {
6
+ const localStorageMock = (() => {
7
+ let store: Record<string, string> = {}
8
+ return {
9
+ getItem: (key: string) => store[key] || null,
10
+ setItem: (key: string, value: string) => {
11
+ store[key] = value.toString()
12
+ },
13
+ clear: () => {
14
+ store = {}
15
+ }
16
+ }
17
+ })()
18
+ vi.stubGlobal('localStorage', localStorageMock)
19
+
20
+ beforeEach(() => {
21
+ localStorage.clear()
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ describe('make', () => {
26
+ it('应当实例化插件,并且能够调用插件的属性方法', () => {
27
+ function defineTracker() {
28
+ return definePlugin(() => ({
29
+ track: (event: string) => console.log(`tracking ${event}`)
30
+ }))
31
+ }
32
+ const tracker = defineTracker().make()
33
+ expect(tracker).toHaveProperty('track')
34
+ expect(typeof tracker.track).toBe('function')
35
+
36
+ const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
37
+ tracker.track('test event')
38
+ expect(spy).toHaveBeenCalled()
39
+ })
40
+ })
41
+
42
+ describe('use', () => {
43
+ const defineStore = (initial: Record<string, number>) =>
44
+ definePlugin(() => {
45
+ let store: Record<string, number> = initial
46
+ return {
47
+ setStore(newStore: Record<string, number>) {
48
+ store = newStore
49
+ },
50
+ get(key: string) {
51
+ return store[key]
52
+ },
53
+ set(key: string, val: number) {
54
+ store[key] = val
55
+ }
56
+ }
57
+ })
58
+
59
+ const defineLogger = () =>
60
+ definePlugin((ctx: PluginMade<typeof defineStore>) => ({
61
+ set: (key: string, val: number) => {
62
+ ctx.set(key, val)
63
+ console.log(`set ${key} = ${val}.`)
64
+ }
65
+ }))
66
+
67
+ const definePersist = (persitKey = 'persit-store') =>
68
+ definePlugin((ctx: PluginMade<typeof defineStore>) => {
69
+ const cache = localStorage.getItem(persitKey)
70
+ if (cache) ctx.setStore(JSON.parse(cache))
71
+ return {
72
+ setStore(newStore: Record<string, number>) {
73
+ ctx.setStore(newStore)
74
+ localStorage.setItem(persitKey, JSON.stringify(newStore))
75
+ },
76
+ set(key: string, val: number) {
77
+ ctx.set(key, val)
78
+ const cache: Record<string, number> = JSON.parse(localStorage.getItem(persitKey) ?? '{}')
79
+ cache[key] = val
80
+ localStorage.setItem(persitKey, JSON.stringify(cache))
81
+ }
82
+ }
83
+ })
84
+
85
+ it('应当正确组合 Store, Logger 和 Persist 逻辑', () => {
86
+ const logSpy = vi.spyOn(console, 'log')
87
+
88
+ const store = defineStore({ a: 1, b: 2 }).use(defineLogger()).use(definePersist()).make()
89
+
90
+ expect(store.get('a')).toBe(1)
91
+
92
+ // 调用顺序: Persist.set -> Logger.set -> Store.set
93
+ store.set('a', 100)
94
+
95
+ expect(store.get('a')).toBe(100)
96
+ expect(logSpy).toHaveBeenCalledWith('set a = 100.')
97
+
98
+ const cached = JSON.parse(localStorage.getItem('persit-store') || '{}')
99
+ expect(cached.a).toBe(100)
100
+ })
101
+
102
+ it('应当在初始化时从持久化恢复数据', () => {
103
+ localStorage.setItem('persit-store', JSON.stringify({ a: 999 }))
104
+
105
+ const store = defineStore({ a: 1 }).use(definePersist()).make()
106
+
107
+ expect(store.get('a')).toBe(999)
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { defineEventEmitter } from '../event-emitter'
4
+
5
+ describe('EventEmitter 单元测试', () => {
6
+ it('应当支持注册和触发事件回调', () => {
7
+ const logSpy = vi.spyOn(console, 'log')
8
+
9
+ const eventEmitter = defineEventEmitter<{
10
+ click: [string]
11
+ }>().make()
12
+ eventEmitter.on('click', e => {
13
+ console.log(`${e} click 1`)
14
+ })
15
+ eventEmitter.on('click', e => {
16
+ console.log(`${e} click 2`)
17
+ })
18
+ eventEmitter.emit('click', 'Tom')
19
+
20
+ expect(logSpy).toHaveBeenCalledWith('Tom click 1')
21
+ expect(logSpy).toHaveBeenCalledWith('Tom click 2')
22
+ })
23
+
24
+ it('应当支持传递 rest 参数', () => {
25
+ const eventPlugin = defineEventEmitter<{
26
+ 'user:update': [number, string, 'admin' | 'user']
27
+ 'app:start': []
28
+ }>()
29
+ const app = eventPlugin.make()
30
+ const users: string[] = []
31
+
32
+ app.on('user:update', (id, name, role) => {
33
+ const user = `ID: ${id}, Name: ${name}, Role: ${role}`
34
+ users.push(user)
35
+ })
36
+
37
+ app.emit('user:update', 1, 'Tom', 'admin')
38
+
39
+ expect(users).toEqual(['ID: 1, Name: Tom, Role: admin'])
40
+ })
41
+ })
@@ -0,0 +1,61 @@
1
+ import { definePlugin } from './core'
2
+
3
+ type Resolve<S> = (queue: S[]) => void
4
+
5
+ export function defineBatchEmitter<S>(onFlushed?: (queue: S[]) => Promise<void>) {
6
+ return definePlugin(() => {
7
+ let queue: S[] = []
8
+ let resolves: Resolve<S>[] = []
9
+ let timerId: ReturnType<typeof setTimeout> | null = null
10
+
11
+ /**
12
+ * 批量发出,延迟执行
13
+ */
14
+ async function batchEmit(data: S, batchingDelay = 0): Promise<S[]> {
15
+ if (batchingDelay <= 0) {
16
+ if (onFlushed) await onFlushed([data])
17
+ return Promise.resolve([data])
18
+ }
19
+
20
+ return new Promise((resolve, reject) => {
21
+ resolves.push(resolve)
22
+
23
+ if (queue.push(data) === 1) {
24
+ timerId = setTimeout(() => {
25
+ try {
26
+ void flush()
27
+ } catch (error) {
28
+ reject(error)
29
+ }
30
+ }, batchingDelay)
31
+ }
32
+ })
33
+ }
34
+
35
+ /**
36
+ * 立即清空并执行当前的队列
37
+ */
38
+ async function flush() {
39
+ if (!queue.length) return
40
+
41
+ // 先快照一份,防止后续 push 污染当前批次,消除异步重入(Re-entrancy)
42
+ const currentQueue = [...queue]
43
+ const currentResolves = [...resolves]
44
+ queue = []
45
+ resolves = []
46
+ if (timerId) {
47
+ clearTimeout(timerId)
48
+ timerId = null
49
+ }
50
+
51
+ if (onFlushed) await onFlushed(currentQueue)
52
+
53
+ let fn: Resolve<S> | undefined
54
+ while ((fn = currentResolves.shift())) {
55
+ fn(currentQueue)
56
+ }
57
+ }
58
+
59
+ return { batchEmit, flush }
60
+ })
61
+ }
@@ -0,0 +1,37 @@
1
+ export interface Plugin<C extends object, D extends object> {
2
+ readonly use: <U extends object, E extends object>(plugin: Plugin<U, E>) => Plugin<C & U, D & E>
3
+ readonly make: <T extends D>(ctx?: T) => T & C
4
+ }
5
+
6
+ export function definePlugin<C extends object, D extends object>(setup: (ctx: D) => C): Plugin<C, D> {
7
+ function use<U extends object, E extends object>(plugin: Plugin<U, E>): Plugin<C & U, D & E> {
8
+ return definePlugin<C & U, D & E>(ctx => {
9
+ return plugin.make({
10
+ ...ctx,
11
+ ...setup(ctx)
12
+ })
13
+ })
14
+ }
15
+
16
+ function make<T extends D>(ctx = {} as T): T & C {
17
+ return {
18
+ ...ctx,
19
+ ...setup(ctx)
20
+ }
21
+ }
22
+
23
+ return {
24
+ use,
25
+ make
26
+ }
27
+ }
28
+
29
+ /**
30
+ * 提取插件实例化的类型 (C & D)
31
+ * T 可以是 Plugin 实例,也可以是返回 Plugin 实例的函数
32
+ */
33
+ export type PluginMade<T> = T extends (...args: any[]) => Plugin<infer C, infer D>
34
+ ? C & D
35
+ : T extends Plugin<infer C, infer D>
36
+ ? C & D
37
+ : never
@@ -0,0 +1,25 @@
1
+ import { definePlugin } from './core'
2
+
3
+ export function defineEventEmitter<E extends Record<string, unknown[]>>() {
4
+ return definePlugin(() => {
5
+ const bus = new EventTarget()
6
+
7
+ return {
8
+ on<K extends keyof E>(type: K & string, handler: (...args: E[K]) => void, options?: AddEventListenerOptions) {
9
+ const listener = (e: Event) => {
10
+ const args = (e as CustomEvent).detail as E[K]
11
+ handler(...args)
12
+ }
13
+ bus.addEventListener(type, listener, options)
14
+ // off fn
15
+ return () => bus.removeEventListener(type, listener)
16
+ },
17
+
18
+ // 支持传入多个参数
19
+ emit<K extends keyof E>(type: K & string, ...args: E[K]) {
20
+ const event = new CustomEvent(type, { detail: args })
21
+ bus.dispatchEvent(event)
22
+ }
23
+ }
24
+ })
25
+ }
@@ -0,0 +1,2 @@
1
+ export * from './batching-emitter'
2
+ export * from './core'
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { randomFloat, randomHex, randomRgb } from '..'
4
+
5
+ describe('random 单元测试', () => {
6
+ describe('randomFloat', () => {
7
+ it('结果应在指定的范围内', () => {
8
+ const min = 1.1
9
+ const max = 5.5
10
+ for (let i = 0; i < 100; i++) {
11
+ const result = randomFloat(min, max)
12
+ expect(result).toBeGreaterThanOrEqual(min)
13
+ // 考虑到 Math.random() 是 [0, 1),结果理论上小于 max
14
+ expect(result).toBeLessThanOrEqual(max)
15
+ }
16
+ })
17
+
18
+ it('即便 min 和 max 传反了也应当正常工作', () => {
19
+ const min = 10
20
+ const max = 1
21
+ const result = randomFloat(min, max)
22
+ expect(result).toBeGreaterThanOrEqual(1)
23
+ expect(result).toBeLessThanOrEqual(10)
24
+ })
25
+
26
+ it('当 min 等于 max 时应返回该数值', () => {
27
+ const result = randomFloat(5, 5)
28
+ expect(result).toBe(5)
29
+ })
30
+ })
31
+
32
+ describe('randomRgb', () => {
33
+ it('应当返回正确的 rgb(r, g, b) 格式', () => {
34
+ // 允许逗号后有或没有空格
35
+ const rgbRegex = /^rgb\(\d{1,3},\s?\d{1,3},\s?\d{1,3}\)$/
36
+ for (let i = 0; i < 50; i++) {
37
+ const result = randomRgb()
38
+ expect(result).toMatch(rgbRegex)
39
+
40
+ // 进一步校验数值范围
41
+ const colors = result.match(/\d+/g)?.map(Number)
42
+ colors?.forEach(c => {
43
+ expect(c).toBeGreaterThanOrEqual(0)
44
+ expect(c).toBeLessThanOrEqual(255)
45
+ })
46
+ }
47
+ })
48
+ })
49
+
50
+ describe('randomHex', () => {
51
+ it('应当返回正确的十六进制格式', () => {
52
+ const hexRegex = /^#[0-9a-f]{6}$/
53
+ for (let i = 0; i < 50; i++) {
54
+ const result = randomHex()
55
+ expect(result).toMatch(hexRegex)
56
+ }
57
+ })
58
+
59
+ it('生成的十六进制应当是小写的', () => {
60
+ const result = randomHex()
61
+ // 检查不含大写字母:如果包含大写字母则测试失败
62
+ expect(result).not.toMatch(/[A-Z]/)
63
+ })
64
+ })
65
+
66
+ describe('随机性校验 (Randomness)', () => {
67
+ it('连续调用不应产生相同的结果', () => {
68
+ const results = new Set()
69
+ const count = 100
70
+ for (let i = 0; i < count; i++) {
71
+ results.add(randomHex())
72
+ }
73
+ // 16777216 种颜色选 100 次,碰撞概率微乎其微
74
+ expect(results.size).toBe(count)
75
+ })
76
+ })
77
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 随机整数 [min, max]
3
+ * @param min 最小值
4
+ * @param max 最大值
5
+ */
6
+ export function random(min: number, max: number): number {
7
+ return Math.floor(Math.random() * (max - min + 1)) + min
8
+ }
9
+
10
+ /**
11
+ * 随机浮点数 [min, max)
12
+ * @param min 最小值
13
+ * @param max 最大值
14
+ * @param precision 保留小数位,不传则返回原始浮点数
15
+ */
16
+ export function randomFloat(min: number, max: number): number {
17
+ const lower = Math.min(min, max)
18
+ const upper = Math.max(min, max)
19
+ return lower + Math.random() * (upper - lower)
20
+ }
21
+
22
+ /**
23
+ * 生成随机的 RGB 颜色代码
24
+ * @returns RGB 颜色代码,例如 rgb(255, 0, 0)
25
+ */
26
+ export function randomRgb(): string {
27
+ return `rgb(${random(0, 255)}, ${random(0, 255)}, ${random(0, 255)})`
28
+ }
29
+
30
+ /**
31
+ * 生成随机的十六进制颜色代码
32
+ * @returns 十六进制颜色代码,例如 #ff0000
33
+ */
34
+ export function randomHex(): string {
35
+ const color = Math.floor(Math.random() * 0x1000000).toString(16)
36
+ return '#' + color.padStart(6, '0')
37
+ }
@@ -0,0 +1,51 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { createControllableInterval } from '..'
4
+
5
+ describe('timer 单元测试', () => {
6
+ describe('ControllableInterval', () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers()
9
+ })
10
+
11
+ it('应当能正常循环执行', () => {
12
+ const cb = vi.fn()
13
+ const timer = createControllableInterval(cb, 1000)
14
+ timer.start()
15
+
16
+ vi.advanceTimersByTime(3500)
17
+ expect(cb).toHaveBeenCalledTimes(3)
18
+ timer.stop()
19
+ })
20
+
21
+ it('暂停后不应触发回调', () => {
22
+ const cb = vi.fn()
23
+ const timer = createControllableInterval(cb, 1000)
24
+ timer.start()
25
+
26
+ vi.advanceTimersByTime(500)
27
+ timer.pause()
28
+ vi.advanceTimersByTime(1000)
29
+
30
+ expect(cb).not.toHaveBeenCalled()
31
+ timer.stop()
32
+ })
33
+
34
+ it('恢复后应先补全剩余时间', () => {
35
+ const cb = vi.fn()
36
+ const timer = createControllableInterval(cb, 1000)
37
+ timer.start()
38
+
39
+ vi.advanceTimersByTime(800) // 此时还剩 200ms
40
+ timer.pause()
41
+
42
+ timer.resume()
43
+ vi.advanceTimersByTime(150)
44
+ expect(cb).not.toHaveBeenCalled() // 还没到 200ms,不触发
45
+
46
+ vi.advanceTimersByTime(60) // 超过 200ms 了
47
+ expect(cb).toHaveBeenCalledTimes(1)
48
+ timer.stop()
49
+ })
50
+ })
51
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @file timer
3
+ * @description 增强型定时器工具(ControllableInterval)
4
+ * 特性:
5
+ * 1.进度保留:暂停 (pause) 时记录剩余时间,恢复 (resume) 时补全当前周期,而非重新计时
6
+ * 2.状态可控:支持随时 start、pause、resume、stop
7
+ * 3.链路稳健:基于递归 setTimeout 实现,避免原生 setInterval 在回调耗时过长时的“堆积”效应
8
+ * 4.环境兼容:适配浏览器与 Node.js (SSR),自动处理 Timeout 类型差异
9
+ * @example
10
+ * const timer = createControllableInterval(() => {
11
+ * console.log('tick')
12
+ * }, 1000))
13
+ * timer.start()
14
+ * setTimeout(() => {
15
+ * timer.pause() // 运行 800ms 后暂停,剩余 200ms
16
+ * setTimeout(() => {
17
+ * timer.resume() // 200ms 后立即触发回调,随后回归 1000ms 周期
18
+ * }, 500)
19
+ * }, 800)
20
+ */
21
+
22
+ import { definePlugin } from '@/plugin'
23
+
24
+ export function defineControllableInterval(callback: () => void, interval: number) {
25
+ return definePlugin(() => {
26
+ let timerId: ReturnType<typeof setTimeout> | null = null
27
+ let isPaused = false
28
+ let remainingTime = 0
29
+ let lastStartTime = 0
30
+
31
+ function tick(delay: number) {
32
+ lastStartTime = Date.now()
33
+ timerId = setTimeout(() => {
34
+ callback()
35
+ // 只有在没被暂停的情况下,才继续下一次循环
36
+ if (!isPaused) tick(interval)
37
+ }, delay)
38
+ }
39
+
40
+ function start() {
41
+ if (timerId || isPaused) return
42
+
43
+ tick(interval)
44
+ }
45
+
46
+ function pause() {
47
+ if (isPaused || !timerId) return
48
+
49
+ isPaused = true
50
+ clearTimeout(timerId)
51
+ timerId = null
52
+
53
+ // 计算当前这一轮还剩多少时间没跑完
54
+ const diff = Date.now() - lastStartTime
55
+ remainingTime = Math.max(0, interval - diff)
56
+ }
57
+
58
+ function resume() {
59
+ if (!isPaused) return
60
+
61
+ isPaused = false
62
+ // 恢复执行:先跑完上一轮剩下的时间
63
+ tick(remainingTime || interval)
64
+ }
65
+
66
+ function stop() {
67
+ if (timerId) clearTimeout(timerId)
68
+
69
+ timerId = null
70
+ isPaused = false
71
+ remainingTime = 0
72
+ }
73
+
74
+ return {
75
+ tick,
76
+ start,
77
+ pause,
78
+ resume,
79
+ stop
80
+ }
81
+ })
82
+ }
83
+
84
+ export function createControllableInterval(callback: () => void, interval: number) {
85
+ return defineControllableInterval(callback, interval).make()
86
+ }