深入浅出:JavaScript中防抖与节流详解

1.前言

宝藏秘籍书太火爆,被借的太多,所以一直被打扰,一直敲门,导致我们必须要限流。我们会在门口出示借书规则,不要一直敲门,工作日每两个小时会开放借一本书(节流);休息日,提示更是不要一直敲门,印刷员工需要休息,如果一直敲门,就会一直借不到书,敲门之后等待两个小时,印刷工在开门借书,如果在两个小时之内又敲门,则会重新计时(防抖)。这样既保证每天都能借到书,又要照顾我们印刷员工的情绪。

2.详解

  • 防抖:事件触发 n 秒后,回调才会执行,如果在 n 秒内再次触发,则会重新计时
  • 节流:事件触发 n 秒后,回调才会执行,如果在 n 秒内持续触发,在 n 秒间隔之内只执行一次

3.适用场景

  • 防抖适用场景:关心的是最终状态结果

    • 用户输入完成后搜索
    • 调整窗口大小后重新布局
  • 节流适用场景:关心的是定期反馈中间状态结果

    • 鼠标滚动事件获取滚动位置
    • 鼠标移动跟踪鼠标位置
    • 频繁触发点击事件

4.优缺点

4.1 防抖的优缺点

  • 防抖的优点
    • 聚焦最终状态,减少中间无用调用次数
  • 防抖的缺点
    • 实时场景需求上,延迟执行可能造成卡顿

4.2 节流的优缺点

  • 节流的优点
    • 优化性能,减少函数无效调用次数,在固定时间间隔内又有反馈
  • 节流的缺点
    • 可能冗余,在事件间隔内停止触发,可能还会在执行最后一次

5.代码实现

5.1 防抖代码实现

  1. 使用闭包保存定时器的状态,使得状态持久化,设置参数回调函数和延迟时间
javascript 复制代码
const debounce = (func,delay) => {

    let timeoutId;

    const debounced =  function(...args) {
        
        //每次执行前,需要清除定时器
        if (timeoutId) {
            clearTimeout(timeoutId);
        }
        timeoutId = setTimeout(() => {
            //调用函数如果重新绑定 this,支持 this 能够正确绑定
            func.apply(this,args);
        }, delay);      
    }

    return debounced
}
  1. 支持立即执行配置
ini 复制代码
const debounce = (func,delay,immediate) => {

    let timeoutId;

    const debounced =  function(...args) {
        
        //每次执行前,需要清除定时器
        if (timeoutId) {
            clearTimeout(timeoutId);
        }

        if(immediate) {
           func.apply(this,args);
           immediate = false;
           return;

        }
        timeoutId = setTimeout(() => {
            //调用函数如果重新绑定 this,支持 this 能够正确绑定
            func.apply(this,args);
        }, delay);      
    }

    return debounced
}
  1. 支持取消函数方法
scss 复制代码
const debounce = (func,delay,immediate) => {

    let timeoutId;

    const debounced =  function(...args) {
        
        if (timeoutId) {
            clearTimeout(timeoutId);
        }
        if(immediate) {
            func.apply(this,args);
            immediate = false; // 立即执行后,设置为false
            return;
        }

        timeoutId = setTimeout(() => {
            //调用函数如果重新绑定 this,支持 this 能够正确绑定
            func.apply(this,args);
        }, delay);      
    }

    // 取消函数继续执行
    debounced.cancel = () => {
        if(timeoutId) {
            clearTimeout(timeoutId);
        }
    }

    return debounced
}

5.2 节流代码实现

  1. 使用时间戳控制执行频率,如果等待时间大于间隔时间,则执行回调事件。1 秒内多次执行只执行一次
ini 复制代码
const throttle = (func, limit) => {

    let timeoutId,lastExcuted = 0;

    if(!limit || limit < 1000){ limit = 1000; } // 最小限制为1000毫秒

    return function(...args) {

        const wait = limit - (Date.now() - lastExcuted);

        if (wait <= 0) {

          if (timeoutId) {
              clearTimeout(timeoutId);
              timeoutId = null
          }
          func.apply(this, args);
          lastExcuted = Date.now();
        } 
      
    }
}
  1. 支持第一次触发立即执行
ini 复制代码
const throttle = (func, limit,options = {leading: true}) => {

    let timeoutId,lastExcuted = 0;

    if(!limit || limit < 1000){ limit = 1000; } // 最小限制为1000毫秒

    return function(...args) {

        const wait = limit - (Date.now() - lastExcuted);

        //支持灵活配置是否首次执行
        if(!lastExcuted && !leading) {
            lastExcuted =  Date.now()
        }

        if (wait <= 0) {

          if (timeoutId) {
              clearTimeout(timeoutId);
              timeoutId = null
          }
          func.apply(this, args);
          lastExcuted = Date.now();
        } 
      
    }
}
  1. 支持尾部执行,在间隔时间内的最后一次触发会执行
