From 3367ff60606b895287af50a8f1345a8d758c1f14 Mon Sep 17 00:00:00 2001 From: Shigma Date: Sat, 6 Jan 2024 03:13:01 +0800 Subject: [PATCH] feat(timer): add throttle and debounce --- packages/timer/src/index.ts | 51 +++++++++++ packages/timer/tests/index.spec.ts | 132 +++++++++++++++++++++-------- 2 files changed, 149 insertions(+), 34 deletions(-) diff --git a/packages/timer/src/index.ts b/packages/timer/src/index.ts index cd7135c..a0726c2 100644 --- a/packages/timer/src/index.ts +++ b/packages/timer/src/index.ts @@ -4,9 +4,16 @@ import { remove } from 'cosmokit' declare module 'cordis' { interface Context { timer: TimerService + setTimeout(callback: () => void, delay: number): () => void + setInterval(callback: () => void, delay: number): () => void + sleep(delay: number): Promise + throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose + debounce void>(callback: F, delay: number): WithDispose } } +type WithDispose = T & { dispose: () => void } + class TimerService extends Service { constructor(ctx: Context) { super(ctx, 'timer', true) @@ -46,6 +53,50 @@ class TimerService extends Service { }) }) } + + private createWrapper(callback: (args: any[], check: () => boolean) => any, isDisposed = false) { + const caller = this[Context.current] + caller.scope.assertActive() + + let timer: number | NodeJS.Timeout | undefined + const dispose = () => { + isDisposed = true + remove(caller.scope.disposables, dispose) + clearTimeout(timer) + } + + const wrapper: any = (...args: any[]) => { + clearTimeout(timer) + timer = callback(args, () => !isDisposed && caller.scope.isActive) + } + wrapper.dispose = dispose + caller.scope.disposables.push(dispose) + return wrapper + } + + throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + let lastCall = -Infinity + const execute = (...args: any[]) => { + lastCall = Date.now() + callback(...args) + } + return this.createWrapper((args, isActive) => { + const now = Date.now() + const remaining = delay - (now - lastCall) + if (remaining <= 0) { + execute(...args) + } else if (isActive()) { + return setTimeout(execute, remaining, ...args) + } + }, noTrailing) + } + + debounce void>(callback: F, delay: number): WithDispose { + return this.createWrapper((args, isActive) => { + if (!isActive()) return + return setTimeout(callback, delay, ...args) + }) + } } export default TimerService diff --git a/packages/timer/tests/index.spec.ts b/packages/timer/tests/index.spec.ts index 340cb23..45707a9 100644 --- a/packages/timer/tests/index.spec.ts +++ b/packages/timer/tests/index.spec.ts @@ -1,28 +1,22 @@ -import { afterEach, beforeEach, describe, mock, test } from 'node:test' +import { describe, mock, test } from 'node:test' +import { FakeTimerInstallOpts, install, InstalledClock } from '@sinonjs/fake-timers' import { Context } from 'cordis' -import { expect } from 'chai' -import Timer from '../src' import assert from 'node:assert' +import Timer from '../src' -function tick(delay = 0) { - mock.timers.tick(delay) - return new Promise(resolve => process.nextTick(resolve)) +declare module 'cordis' { + interface Context { + clock: InstalledClock + } } -beforeEach(() => { - mock.timers.enable() -}) - -afterEach(() => { - mock.timers.reset() -}) - -function withContext(callback: (ctx: Context) => Promise) { +function withContext(callback: (ctx: Context) => Promise, config?: FakeTimerInstallOpts) { return () => new Promise((resolve, reject) => { const ctx = new Context() + ctx.clock = install(config) ctx.plugin(Timer) ctx.plugin(() => { - callback(ctx).then(resolve, reject) + callback(ctx).then(resolve, reject).finally(() => ctx.clock.uninstall()) }) }) } @@ -31,20 +25,20 @@ describe('ctx.setTimeout()', () => { test('basic support', withContext(async (ctx) => { const callback = mock.fn() ctx.setTimeout(callback, 1000) - expect(callback.mock.calls).to.have.length(0) - await tick(1000) - expect(callback.mock.calls).to.have.length(1) - await tick(1000) - expect(callback.mock.calls).to.have.length(1) + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(1000) + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(1000) + assert.strictEqual(callback.mock.calls.length, 1) })) test('dispose', withContext(async (ctx) => { const callback = mock.fn() const dispose = ctx.setTimeout(callback, 1000) - expect(callback.mock.calls).to.have.length(0) + assert.strictEqual(callback.mock.calls.length, 0) dispose() - await tick(5000) - expect(callback.mock.calls).to.have.length(0) + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 0) })) }) @@ -52,14 +46,14 @@ describe('ctx.setInterval()', () => { test('basic support', withContext(async (ctx) => { const callback = mock.fn() const dispose = ctx.setInterval(callback, 1000) - expect(callback.mock.calls).to.have.length(0) - await tick(1000) - expect(callback.mock.calls).to.have.length(1) - await tick(1000) - expect(callback.mock.calls).to.have.length(2) + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(1000) + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(1000) + assert.strictEqual(callback.mock.calls.length, 2) dispose() - await tick(5000) - expect(callback.mock.calls).to.have.length(2) + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 2) })) }) @@ -68,15 +62,85 @@ describe('ctx.sleep()', () => { const resolve = mock.fn() const reject = mock.fn() ctx.sleep(1000).then(resolve, reject) - await tick(500) + await ctx.clock.tickAsync(500) assert.strictEqual(resolve.mock.calls.length, 0) assert.strictEqual(reject.mock.calls.length, 0) - await tick(500) + await ctx.clock.tickAsync(500) assert.strictEqual(resolve.mock.calls.length, 1) assert.strictEqual(reject.mock.calls.length, 0) ctx.scope.dispose() - await tick(5000) + await ctx.clock.tickAsync(2000) assert.strictEqual(resolve.mock.calls.length, 1) assert.strictEqual(reject.mock.calls.length, 0) })) }) + +describe('ctx.throttle()', () => { + test('basic support', withContext(async (ctx) => { + const callback = mock.fn() + const throttled = ctx.throttle(callback, 1000) + throttled() + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(600) + throttled() + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(600) + throttled() + assert.strictEqual(callback.mock.calls.length, 2) + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 3) + })) + + test('trailing mode', withContext(async (ctx) => { + const callback = mock.fn() + const throttled = ctx.throttle(callback, 1000) + throttled() + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(500) + throttled() + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(500) + assert.strictEqual(callback.mock.calls.length, 2) + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 2) + })) + + test('disposed', withContext(async (ctx) => { + const callback = mock.fn() + const throttled = ctx.throttle(callback, 1000) + throttled.dispose() + throttled() + assert.strictEqual(callback.mock.calls.length, 1) + await ctx.clock.tickAsync(500) + throttled() + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 1) + })) +}) + +describe('ctx.debounce()', () => { + test('basic support', withContext(async (ctx) => { + const callback = mock.fn() + const debounced = ctx.debounce(callback, 1000) + debounced() + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(400) + debounced() + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(400) + debounced() + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(1000) + assert.strictEqual(callback.mock.calls.length, 1) + })) + + test('disposed', withContext(async (ctx) => { + const callback = mock.fn() + const debounced = ctx.debounce(callback, 1000) + debounced.dispose() + debounced() + assert.strictEqual(callback.mock.calls.length, 0) + await ctx.clock.tickAsync(2000) + assert.strictEqual(callback.mock.calls.length, 0) + })) +})