可配置的节流函数
实现一个增强版的 throttle(fn, wait, options?),第三个参数允许传入:
ts
interface ThrottleOptions {
leading?: boolean // 是否在时间窗口开始立刻执行一次(默认 true)
trailing?: boolean // 是否在窗口结束时(若有被压缩的调用)再补执行一次(默认 true)
}基础概念
给定一个固定长度的“时间窗口”(例如示例中的 wait = 3 单位),对一串在不同时间点发起的调用进行“压缩”,使得在窗口期内的多次调用按配置仅保留特定的执行时刻。
时间轴示例
原始调用序列(字母代表调用标识,位置代表调用时间刻度):
─ A ─ B ─ C ─ ─ D ─ ─ ─ ─ ─ ─ E ─ ─ F ─ G
0 2 3 6 12 13 14 (时间刻度示意)在 wait = 3 的前提下,不同 options 组合对应“保留下来的执行”应如下(仅保留题面核心差异):
| options | 结果执行序列 | 说明 |
|---|---|---|
{ leading: true, trailing: true } | A C D E G | 窗口开始立即执行;窗口内再次调用可在末尾补一次;F 被压缩 |
{ leading: false, trailing: true } | C D G | 跳过每个窗口的第一次(如 A、E)但在窗口末尾补发 |
{ leading: true, trailing: false } | A D E | 仅窗口开头,末尾不补发(C、G 被压缩) |
{ leading: false, trailing: false } | (无执行) | 两者皆禁用 => 不触发 |
若实现中发现与该表格不一致,说明窗口起止或补发逻辑处理有偏差。 `
需求要点(必须满足)
- 尊重
leading与trailing的组合:四种情形输出与表格一致。 leading === false && trailing === false时不会产生任何执行。- 仅在允许的时间点执行,且不多执行、不漏执行。
trailing补发只在窗口内“曾经有被压缩的调用”时才发生。- 不依赖真实时间精度:评测环境会对
setTimeout / clearTimeout做可控替换。
行为细节
- “窗口开始”指:某次执行后的
wait周期起点。 - 如果
leading = false,第一次调用不会立即执行,窗口基准需推后(具体实现方式由你决定,但结果必须符合表格)。 - 如果在一个窗口内多次调用,且
trailing = true,窗口结束时应使用“最后一次调用的参数”再执行一次。 - 如果在窗口期间没有新的调用,则不应产生“多余的 trailing 执行”。
题目模版
js
/**
* @param {(...args: any[]) => any} func
* @param {number} wait
* @param {{leading: boolean, trailing:boolean}} option
* @returns {(...args: any[]) => any} func
*/
export default function throttle(func, wait, option = { leading: true, trailing: true }) {
}测试代码
js
import { afterEach, describe, expect, it, vi } from 'vitest'
import throttle from './throttle'
afterEach(() => {
vi.useRealTimers()
})
describe('day24 throttle', () => {
it('默认 leading+trailing: 只立即执行一次并在窗口结束执行最后一次', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 100)
throttled('a1')
throttled('a2')
throttled('a3')
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0]).toEqual(['a1'])
vi.advanceTimersByTime(100) // 触发 trailing
expect(fn).toHaveBeenCalledTimes(2)
expect(fn.mock.calls[1]).toEqual(['a3']) // 最后一次参数
vi.advanceTimersByTime(100) // 清理第二个内部计时器
expect(fn).toHaveBeenCalledTimes(2) // 没有多余调用
})
it('leading:false trailing:true: 首次不执行,窗口结束执行最后一次', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 100, { leading: false, trailing: true })
throttled('x1')
throttled('x2')
expect(fn).toHaveBeenCalledTimes(0)
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0]).toEqual(['x2'])
vi.advanceTimersByTime(150)
expect(fn).toHaveBeenCalledTimes(1)
})
it('leading:true trailing:false: 只有立即调用,没有尾部调用', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 100, { leading: true, trailing: false })
throttled('a')
throttled('b')
throttled('c')
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0]).toEqual(['a'])
vi.advanceTimersByTime(100) // 定时器结束后允许下一次立即执行
expect(fn).toHaveBeenCalledTimes(1)
throttled('d')
expect(fn).toHaveBeenCalledTimes(2)
expect(fn.mock.calls[1]).toEqual(['d'])
vi.advanceTimersByTime(100)
throttled('e')
expect(fn).toHaveBeenCalledTimes(3)
expect(fn.mock.calls[2]).toEqual(['e'])
})
it('leading:false trailing:false: 永远不会调用原函数', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 100, { leading: false, trailing: false })
throttled(1)
throttled(2)
vi.advanceTimersByTime(500)
throttled(3)
vi.advanceTimersByTime(500)
expect(fn).toHaveBeenCalledTimes(0)
})
it('上下文 this 与参数保留: trailing 使用最后一次参数', () => {
vi.useFakeTimers()
const calls = []
const obj = {
value: 42,
method: null,
}
function original(a, b) {
calls.push({ thisVal: this, args: [a, b] })
}
obj.method = throttle(original, 100) // 默认 leading+trailing
obj.method(1, 'first')
obj.method(2, 'second') // 覆盖
obj.method(3, 'third') // 最终 trailing 应该用这个
expect(calls.length).toBe(1)
expect(calls[0]).toEqual({ thisVal: obj, args: [1, 'first'] })
vi.advanceTimersByTime(100)
expect(calls.length).toBe(2)
expect(calls[1]).toEqual({ thisVal: obj, args: [3, 'third'] })
})
it('多时间窗口行为: 连续窗口中的调用次数与时序', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 100)
// 第一个窗口
throttled('w1-a')
throttled('w1-b')
vi.advanceTimersByTime(100) // trailing -> 第二次
expect(fn).toHaveBeenCalledTimes(2)
expect(fn.mock.calls[0]).toEqual(['w1-a'])
expect(fn.mock.calls[1]).toEqual(['w1-b'])
// 第二个窗口内(timer 仍在第二个 wait 周期)
throttled('w2-a') // 不立即执行 (仍处于内部第二个 setTimeout)
vi.advanceTimersByTime(100) // trailing -> 第三次
expect(fn).toHaveBeenCalledTimes(3)
expect(fn.mock.calls[2]).toEqual(['w2-a'])
// 计时器清除后新一轮
vi.advanceTimersByTime(100) // 清除第二级空计时器
throttled('w3-a') // 新窗口 leading
expect(fn).toHaveBeenCalledTimes(4)
expect(fn.mock.calls[3]).toEqual(['w3-a'])
})
it('在窗口中只保留最后一次调用参数 (覆盖 savedArgs)', () => {
vi.useFakeTimers()
const fn = vi.fn()
const throttled = throttle(fn, 80)
throttled('a')
throttled('b')
throttled('c')
throttled('d')
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0]).toEqual(['a'])
vi.advanceTimersByTime(80)
expect(fn).toHaveBeenCalledTimes(2)
expect(fn.mock.calls[1]).toEqual(['d'])
})
})答案
| 类型 | 路径 |
|---|---|
| JS 版本 | problems/Day 24/answer.js |
| TS 版本 | 待补充 |
| Review | 待补充 |