防抖和节流知识点总结

前言

学习这一部分我们要搞清楚的是:

  • arguments是什么?以及它的基本用法
  • 简单的防抖节流的实现,要在面试中能够手写简单防抖和节流的代码
  • 分析 underscore 中防抖和节流函数实现的源码

JS的arguments

  • 在函数调用的时候,浏览器每次都会传递进两个隐式参数:

    一个是函数的上下文对象this,另一个则是封装实参的类数组对象arguments。

  • 与其他程序设计语言不同,ECMAScript不会验证传递给函数的参数个数是否等于函数定义的参数个数。开发者定义的函数都可以接受任意个数的参数(但根据Netscape的文档,最多可接受255个),而不会引发任何错误。任何遗漏的参数都会以undefined传递给函数,多余的函数将忽略。
    即参数从左向右进行匹配,如果实参个数少于形参,后面的参数对应赋值为 undefined

function fn (a, b, c) {
    console.log(a, b, c); // 1 2 undefined
    // 函数对象的length属性就是函数形参的个数
    console.log(fn.length); // 3
}
fn(1, 2);

arguments

一、 arguments

1. arguments 定义

arguments 的定义是对象,但是因为对象的属性是无序的,而 arguments 是用来存储实参的,是有顺序的,它具备和数组相同的访问性质及方式,并拥有数组长度属性 length,所以 arguments 是特殊的对象,又叫类数组对象,当我们听到类数组时就可以知道说的是 arguments

即arguments是一个类数组对象,用来存储实际传递给函数的参数,使调用函数时不局限于函数声明所定义的参数列表。

function fn() {
    console.log(arguments);
    console.log(typeof arguments); // object
    console.log(toString.call(arguments)); // [object Arguments]
}
fn('name', 'age');

2. 访问实参和检查实参个数

arguments 访问单个参数的方式与访问数组元素的方式相同。例如 arguments[0]arguments[1]arguments[n] ,在函数中不需要明确指出参数名,就能访问它们。通过length属性可以知道实参的个数。

function f2() {
    console.log(arguments[0]); // name
    console.log(arguments[1]); // age
    console.log(arguments.length); // 2
}
f2('name', 'age');

3. callee属性

每一个对象都有自己的属性,而 arguments 有一个 callee 属性,返回正被执行的Function对象。

function f3() {
    console.log(arguments.callee === f3); // true
}
f3('name', 'age');

4. arguments的修改

在正常的模式下,arguments 对象是允许在运行时进行修改的。

function f4() {
    arguments[0] = 'sex';
    console.log(arguments[0]); // sex
}
f4('name', 'age');

5. 转化成真实数组

arguments 是类数组对象,除了 length 属性和索引元素之外没有任何 Array 属性。例如,它没有 pop 方法。但是它可以被转换为一个真正的 Array:

function f5(){
    // 可以使用slice来将arguments转换为真实数组
    var args1 = Array.prototype.slice.call(arguments);
    var args2 = [].slice.call(arguments);
    // 也可以使用Array.from()方法或者扩展运算符来将arguments转换为真实数组
    var args3 = Array.from(arguments);
    // 也可以用es6的扩展运算符
    var args4 = [...arguments];
}
f5('name', 'age');

二、用途

1. 借用 arguments.length 可以来查看实参和形参的个数是否一致

function fn (a, b, c) {
    if (fn.length != arguments.length) {
        console.log('形参和实参的个数不一致');
    } else{
        console.log('形参和实参的个数一致');
    }
}
fn(1, 2);

2. 借用 arguments.callee 来让匿名函数实现递归

let sum = function (n) {
    if (n == 1) {
        return 1;
    } else {
        return n + arguments.callee(n - 1); // 5 4 3 2 1
    }
}
console.log(sum(6)); // 21

3. 遍历参数求和或者求最大值

function max () {
    var max = arguments[0];
    for (item of arguments) {
        if (item > max) {
            max = item;
        }
    }
    return max;
}
console.log(max(5, 3, 2, 9, 4)); // 9

4. 模拟函数重载

重载函数是函数的一种特殊情况,为方便使用,C++ 允许在同一范围中声明几个功能类似的同名函数,但是这些同名函数的形式参数(指参数的个数、类型或者顺序)必须不同,也就是说用同一个函数完成不同的功能。

用arguments对象判断传递给函数的参数个数,即可模拟函数重载:

function doAdd() {
    if(arguments.length == 1) {
        console.log(arguments[0] + 5);
    } else if(arguments.length == 2) {
        console.log(arguments[0] + arguments[1]);
    }
}
doAdd(10);  // 15
doAdd(10, 20); // 30

三、总结

  • arguments 是一个类数组对象,用来存储实参;具有lengthcallee等属性;可以用arguments[0] 这个形式访问实参;可以转换为真实数组。
  • arguments 和函数相关联,其只有在函数执行时可用,不能显式创建。
  • arguments 可以用来遍历参数;通过 callee 实现递归;也可以模拟函数重载。