ini 复制代码
const throttle = (func, limit,options = {leading: true,trailing: true}) => {

    let timeoutId,lastExcuted = 0;

    if(!limit || limit < 1000){ limit = 1000; } // 最小限制为1000毫秒

    return function(...args) {

        const wait = limit - (Date.now() - lastExcuted);

        //支持灵活配置是否首次执行
        if(!lastExcuted && !leading) {
            lastExcuted =  Date.now()
        }

        if (wait <= 0) {

          if (timeoutId) {
              clearTimeout(timeoutId);
              timeoutId = null
          }
          func.apply(this, args);
          lastExcuted = Date.now();
        } else if (trailing) {
            // 如果设置了trailing,使用setTimeout来延迟执行
            if (timeoutId) {
                clearTimeout(timeoutId);
            }
            timeoutId = setTimeout(() => {
              result = func.apply(this, args);
              resolve(result);
              lastExcuted = Date.now();
              timeoutId = null
            }, wait);
        }
      
    }
}
  1. 保持与回调函数的返回值一致,尾部执行属于异步调用执行,我们可以使用 Promise来接收函数返回值
ini 复制代码
const throttle = (func, limit,options = { leading: true, trailing: true }) => {

    if (typeof func !== 'function') {
        throw new TypeError('Expected a function');
    }
    
    let lastExcuted = 0;
    let timeoutId;
    if(!limit || limit < 1000){ limit = 1000; } // 最小限制为1000毫秒
    const { leading = true, trailing = true } = options

    return function(...args) {
      return new Promise((resolve, reject) => { 
        let result;
        //首次不执行
        if(!lastExcuted && !leading) {
            lastExcuted = Date.now();;
        }

        const wait = limit - (Date.now() - lastExcuted);

        if (wait <= 0) {
          if (timeoutId) {
              clearTimeout(timeoutId);
              timeoutId = null
          }
          
          result = func.apply(this, args);
          resolve(result);
          lastExcuted = Date.now();
            
        } else if (trailing ) {
            // 如果设置了trailing,使用setTimeout来延迟执行
            if (timeoutId) {
                clearTimeout(timeoutId);
            }
            timeoutId = setTimeout(() => {
              result = func.apply(this, args);
              resolve(result);
              lastExcuted = Date.now();
              timeoutId = null
            }, wait);
        }
      })
    }
}

6.测试验证

防抖与节流都进行了单元测试,使用了Jest框架,下面是测试的几个测试用例,完全通过

6.1防抖函数单元测试

scss 复制代码
// debounce.test.js
const debounce = require('../4.debounce');

// Jest内置的定时器模拟
jest.useFakeTimers();

describe('debounce 防抖函数', () => {
  let mockFunc; // 用于测试的模拟函数

  beforeEach(() => {
    // 重置模拟函数
    mockFunc = jest.fn();
  });

  afterEach(() => {
    // 清除所有定时器
    jest.clearAllTimers();
  });

  test('延迟时间内多次调用,只执行最后一次', () => {
    const debouncedFunc = debounce(mockFunc, 100);

    // 连续调用3次
    debouncedFunc('第一次');
    debouncedFunc('第二次');
    debouncedFunc('第三次');

    // 此时定时器未触发,函数不应执行
    expect(mockFunc).not.toHaveBeenCalled();

    // 快进时间到100ms(刚好触发定时器)
    jest.advanceTimersByTime(100);

    // 验证只执行了最后一次调用
    expect(mockFunc).toHaveBeenCalledTimes(1);
    expect(mockFunc).toHaveBeenCalledWith('第三次');
  });

  test('两次调用间隔超过延迟时间,都会执行', () => {
    const debouncedFunc = debounce(mockFunc, 100);

    // 第一次调用
    debouncedFunc('第一次');
    // 快进150ms(超过延迟时间,触发第一次执行)
    jest.advanceTimersByTime(150);
    expect(mockFunc).toHaveBeenCalledTimes(1);
    expect(mockFunc).toHaveBeenCalledWith('第一次');

    // 第二次调用(与第一次间隔超过100ms)
    debouncedFunc('第二次');
    // 再快进100ms
    jest.advanceTimersByTime(100);
    expect(mockFunc).toHaveBeenCalledTimes(2);
    expect(mockFunc).toHaveBeenCalledWith('第二次');
  });

  test('延迟时间准确', () => {
    const delay = 200;
    const debouncedFunc = debounce(mockFunc, delay);

    debouncedFunc();
    // 快进199ms(未到延迟时间)
    jest.advanceTimersByTime(199);
    expect(mockFunc).not.toHaveBeenCalled();

    // 再快进1ms(总时间200ms)
    jest.advanceTimersByTime(1);
    expect(mockFunc).toHaveBeenCalledTimes(1);
  });

  test('能正确传递参数和this指向', () => {
    const context = { name: '测试对象' };
    const debouncedFunc = debounce(function(arg1, arg2) {
      expect(this).toBe(context); // 验证this指向
      expect(arg1).toBe('参数1');
      expect(arg2).toBe('参数2');
    }, 100);

    // 绑定上下文并传参
    debouncedFunc.call(context, '参数1', '参数2');
    jest.advanceTimersByTime(100);
  });

  test('立即执行函数,第一次调用立即执行', () => {
    const debouncedFunc = debounce(mockFunc, 100, true);

    // 第一次调用
    debouncedFunc('第一次');
    expect(mockFunc).toHaveBeenCalledTimes(1);
    expect(mockFunc).toHaveBeenCalledWith('第一次');

    // 第二次调用
    debouncedFunc('第二次');
    // 再快进100ms
    jest.advanceTimersByTime(100);

    expect(mockFunc).toHaveBeenCalledTimes(2);
    expect(mockFunc).toHaveBeenCalledWith('第二次');
  });

  test('取消函数,函数不在执行', () => {

    const debouncedFunc = debounce(mockFunc, 100);

    // 第一次调用
    debouncedFunc('第一次');
    // 取消防抖函数
    debouncedFunc.cancel();
    // 再快进100ms
    jest.advanceTimersByTime(100);

    expect(mockFunc).not.toHaveBeenCalled();

  });
});

