闭包的详细介绍

255 阅读15分钟

闭包的定义和作用

1. 什么是闭包?

闭包就是一个函数和它的周围的状态的引用捆绑在一起的组合,简单理解为就是一个函数和他引用的外部变量的组合。

举个例子:

    let a = 1;
    function fn() {
      console.log("a", a);
    }
    fn();  // a 1

定义一个函数fn,里面打印变量a,这是一个自由变量,所以会往上寻找,所以在函数fn里面可以访问到外界的变量。这就是闭包的最基本的体现,也就是一个函数(fn函数)和周围状态的引用(访问到外界变量a)捆绑在一起的组合。

注意:闭包不是一个函数,而是函数和外部的引用在一起才是一个闭包。

这样看来好像外包没啥用,就只是一个作用域链向上寻找变量而已,其实不然,下面先介绍闭包的最主要的作用。

2. 闭包的作用

其实在闭包的定义里就已经能够看出来了闭包的特性,是:在函数内部能够访问到外界的变量。

根据这个特性,我们可以讲一下闭包的作用:

    function fn1() {
      let a = 1;
      function fn2() {
        console.log("a", a);
      }
      return fn2;
    }
    let fn = fn1();
    fn(); // a 1

这段代码是把闭包放进一个函数里,内层函数fn2里面打印一个变量a,因为闭包的特性所以可以获取到外层函数fn1声明的变量a,然后外层函数fn1再把内层函数fn2给return出去,用变量fn去接收,那么我们就可以通过这个函数fn去访问到外层函数fn1内部的变量了。

以前我们都是通过作用域链去访问到外层的变量,而现在通过闭包,我们就可以访问到函数内部的变量。然后因为变量是声明在函数内部的,外界无法随意去修改,但是我们又可以去访问到,这就相当于封装了一个私有变量,对变量进行了一种保护。

举个例子:我们要声明一个计数器,每次调用方法的时候这个计数器都会去+1,先写一个没有使用到闭包的代码:

    let num = 1;
    function add() {
      num++;
      console.log(num);
    }
    add(); // 2
    add(); // 3

这样可以实现功能,但是要注意一点,这个变量是声明在全局作用域的,这就说明在外面可以随意的修改这个变量,就会对实际功能造成影响,比如下面:

    let num = 1;
    function add() {
      num++;
      console.log(num);
    }
    add(); // 2
    num = 100;
    add(); // 101

因为可以随意的修改变量,就对实际效果产生了影响,此时如果使用闭包,就可以很好的避免这个问题:

    function fn1() {
      let num = 1;
      function fn2() {
        num++;
        console.log(num);
      }
      return fn2;
    }
    let add = fn1();
    add(); // 2
    num = 10;
    add(); // 3

这就是闭包最基础的作用:封装私有变量。

因为变量私有了,就避免了和其他变量的命名冲突,这也是闭包的一个作用:避免全局变量污染。

再看代码,变量num是一个局部变量,按理说会在函数fn1执行完毕以后销毁,但是在这里因为闭包的存在,延长了变量num的生命周期,让后续在调用函数add的时候都可访问到这个变量num,这也是闭包的作用:延长变量生命周期。

至于为什么会这样,是因为外层函数把内层函数给return出去以后赋值给函数add,函数add是一个全局变量,不会被回收机制回收掉,然后函数add其实就是内层函数fn2,而fn2又引用了变量num,所以函数add内引用了变量num,那么只要函数add存在对变量num的引用,那么变量num就不会被回收机制回收,就延长了生命周期。当然这也就造成了闭包的缺点:内存泄漏问题,下面会讲到。

还有一点很容易被忽略,最初的变量num为1,调用函数add第一次以后,变量num为2,再调用一次以后,变量num为3,这是我们想要的结果,但是这个结果,也说明了闭包是具有缓存的,再举个例子证明一下:

    function createCounter() {
      let count = 0;
      return {
        increase: function () {
          count++;
        },
        decrease: function () {
          count--;
        },
        getCount: function () {
          return count;
        },
      };
    }

    let counter = createCounter();
    counter.increase(); // 第一次调用increase方法,此时num+1 为1
    counter.increase(); // 第二次调用increase方法,此时num再次+1 为2
    counter.decrease(); // 第三次调用decrease方法,此时num-1 为1
    console.log(counter.getCount()); // 1

在这段代码里可以看到第二次调用increase方法的时候不是对原始变量num进行操作,而是对调用过一次increase方法后的num进行操作,包括调用decreas方法的时候也是如此,操作的也不是原始变量num,由此可以知道,闭包的一个容易忽略的作用:闭包是有缓存的。

总结一下目前提到的闭包的作用:

  • 封装私有变量。
  • 避免变量全局污染。
  • 延长变量生命周期。
  • 缓存。

