JavaScript闭包系列之闭包进阶应用—手撕防抖、节流

216 阅读7分钟

个人笔记

需求:如何按按钮时频繁地发送请求?

最简单的处理:定义一个标识,来表示是否结束发送请求,结束了才可以重新请求

let box = document.querySelector('.box');
// 模拟获取数据
function queryData(callback) {
    setTimeout(() => {
        typeof callback === "function" ? callback('OK') : null;
    }, 1000);
}

let isRun = false;
box.onclick = function () {
    if (isRun) return;
    isRun = true;
    queryData(result => {
        console.log(result);
        isRun = false;
    });
};

缺点:当项目中有很多按钮的时候,需要定义的标识太多了,并且按钮触发频率还是不可控,即使等请求结束再发送请求,仍然会产生性能上的损耗。

防抖和节流的概念

防抖:在行为频繁的触发下,只识别一次。(可以控制识别第一次或最后一次)。我们自己可以规定频发触发的条件。例如,规定300ms内,只要触发多次就算是频繁触发。当频繁触发时,两次中间只要间隔小于300ms,最终只触发一次,触发间隔超过300ms,就算开始了第二轮执行。

节流:节流也是降低触发的频率,把传入的函数按照一定的频率执行,即限制一个函数在一定时间内只能执行一次,以此降低触发的频率。浏览器有自己的最快反应时间,谷歌是5-7ms,IE是10-17ms。在我们的频繁操作下,例如频繁点击按钮,谷歌浏览器的频率是5ms执行一次。意思就是,即使触发的速度再快,例如5ms内触发了两次,最终仍然算触发一次。节流就是降低这个频率,例如我们设定频率是300ms,我们在频繁触发的过程中限制设置为300ms,那么不管触发几次,300ms内只会执行一次。

  • 防抖举例:一般点击事件的优化都以为多,时间间隔内多次点击只触发一次。
  • 节流举例:键盘输入事件或者滚动条滚动事件都是以节流为主。例如输入框的自动搜索, 在输入时可限制发请求的次数。滚动条滚动时,防止坚挺的滚动时间触发次数太多。又或者轮播图的点击切换可以使用节流限制,比如300ms内点击多次只切换一次,防止轮播图滚动过快。onresizescrollmousemovemousehover 等这些事件的触发频率很高。如果我们不处理,浏览器会帮我们每隔大概5ms触发一次,会很消耗性能。除此之外,重复的 ajax 调用不仅可能会造成请求数据的混乱,还会使网络拥塞,占用服务器带宽,增加服务器压力

防抖

思路:

let box = document.querySelector('.box');

function debounce() {

    return function proxy() {
    
    };
}
function fn() {
    console.log('OK');
}
 box.onclick = debounce(fn, 300, true);

box.onclick=proxy : 疯狂点击box,疯狂触发proxy,但是我们最终想执行的是fn,所以需要我们在proxy中,基于一些逻辑的处理,让fn只执行一次即可

简易版(边界触发):不关心是第一次就触发,还是最后一次触发,默认为最后一次触发

function debounce(func, wait) {
        if (typeof func !== "function") throw new TypeError('func must be required and be an function!');

        if (typeof wait !== "number") wait = 300;
        var timer = null
        return function proxy() {
            var params = [].slice.call(arguments),
                self = this;
            if (timer) clearTimeout(timer); //timer=null 省略,因为下面直接给timer赋值了 // 如果之前的300毫秒还没有执行完,那就清除之前的
            timer = setTimeout(function () {
                if (timer) { //当最新的结束后,把没用的这个定时器也清掉「良好的习惯」
                    clearTimeout(timer);
                    timer = null;
                }
                func.apply(self, params)
            }, wait);
        };
    }

可以借助一些es6语法

const debounce = function (fn, wait) {
  if (typeof fn !== 'function') throw new TypeError('fn must be function')
  if (typeof wait !== 'number') wait = 300//默认是300

  let timer = null
  return function proxy(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      clearTimeout(timer)
      timer = null
      fn.apply(this, args)
    }, wait)
  }
}

完整版:传入参数immediate控制是否第一次触发

/*
 * debounce:函数防抖
 *   @params
 *     func「function,required」:最后要执行的函数
 *     wait「number」:设定的频发触发的频率时间,默认值是300
 *     immediate「boolean」:设置是否是开始边界触发(是否立即执行,true就在第一次触发的时候执行,不传就在最后一次执行),默认值是false
 *   @return
 *     func执行的返回结果
 */