6.2节流函数单元测试

scss 复制代码
// throttle.test.js
const throttle = require('../5.throttle');

// 模拟定时器
jest.useFakeTimers();

describe('节流函数测试', () => {
  let callback;
  let throttledFunc;
  let returnFunc;

  beforeEach(() => {
    // 重置模拟函数
    callback = jest.fn();
    // 创建带返回值的测试函数
    returnFunc = jest.fn((num) => {
      return num * 2;  // 返回输入值的2倍
    });

    // 清除所有定时器
    jest.clearAllTimers();
  });


  // 测试默认配置(leading=true, trailing=true)
  test('默认配置下,函数应按指定间隔执行并在最后触发一次', () => {
    throttledFunc = throttle(callback, 1000);

    // 连续触发5次,每次间隔20ms
    throttledFunc(1); // t=0
    throttledFunc(2); // t=20
    throttledFunc(3); // t=40
    throttledFunc(4); // t=60
    throttledFunc(5); // t=80

    // 第一次调用应立即执行
    expect(callback).toHaveBeenCalledTimes(1);
    expect(callback).toHaveBeenCalledWith(1);

    // 快进到99ms,仍在第一个间隔内
    jest.advanceTimersByTime(990);
    expect(callback).toHaveBeenCalledTimes(1); // 未到间隔,不执行

    // 快进到100ms,第一个间隔结束
    jest.advanceTimersByTime(10);
    expect(callback).toHaveBeenCalledTimes(2);
    expect(callback).toHaveBeenCalledWith(5); // 最后一次的参数
  });

  // 测试leading=false(不立即执行)
  test('leading=false时,首次调用不应立即执行', () => {
    throttledFunc = throttle(callback, 1000, { leading: false });

    throttledFunc(1); // t=0
    expect(callback).not.toHaveBeenCalled(); // 不应立即执行

    // 快进50ms,触发第二次调用
    jest.advanceTimersByTime(500);
    throttledFunc(2);

    // 快进50ms,达到间隔时间
    jest.advanceTimersByTime(500);
    expect(callback).toHaveBeenCalledTimes(1);
    expect(callback).toHaveBeenCalledWith(2); // 应使用最后一次调用的参数
  });

  // 测试trailing=false(不执行最后一次)
  test('trailing=false时,间隔结束后不应执行最后一次', () => {
    throttledFunc = throttle(callback, 1000, { trailing: false });

    throttledFunc(1); // t=0
    expect(callback).toHaveBeenCalledTimes(1); // leading=true,立即执行

    // 快进50ms,触发第二次调用
    jest.advanceTimersByTime(500);
    throttledFunc(2);

    // 快进50ms,达到间隔时间
    jest.advanceTimersByTime(500);
    expect(callback).toHaveBeenCalledTimes(1); // trailing=false,不执行
  });

  // 测试this上下文绑定
  test('节流函数应正确绑定this上下文', () => {
    const context = { value: 10 };
    function testFunc() {
      callback(this.value);
    }
    
    throttledFunc = throttle(testFunc, 1000);
    
    // 使用call绑定上下文
    throttledFunc.call(context);
    expect(callback).toHaveBeenCalledWith(10);
  });

  // 测试间隔外的调用
  test('超过间隔时间的调用应重新执行', () => {
    throttledFunc = throttle(callback, 1000);

    throttledFunc(1); // t=0
    expect(callback).toHaveBeenCalledTimes(1);

    // 快进150ms,超过间隔时间
    jest.advanceTimersByTime(1500);
    throttledFunc(2); // t=150
    expect(callback).toHaveBeenCalledTimes(2); // 应再次执行
    expect(callback).toHaveBeenCalledWith(2);
  });

  // 测试长时间连续调用
  test('长时间连续调用应按间隔规律执行', () => {
    throttledFunc = throttle(callback, 1000);

    // 每20ms调用一次,持续500ms
    for (let i = 0; i < 25; i++) {
      
      throttledFunc(i);
      jest.advanceTimersByTime(200);
    }

    //总时长5000ms,预期执行6次(0:0ms, 4:1000ms,:9:2000ms,:14:3000ms,:19:4000ms,:24:5000ms)
    expect(callback).toHaveBeenCalledTimes(6);
  });
  // 测试默认模式下的返回值
  test('默认模式下应正确返回函数执行结果', async () => {
    throttledFunc = throttle(returnFunc, 1000);

    // 第一次调用(立即执行)
    const result1 = await throttledFunc(1);
    expect(result1).toBe(2);
    expect(returnFunc).toHaveBeenCalledWith(1);

    // 快进到1000ms(节流间隔结束)
    jest.advanceTimersByTime(1000);
    const result2 = await throttledFunc(2);  // 这个调用会触发尾部执行
    expect(result2).toBe(4);  // 应返回第二次调用的结果
    expect(returnFunc).toHaveBeenCalledWith(2);
  });

  // 测试leading=false模式的返回值
  test('leading=false时应返回延迟执行的结果', async () => {
    throttledFunc = throttle(returnFunc, 1000, { leading: false });

    // 第一次调用(不会立即执行)
    const promise1 = throttledFunc(1);
    expect(returnFunc).not.toHaveBeenCalled();

    // 推进到100ms
    jest.advanceTimersByTime(1000);
    
    // 应返回第一次调用的结果
    expect(await promise1).toBe(2);
    expect(returnFunc).toHaveBeenCalledWith(1);
  });

  // 测试间隔外调用的返回值
  test('超过间隔后调用应返回新的执行结果', async () => {
    throttledFunc = throttle(returnFunc, 1000);

    // 第一次调用
    const result1 = await throttledFunc(1);
    expect(result1).toBe(2);

    // 推进150ms(超过节流间隔)
    jest.advanceTimersByTime(1500);
    
    // 新的调用应立即执行并返回新结果
    const result2 = await throttledFunc(2);
    expect(result2).toBe(4);
    expect(returnFunc).toHaveBeenCalledTimes(2);
  });

  // 测试this上下文对返回值的影响
  test('正确绑定this上下文时的返回值', async () => {
    const context = { 
      multiplier: 3,
      calculate: function(num) {
        return num * this.multiplier;
      }
    };

    throttledFunc = throttle(context.calculate, 1000);

    // 绑定上下文调用
    const result = await throttledFunc.call(context, 2);
    expect(result).toBe(6);  // 2 * 3 = 6
  });
});