一句话说就是闭包的作用就是封装一个私有变量,并延长其生命周期让它有了缓存,顺带避免了全局变量污染。

闭包的缺点和解决方法

1. 造成内存泄露

其实闭包和其他的内存泄露没有本质的区别,都是持有了不再需要的函数引用(函数不再用了,但是没有置空),会导致函数关联的词法环境无法销毁,从而导致内存泄露。闭包和内存泄露的讲解会单独出一帖子去讲解,现在只是讲个大概。

JavaScript的回收机制会回收一些用不到的内存对象,但是由于调用完外层函数后,得到了外层函数所返回的内层函数 ,而内层函数中引用了外层函数的变量,就导致变量所占用的内存无法回收,造成了变量的内存泄露。所以解决内存泄露的关键在于:解除对外部函数里变量的引用。

有下面几种方法去解决这个问题:

1.1 释放闭包(推荐)

为了避免内存泄露,我们可以在不使用闭包的时候,将其置为null,通过置为null,让其断了对变量的引用,从而让变量被回收机制回收:

    function createCounter() {
      let count = 0;
      return {
        increase: function () {
          count++;
        },
        decrease: function () {
          count--;
        },
        getCount: function () {
          return count;
        },
      };
    }

    let counter = createCounter();
    counter.increase();
    counter.increase();
    counter.decrease();
    console.log(counter.getCount()); // 1

    counter = null;
    function createClosure() {
      let value = "Hello";

      // 闭包函数
      var closure = function () {
        console.log(value);
      };

      var releaseClosure = function () {
        value = null; // 解除外部变量的引用
        closure = null; // 解除闭包函数的引用
        releaseClosure = null; // 解除解绑函数的引用
      };

      // 返回闭包函数和解绑函数
      return {
        closure,
        releaseClosure,
      };
    }

    var closureObj = createClosure();
    closureObj.closure(); // 输出:Hello
    closureObj.releaseClosure();
    closureObj.closure(); // 输出:null

1.2 立即执行函数

我们也可以使用立即执行函数去解决,因为立即执行函数执行完以后会立即销毁,所以其中的变量也会被回收。阅读完该帖并且理解的话会发现其实这里能够解决内存泄露是因为,没有使用return。

    ;(function fn() {
      let a = 1
      function add() {
        a++
        console.log(a)
      }
      add()
    })()

如果此时给立即执行函数赋值一个变量名,然后立即执行函数在return出去一个值,其实还有是内存泄露的。

    let fn = (function fn() {
      let a = 1
      function add() {
        a++
        console.log(a)
      }
      return add
    })()

    let fn1 = fn
    fn1() // 2
    fn1() // 3

1.3 不使用return

具体情况在下文分析闭包是否一定要有return里一块讲解。

2. 性能消耗高

前文说了,闭包的函数能够访问到外界变量是通过作用域链去向上一层一层查找的,如果作用域链太长了,就会影响函数的执行效率,所以我们要尽量避免闭包函数嵌套太多层数。

闭包的一些注意事项

1. 闭包一定有return吗?

不一定。看下面这段代码:

    function fn1() {
      let name = '于家宝'
      function fn2() {
        name += '是程序员'
        console.log(name)
      }
      fn2()
    }
    fn1() // 于家宝是程序员

上段代码也是一个闭包,就没有使用到return。那么什么时候我们才使用return呢?

是在我们不止一次的想要用到这个引用变量的时候才会去使用return,比如上文在讲计数器时使用到的闭包就用到了return,因为需要一直使用到里面的变量num,我们要让num去+1:

    function fn1() {
      let num = 1;
      function fn2() {
        num++;
        console.log(num);
      }
      return fn2;
    }
    let add = fn1();
    add(); // 2
    add(); // 3

我们再修改一下刚才的代码:

function fn1() {
  let name = "于家宝";
  function fn2() {
    name += "是程序员";
    console.log(name);
  }
  return fn2;
}

let fn = fn1();
fn(); // 于家宝是程序员
fn(); // 于家宝是程序员是程序员

这里的不止一次要延长它的生命周期,让它有缓存,如果此时不用return会是什么结果:

    function fn1() {
      let num = 1
      function fn2() {
        num++
        console.log(num)
      }
      fn2()
    }
    fn1() // 2
    fn1() // 2

会发现无论调用多少次函数fn,值都是2,这就说明变量num没有缓存了,就无法实现我们预期的效果,为什么会这样呢?

是因为函数fn1执行完毕以后会自动销毁,那么里面的变量num并没有像return的时候在外面被全局变量(函数add)引用,所以变量num也会被回收机制回收,所以每次调用fn1操作的都是原始的变量num,就实现不了预期效果。