function debounce(func, wait, immediate) {
    if (typeof func !== "function") throw new TypeError('func must be required and be an function!');
    if (typeof wait === "boolean") {//兼容这种情况debounce(fn,true)
        immediate = wait;
        wait = 300;
    }
    if (typeof wait !== "number") wait = 300;//初始化参数值,不管什么情况,都初始化为300,比如兼容这种情况debounce(fn),增强健壮性
    if (typeof immediate !== "boolean") immediate = false;//初始化参数值
    var timer = null,
        result;
    return function proxy() {
        var runNow = !timer && immediate,//一个是否立即执行的标记,没有timer(代表完全新打开页面第一次点击和 点击完,执行过以后被清除,即完全重新开始 的情况),并且是传入参数是true,就立即执行
            params = [].slice.call(arguments),//将伪数组集合变为数组
            self = this;
        if (timer) clearTimeout(timer); //干掉之前的
        timer = setTimeout(function () {
            if (timer) { //当最新的结束后,把没用的这个定时器也干掉「良好的习惯」
                clearTimeout(timer);//所有的定时器都没有了,一切从头开始
                timer = null;
            };
            !immediate ? result = func.apply(self, params) : null;//这里判断是否边界执行(最后那次执行)
        }, wait);
        runNow ? result = func.apply(self, params) : null;//里判断是否立即执行(触发之后立即执行)
        return result;//有可能有的地方会用到返回值
    };
}   
function fn(ev) {
     console.log('OK', ev, this);
}
box.onclick = debounce(fn, 300, true);

在疯狂点击缶,只触发了一次,并且函数的参数和this都正确的传到里面去了

注:这里apply传入伪数组作为参数其实也是可以的

小tip:

为什么[].slice.call(arguments) 会将伪数组集合变为数组?

MDN slice-polyfill polyfill里面原理是用了for...i循环+push,所以[].slice.call(arguments)会返回真的数组,实际上只是做了for循环+push而已

节流

function fn() {
    console.log('OK');
}
window.onscroll =fn

基础方案(两种方式)

  1. 当前执行的时间减去上次执行的时间,看是否大于wait
// 时间戳方案
function throttle(fn,wait){
    var pre = Date.now();
    return function(){
        var context = this;
        var args = arguments;
        var now = Date.now();
        if( now - pre >= wait){
            fn.apply(context,args);
            pre = Date.now();
        }
    }
}

function handle(){
    console.log(Math.random());
}

window.addEventListener("mousemove",throttle(handle,1000));
const throttle = function (fn, wait) {
if (typeof fn !== 'function') throw new TypeError('fn must be function')
if (typeof wait !== 'number') wait = 300//默认是300

let pre = Date.now();//当前执行的时间戳
return function proxy(...args) {
  let now = Date.now()
  if (now - pre >= wait) {
    fn.apply(this, args)
    pre = Date.now()
  }
}
}
  1. 定时器执行完成后再执行下一次
// 定时器方案
function throttle(fn,wait){
    var timer = null;
    return function(){
        var context = this;
        var args = arguments;
        if(!timer){
            timer = setTimeout(function(){
                fn.apply(context,args);
                timer = null;
            },wait)
        }
    }
}

function handle(){
    console.log(Math.random());
}

window.addEventListener("mousemove",throttle(handle,1000));
const throttle2 = function (fn, wait){

  if (typeof fn !== 'function') throw new TypeError('fn must be function')
  if (typeof wait !== 'number') wait = 300//默认是300
  
  let timer = null
  return function proxy(...args) {
    if(!timer){
      timer = setTimeout(()=>{
        clearTimeout(timer)
        timer = null
        fn.apply(this, args)
      }, wait)
    }
  }
}

underscore库的源码版

function throttle(func, wait) {
    if (typeof func !== "function") throw new TypeError('func must be required and be an function!');
    if (typeof wait !== "number") wait = 300;
    var timer = null,
        previous = 0,//记录上次执行完的时间,为了保证第一次可以立即执行一次,初始化为0
        result;
    return function proxy() {
        var now = +new Date(),
            remaining = wait - (now - previous),//remaining代表这一次到上一次之间,还差多长时间到500毫秒
            self = this,
            params = [].slice.call(arguments);
        if (remaining <= 0) {//第一次肯定小于零,立即执行一次,如果以后不再500ms的间隔之内,已经超过了500ms,那么不需要等待了,立即执行,并且重定previous
            // 立即执行即可
            if (timer) {//清上一个已经运行完的定时器,良好的习惯
                clearTimeout(timer);
                timer = null;
            }
            result = func.apply(self, params);
            previous = +new Date();//更新上次执行完的时间
        } else if (!timer) {
            // 没有达到间隔时间,而且之前也没有设置过定时器(或者定时器被清除),此时我们设置定时器,等到remaining后执行一次
            timer = setTimeout(function () {
                if (timer) {
                    clearTimeout(timer);
                    timer = null;
                }
                result = func.apply(self, params);
                previous = +new Date();
            }, remaining);
        }
        return result;
    };
}

以上方法第一次可以立即触发,并且以后在500ms之内触发了,就要等到500ms的间隔到了之后才会触发