7.总结

防抖函数 是属于延迟执行,在防抖函数最后一次触发,等待间隔时间后执行,适用于比较关心最终结果状态的需求;节流函数属于定期执行,在固定时间频率上执行,适用于中间有实时反应状态的需求

相关推荐
wayhome在哪几秒前
用 fabric.js 搞定电子签名拖拽合成图片
javascript·产品·canvas
JayceM1 小时前
Vue中v-show与v-if的区别
前端·javascript·vue.js
楼田莉子1 小时前
C++算法题目分享:二叉搜索树相关的习题
数据结构·c++·学习·算法·leetcode·面试
HWL56791 小时前
“preinstall“: “npx only-allow pnpm“
运维·服务器·前端·javascript·vue.js
咪咪渝粮1 小时前
JavaScript 中constructor 属性的指向异常问题
开发语言·javascript
最初的↘那颗心1 小时前
Java HashMap深度解析:原理、实现与最佳实践
java·开发语言·面试·hashmap·八股文
无羡仙2 小时前
事件流与事件委托:用冒泡机制优化前端性能
前端·javascript
CodeTransfer2 小时前
今天给大家搬运的是利用发布-订阅模式对代码进行解耦
前端·javascript
鹏多多2 小时前
js中eval的用法风险与替代方案全面解析
前端·javascript
热爱2332 小时前
前端面试必备:原型链 & this 指向总结
前端·javascript·面试