一个函数理解js的this和闭包——详解debounce

debounce应用场景模拟

debounce函数,俗称防抖函数,专治input、resize、scroll等频繁操作打爆浏览器或其他资源。前端面试几乎必考,当然肯定会做一些变化。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Debounce Demo</title>
</head>

<body>
  <p>Input here:</p>
  <input type="text" id="input">
  <script>
    var handler = function () {
      console.log(this, Date.now());
    }
    document.getElementById('input').addEventListener('input', handler);
  </script>
</body>

</html>

现状

用户每次输入操作都会触发handler调用,性能浪费。

目标

用户一直输入并不触发handler,直到用户停止输入500ms以上,才触发一次handler。

前提是,不修改原有的业务代码,且尽量通用。

思路

  1. setTimeout实现计时
  2. 高阶函数,即function作为参数并且返回function

代码实现过程

第一版

function debounce(fn, delay) {
  return function () {
    setTimeout(function () {
      fn();
    }, delay);
  }
}

给handler包上试试

document.getElementById('input').addEventListener('input', debounce(handler, 500));

明显不可以!!这样写只不过将每次触发都延时了500ms,并没有减少触发次数。不过我们至少实现了高阶函数,不会破坏原有的业务代码了。那么接下来就试着减少触发次数。

思路就是每次触发先clearTimeout把之前的计时器清掉,再重新setTimout。那么问题来了,第2次进来时,怎么获取到第1次的计时器,并清除呢?

第二版

function debounce(fn, delay) {
  var timer;
  return function () { // 闭包
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn();
    }, delay);
  }
}

试来试去,发现把timer放到“外面”最好(为什么不放到更外面?),每次调用进来,大家用的都是一个timer,完美。同时,我们的第一个主角登场了——闭包。

闭包

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。——百度百科

计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。——维基百科

网上可以找个很多关于闭包的概念与解释,估计越看越蒙。认识事物需要一个从具象到抽象的过程,以目前的情况来看,我们只要知道,“定义在一个函数(外函数)内部的函数(内函数),并且内函数访问了外函数的变量,这个内函数就叫做闭包”。

最关键的问题,闭包有什么用?从debounce这个例子,我们可以看到,闭包可以让每次触发的handler共享一个变量,通常用到高阶函数的地方,就会用到闭包。再举几个闭包的应用场景,比如给ajax请求加缓存、加锁,为一系列回调设置初始值,防止污染全局或局部变量等。可能这么说大家还是若有若无的,没关系,实践出真知,现实当中肯定会碰到能够应用闭包的地方的。我们继续debounce。

终于解决了触发频率的问题了。但是!细心的同学肯定发现了。我们handler里的console打印出来的this,是不一样的!!!之前的this是input结点,现在的this是window对象。这绝对是不行的,比如我想要在handler里打印input的value,现在怎么做呢?

第三版

function debounce(fn, delay) {
  // 1
  var timer;
  return function () { // 闭包
    // 2
    var ctx = this; // this上下文
    clearTimeout(timer);
    timer = setTimeout(function () {
      // 3
      fn.apply(ctx); // this上下文调用
    }, delay);
  }
}

解决思路也简单,就是先把正确的this保存起来,我们在这里把this称为“上下文”,大家可以细细品味一下这个词。然后用apply(或call)重新制定一下fn的上下文即可。

上下文this

js的this是很善变的,谁调用它,它就指向谁,所以“上下文”这个词还是很贴切的。那么,为什么在2处能够得到正确的this呢?涉及到上下文切换的地方,一共有3处,已在上面代码中标了出来。我总结了一个三步定位this法:

第一步,是否立即执行?如果是,跳过第二步!

第二步,如果不是立即执行,它一定会被转交给某个对象保管,看它被挂在了哪,或者说转交给了谁!

第三步,这个执行函数挂在谁身上,谁就是this!

我们来实践一下。

第1处:

我们需要先简单处理一下,debounce其实是挂在window全局上的,写全应该是window.debounce(handler, 500)。第一步,是立即执行的!跳过第二步!第三步,debounce挂在window上!所以this指向是window。

第2处:

先简单处理下,debounce(handler, 500)的执行结果是返回一个函数,所以下面两段代码基本上可以视为等价的

document.getElementById('input').addEventListener('input', debounce(handler, 500));

document.getElementById('input').addEventListener('input', function () { // 闭包
  // 2
  var ctx = this; // this上下文
  clearTimeout(timer);
  timer = setTimeout(function () {
    // 3
    fn.apply(ctx); // this上下文调用
  }, delay);
});

这么一看,就具体多了。第一步,不是立即执行;第二步,addEventListener是挂在dom上的方法,所以addEventListener只能把回调挂在dom上,可以理解成input.handler = function(){},等行为被触发时才执行。所以它被转交给了input;第三步,handler挂在input上,所以this指向了input!

第3处:

setTimeout是挂在window上的,所以在执行的时候,实际上是window.setTimeout()。我们用伪代码模拟下setTimeout的实现

window.setTimeout = function(fn, delay){
  // 因为不能立即执行,所以要找个地方挂fn,就只能把fn转交给它的主子window
  // 假设window存fn的属性叫setTimeoutHandler,与input.handler类似
  window.setTimeoutHandler = fn;
  // 等待delay毫秒……
  window.setTimeoutHandler(); // 执行
}

仔细理解一下,可以发现这里跟dom的回调非常像。第一步,不是立即执行;第二步,setTimeout是挂在window上的方法,所以只能转交给window的某个方法保管(假设叫setTimeoutHandler,名字不重要);第三步,setTimeoutHandler挂在window上,所以this指向window。

稳妥起见,我们再加一个例子

var obj = {
  test: function(){
    console.log(this);
  }
}
obj.test(); // obj
setTimeout(obj.test,1000); // window

第一个很简单,立即执行,不用转交。直接可以定位this指向了obj!

第二个非立即执行,虽然传进去的是obj.test,实际上需要转交给window.setTimeoutHandler保管,即window.setTimeoutHandler = obj.test。所以this指向的是window!

总之,碰到非立即执行的函数,需要仔细分析一下。

debounce最终版

function debounce(fn, delay) {
  var timer;
  return function () { // 闭包
    var ctx = this; // this上下文
    var args = arguments;
    clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(ctx, args); // this上下文调用
    }, delay);
  }
}

最后,我们再把传参也解决一下(arguments是默认的存储所有传参的类数组对象,时间关系这里就不展开了),完成。

总结

debounce是一个很实用也很经典的功能函数,每一行代码都有丰富的内涵。与其类似的还有throttle,可以查查巩固一下。本文主要是想借debounce这个实用的函数引出js当中的两个比较难理解,的点this和闭包。说实话,这两个点想讲明白很难,更靠谱的办法是用大量的实践来消化。本文算是给各位同学种下一颗种子,以后碰到类似的情况时,能够很快的想起本文的内容,帮助自己更好的理解与感悟。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章