函数防抖(debounce)

在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。

经常用于window 的 resize、scroll,mousedown、mousemove、keyup、keydown等场景

这里我们用一个简单的input输入框通过监听input事件,来模拟防抖函数的应用场景,这个场景一般应用在搜索查询功能中,假设我们要搜索一件商品,那么我们应该等输入完整的商品名称后再调用接口,如果在输入过程中一直调用接口那么对性能是一个极大的考验。这时候就需要用防抖函数来处理:

// 获取到input的 DOM 节点
var Doc = document.querySelector('input');
    
function checkData() {
  // 这里正常应该是调用接口查询数据,我们这里做简单打印模拟
  console.log(Doc.value);
}

function debounce(func, wait) {
  var timeout;
  return function(){
    clearTimeout(timeout)
    timeout = setTimeout(func(), wait)
  }
}

// 到这里就实现了一个最简陋的防抖函数

this

但是在上面的代码中我们发现,如果我们在 checkData 函数中 console.log(this),在不使用 debounce 函数的时候,this 是指向 input这个dom节点的。但是如果我们使用了 debounce 函数,this 就指向了 Window,所以我们需要将 this 指向正确的对象:

// 第二版
function debounce(func, wait) {
  var timeout;
  return function(){
    var context = this; // 此时的这个this是指向input这个dom元素的

    clearTimeout(timeout)
    timeout = setTimeout(function() {
      func.apply(context); // 改变this的指向为input 
    }, wait)
  }
}

现在 this 已经可以正确指向了。让我们看下个问题:

event 对象

JavaScript 在事件处理函数中会提供事件对象 event ,我们在 checkData 中打印一下 event 对象

function checkData(e) {
  console.log(e); 
  console.log(Doc.value);
}

如果我们不使用 debounce 函数,这里会打印 InputEvent 这个事件对象,但是我们使用 debounce 后,打印的却是 undefined

所以我们再修改一下代码:

// 第三版
function debounce(func, wait) {
  var timeout;
  return function(){
    var context = this; // 此时的这个this是指向input这个dom元素的
    var args = arguments;

    clearTimeout(timeout)
    timeout = setTimeout(function() {
      func.apply(context, args); // 改变this的指向为input 
    }, wait)
  }
}

返回值

再注意一个小点,getUserAction 函数可能是有返回值的,所以我们也要返回函数的执行结果

// 第四版
function debounce(func, wait) {
  var timeout, result;
  return function(){
    var context = this; // 此时的这个this是指向input这个dom元素的
    var args = arguments;

    clearTimeout(timeout)
    timeout = setTimeout(function() {
      result = func.apply(context, args); // 改变this的指向为input 
    }, wait)

    return result;
  }
}

到此为止,我们修复了三个小问题:

  • this 指向
  • event 对象
  • 返回值

立即执行

有时候我们会遇到这种情况:我们不希望等到事件停止触发后才执行,而是一开始就立刻执行函数,等到停止触发n秒后,才可以重新触发执行函数。

举个例子,我们如果是搜索查询功能,我们有时候希望在我们刚输入字符的时候就立刻执行函数调用一次接口,在输入过程中就不会继续执行调用接口了。停止输入后,等待n秒后我们如果继续输入,又会立即执行一次函数,输入过程中同样也不会再调用接口。

那我们加个 immediate 参数判断是否是立刻执行:

// 第五版
function debounce(func, wait, immediate) {
  var timeout, result;
  return function(){
    var context = this; // 此时的这个this是指向input这个dom元素的
    var args = arguments;

    if (timeout) clearTimeout(timeout);

    if (immediate) {
        // 如果已经执行过,不再执行
        var callNow = !timeout;
        timeout = setTimeout(function(){
            timeout = null;
        }, wait)
        if (callNow) result = func.apply(context, args)
    }
    else {
        timeout = setTimeout(function(){
            result = func.apply(context, args) // 改变this的指向为input 
        }, wait);
    }

    return result;
  }
}

分析underscore.js中防抖源码

/**
  * 函数去抖,保证在一段时间内,被调用函数只是执行一次
  * @param {*} func 调用用的函数,function
  * @param {*} wait 等待的时间,单位ms
  * @param {*} immediate  当immediate为true时,第一次调用该函数的时候,就调用func函数;false表示超时之后再调用
  */
  export const debounce = function(func, wait, immediate = false) {
    let timeout, args, context, timestamp, result;

    let later = function() {
      // 记录上一次触发时间间隔
      let last = Date.now() - timestamp;

      // 上次被包装函数被调用时间间隔last小于设定时间间隔wait
      // 调用的时间比较频繁,重新计算下次执行的时间
      if (last < wait && last >= 0) {
        timeout = setTimeout(later, wait - last);
      } else {
        timeout = null;

        // 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
        if (!immediate) {
          result = func.apply(context, args);
          if (!timeout) {
            context = args = null;  // eslint-disable-line
          }
        }
      }
    };
    
    return function() {
      // 把上下文的this对象保存下来,因为下面的apply要使用
      context = this;

      args = arguments;

      // 记录当前的时间戳
      timestamp = Date.now();
      // 第一次调用该方法时,且immediate为true,则调用func函数
      let callNow = immediate && !timeout;
      // 如果延时不存在,重新设定延时,首次的时候
      if (!timeout) {
        timeout = setTimeout(later, wait);
      }
      // 如果immediate为true,那么立马调用该函数
      if (callNow) {
        result = func.apply(context, args);
        context = args = null; 
      }

      return result;
    };
  };