这也就说明如果不使用return,就无法延长引用变量的生命周期,也就没有缓存,所以也就谈不上使用。但是这样也避免了内存泄漏问题。

所以总结一下:闭包不一定要return,但是不return,就无法延长引用变量的生命周期,也就没有缓存,这样的闭包实现不了太大的价值。

2. 闭包一定会有内存泄露吗?

不一定,比如不使用return的时候就不会存在内存泄露的问题。

3. 闭包中使用this

在JavaScript中,this的指向和位置无关,谁调用就指向谁。

在这里看两个情况,第一个情况:

    let name = 'window'
    let obj = {
      name: 'object',
      getName: function () {
        console.log(this.name) // object
        return function () {
          console.log(this.name) // window
        }
      }
    }
    obj.getName()()

obj.getName()()中,getName作为obj的方法调用,其this指针就指向了obj,所以obj.getName()执行时this指向obj。然后obj.getName()返回一个函数,obj.getName()()就等价于(obj.getName())(),可以看到新的函数没有作为任何一个对象的方法调用,只是孤立的作为函数调用,其this指针就指向window。

现在看第二个情况:

    let name = 'window'
    let obj = {
      name: 'object',
      getName: function () {
        console.log(this.name) // window
        return function () {
          console.log(this.name) // window
        }
      }
    }
    let a = obj.getName
    a()()

let a = obj.getName; a()();这样将getName提取出来再调用,赋值给a的只是getName的函数本身,并不包括调用它的环境。a()运行时a只是一个函数,并没有作为任何对象的方法,其this指针就已经指向window了。a()()就相当于(a())(),跟第一种情况一样。

解决的方法也很简单:提前设置 self = this

    let name = 'window'
    let obj = {
      name: 'object',
      getName: function () {
        console.log(this.name) // object
        let self = this
        return function () {
          console.log(self.name) // object
        }
      }
    }
    obj.getName()()

闭包的使用场景

1. 封装私有变量(最常用)

    function fn1() {
      var name = 'hello'
      return function () {
        return name
      }
    }
    var fn2 = fn1()
    console.log(fn2()) //hello

2. IIFE(自执行函数)

    ;(function () {
      var name = 'hello'
      var fn1 = function () {
        return name
      }
      //直接在自执行函数里面调用fn2,将fn1作为参数传入
      fn2(fn1)
    })()
    function fn2(f) {
      //将函数作为参数传入
      console.log(f()) //执行函数,并输出
    }

3. 循环赋值

    for (var i = 1; i <= 10; i++) {
      setTimeout(function () {
        console.log(i)
      }, i * 1000)
    }

上面这段代码,预期是每秒一次,分别输出1-10,但是实际上是每秒一次输出11,总共10次11。这是为什么呢?

因为setTimeout是延时函数,是会在循环结束以后再执行,循环结束后 i 的值是11,所以每次当然就输出11了。

在循环中的10个函数都对 i 进行了引用,因为是使用 var 声明的,所以他们都用的是同一个 i,比如第一次循环的时候 console.log(i) 此时对 i 的引用是1,但是当第2次循环的时候i被修改为2了,那么第1次循环对i的引用也是指向这个i的自己也是2,以此类推终止循环终止的时候i是11,而setTimeout里面的回调函数是在循环终止后才执行,所以每次都会是11了。

所以要想实现预期效果,需要对代码进行一些改动:

3.1 使用let

使用let声明的变量具有块级作用域,这时,每次循环都会创建一个新的块级作用域,并在该作用域内绑定一个新的i变量。

    for (let i = 1; i <= 10; i++) {
      setTimeout(function () {
        console.log(i)
      }, i * 1000)
    }

3.2 副本+立即执行函数

    for (var i = 1; i <= 10; i++) {
      ;(function () {
        var j = i
        setTimeout(function () {
          console.log(j)
        }, j * 1000)
      })()
    }

就是将循环里面的函数用自执行函数包裹起来,形成自己的作用域,然后在此作用域里面用copy一个副本 j,用来存储 i 的值,这样就可以达到想要的效果。

3.3 立即执行函数传参

    for (var i = 1; i <= 10; i++) {
      ;(function (j) {
        setTimeout(function () {
          console.log(j)
        }, j * 1000)
      })(i)
    }

和第二种方式类似,只不过把创建副本存储i值变成了立即执行函数的传参。

4 getter和setter

可以通过闭包的形式在函数里面添加类似对象中getter函数和setter函数的功能。

    function fn() {
      var name = 'hello'
      setName = function (n) {
        name = n
      }
      getName = function () {
        return name
      }

      //将setName,getName作为对象的属性返回
      return {
        setName: setName,
        getName: getName
      }
    }
    var fn1 = fn() //返回对象,属性setName和getName是两个函数
    console.log(fn1.getName()) //getter
    fn1.setName('world') //setter修改闭包里面的name
    console.log(fn1.getName()) //getter

