Skip to content
medium
throttle增强版节流

可配置的节流函数

实现一个增强版的 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 }(无执行)两者皆禁用 => 不触发

若实现中发现与该表格不一致,说明窗口起止或补发逻辑处理有偏差。 `

需求要点(必须满足)

  1. 尊重 leadingtrailing 的组合:四种情形输出与表格一致。
  2. leading === false && trailing === false 时不会产生任何执行。
  3. 仅在允许的时间点执行,且不多执行、不漏执行。
  4. trailing 补发只在窗口内“曾经有被压缩的调用”时才发生。
  5. 不依赖真实时间精度:评测环境会对 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待补充

内容基于 MIT 许可 | 保持节奏 · 持续积累