函数节流(throttle)

规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

节流的原理很简单:

根据首次是否执行以及结束后是否执行,效果有所不同,实现的方式也有所不同。
我们用 leading 代表首次是否执行,trailing 代表结束后是否再执行一次。

关于节流的实现,有两种主流的实现方式,一种是使用时间戳,一种是设置定时器。

使用时间戳

使用时间戳,当触发事件的时候,我们取出当前的时间戳,然后减去之前的时间戳(最一开始值设为 0 ),如果大于设置的时间周期,就执行函数,然后更新时间戳为当前的时间戳,如果小于,就不执行。

// 第一版
function throttle(func, wait) {
    var context, args;
    var previous = 0;

    return function() {
        var now = Date.now();
        context = this;
        args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = Date.now();
        }
    }
}

使用定时器

当触发事件的时候,我们设置一个定时器,再触发事件的时候,如果定时器存在,就不执行,直到定时器执行,然后执行函数,清空定时器,这样就可以设置下个定时器。

// 第二版
function throttle(func, wait) {
    var timeout, context;

    return function() {
        context = this;
        args = arguments;
        if (!timeout) {
            timeout = setTimeout(function(){
              func.apply(context, args)
              timeout = null;
            }, wait)
        }

    }
}

比较两个方法:

  • 第一种事件会立刻执行,第二种事件会在 n 秒后第一次执行
  • 第一种事件停止触发后没有办法再执行事件,第二种事件停止触发后依然会再执行一次事件

双剑合璧

那么我们想实现触发事件的时候立即执行一次,停止触发的时候还能再执行一次,综合上面两个例子的优势,我们整合一下:

// 第三版
function throttle(func, wait) {
    var timeout, context, args, result;
    var previous = 0;

    var later = function() {
        previous = Date.now();
        timeout = null;
        func.apply(context, args)
    };

    var throttled = function() {
        var now = Date.now();
        //下次触发 func 剩余的时间
        var remaining = wait - (now - previous);
        context = this;
        args = arguments;
         // 如果没有剩余的时间了或者你改了系统时间
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            func.apply(context, args);
        } else if (!timeout) {
            timeout = setTimeout(later, remaining);
        }
    };

    return throttled;
}

优化

但是有时候我们希望有头有尾,有时候又希望有头无尾,这个咋办?

那我们设置个 options 作为第三个参数,然后根据传的值判断到底哪种效果,我们约定:

leading:false 表示禁用第一次执行
trailing: false 表示禁用停止触发的回调

我们来改一下代码:

// 第四版(也是 underscore 的实现方式)

/**
 * 函数节流
 * @param {Function} func 需要进行节流处理的方法
 * @param {Number} wait 等待时间
 * @param {Object|undedined} options { leading: false } | { trailing: false } : 首次|最后一次触发不执行。默认为true,都执行
 * leading 和 trailing 同时只能设置一个为false
 */
function throttle(func, wait, options) {
    var timeout, context, args, result;
    var previous = 0;

    if (!options) options = {};

    var later = function() {
        previous = options.leading === false ? 0 : Date.now();
        timeout = null;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
    };

    return function() {
        var now = Date.now();

        if (!previous && options.leading === false) previous = now;

        var remaining = wait - (now - previous);
        context = this;
        args = arguments;

        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }

            previous = now;
            result = func.apply(context, args);

            if (!timeout) context = args = null;

        } else if (!timeout && options.trailing !== false) {
            timeout = setTimeout(later, remaining);
        }

        return result;
    }
}

注意

我们要注意 underscore 的实现中有这样一个问题:

那就是 leading:falsetrailing: false 不能同时设置。

如果同时设置的话,比如当你停止触发的时候,因为 trailing 设置为 false,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再触发的话,就会立刻执行,就违反了 leading: false,bug 就出来了,所以,这个 throttle 只有三种用法:

throttle(checkData, 1000);

throttle(checkData, 1000, {
    leading: false
});

throttle(checkData, 1000, {
    trailing: false
});

总结

  • 函数防抖和函数节流都是防止某一时间频繁触发,但是这两者之间的原理却不一样。
  • 函数防抖是某一段时间内只执行一次,而函数节流是间隔时间执行。

结合应用场景:

debounce

  • search搜索联想,用户在不断输入值时,用防抖来节约请求资源。
  • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次

throttle

  • 鼠标不断点击触发,mousedown(单位时间内只触发一次)
  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断

你可能感兴趣的:(防抖和节流知识点总结)