5. 迭代器 (执行一次函数往下取一个值)

    var arr = ['aa', 'bb', 'cc']
    function incre(arr) {
      var i = 0
      return function () {
        //这个函数每次被执行都返回数组arr中 i下标对应的元素
        return arr[i++] || '数组值已经遍历完'
      }
    }
    var next = incre(arr)
    console.log(next()) //aa
    console.log(next()) //bb
    console.log(next()) //cc
    console.log(next()) //数组值已经遍历完

6. 区分参数 (相同的参数函数不会从重复执行)

    function fn1() {
      var arr = [] //用来缓存的数组
      return function (val) {
        if (arr.indexOf(val) == -1) {
          //缓存中没有则表示需要执行
          arr.push(val) //将参数push到缓存数组中
          console.log('函数被执行了', arr)
          //这里写想要执行的函数
        } else {
          console.log('此次函数不需要执行')
        }
        console.log('函数调用完打印一下,方便查看已缓存的数组:', arr)
      }
    }
    let fn = fn1()
    fn(10)
    fn(10)
    fn(1000)
    fn(200)
    fn(1000)

image.png

7. 缓存

    //比如求和操作,如果没有缓存,每次调用都要重复计算,采用缓存已经执行过的去查找,查找到了就直接返回,不需要重新计算
    var fn = (function () {
      var cache = {} //缓存对象
      var calc = function (arr) {
        //计算函数
        var sum = 0
        //求和
        for (var i = 0; i < arr.length; i++) {
          sum += arr[i]
        }
        return sum
      }

      return function () {
        var args = Array.prototype.slice.call(arguments, 0) //arguments转换成数组
        var key = args.join(',') //将args用逗号连接成字符串
        var result,
          tSum = cache[key]
        if (tSum) {
          //如果缓存有
          console.log('从缓存中取:', cache) //打印方便查看
          result = tSum
        } else {
          //重新计算,并存入缓存同时赋值给result
          result = cache[key] = calc(args)
          console.log('存入缓存:', cache) //打印方便查看
        }
        return result
      }
    })()
    fn(1, 2, 3, 4, 5)
    fn(1, 2, 3, 4, 5)
    fn(1, 2, 3, 4, 5, 6)
    fn(1, 2, 3, 4, 5, 8)
    fn(1, 2, 3, 4, 5, 6)

image.png

8. 节流函数

节流函数的作用是在限定的时间内函数只执行一次,比如:

  • 按钮提交(可以避免重复提交,当然不只这种方法,将按钮设置为不可用也可以)。

  • scroll、mousehover、mousemove等触发频率高的时候。

主要的原理就是在闭包内设置一个标记,在限定的时间内这个flag设置为true,函数再次点击则不让执行,setTimeout函数执行以后将flag设置为flase,就可以继续执行 。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=GBK" />
    <style>
      * {
        margin: 0;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <div class="box" id="div_box">
      <button onclick="fn1()">test</button>
    </div>
  </body>

  <script>
    //节流函数
    function throttle(fn, delay) {
      var flag = false
      var timer = null
      return function () {
        var args = [].slice.call(arguments, 0) //将参数转成数组
        var context = this
        if (flag) {
          //如果在限定的时间内 flag是true 则直接返回,不让执行
          return
        }
        flag = true //函数正在控制中
        //执行函数
        fn.apply(context, args)
        clearTimeout(timer) //清除定时器
        timer = setTimeout(function () {
          flag = false //延时时间过了以后,放开函数控制
        }, delay)
      }
    }
    function fn() {
      console.log(123)
    }

    var fn1 = throttle(fn, 2000) //绑定节流函数
  </script>
</html>

有一个简单版的:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=GBK" />
    <style>
      * {
        margin: 0;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <div class="box" id="div_box">
      <button onclick="fn1()">test</button>
    </div>
  </body>

  <script>
    function fn() {
      console.log(123)
    }

    var fn1 = debounce(fn, 2000) //绑定节流函数

    // 节流函数封装  短时间内快速触发一件事,只执行第一次
    function throttle(func, delay) {
      let timer = null
      return function () {
        if (!timer) {
          timer = setTimeout(() => {
            func.apply(this, arguments)
            timer = null
          }, delay)
        }
      }
    }

    // 防抖函数封装 短时间内快速触发一件事,只执行最后一次
    function debounce(func, delay) {
      let timer = null
      return function () {
        clearTimeout(timer)
        timer = setTimeout(() => {
          func.apply(this, arguments)
        }, delay)
      }
    }
  </script>
</html>