闭包

117 阅读7分钟

闭包

1. 作用域

定义:通俗讲就是变量能够访问到的范围。

分类:全局作用域、函数作用域、块级作用域

1.1 全局变量&全局作用域

在JS中变量一般分为全局变量和局部变量。全局变量定义在函数外部代码最前面的。全局变量的挂载到window对象上的变量,在网页的任何位置都可以使用全局变量。在JS中没有定义直接赋值的 变量默认就是一个全局变量。全局变量拥有全局作用域。

缺点:变量污染,命名冲突

1.2 函数变量&函数作用域

在JS中函数中定义的变量,叫函数变量,这个时候只能在函数中使用,因此它的作用域也就是在函数内部,称为函数作用域。当这个函数执行完毕后会销毁这个函数变量。

function getName () {
  var name = 'inner';
  console.log(name); //inner
}
getName();
console.log(name);

1.3 块级作用域

ES6新增了块级作用域,最直接的表现就是let,使用let声明的变量只能在块级作用域中访问,有暂时性死区的特点,也就是在变量未声明之前不能使用。(其实就是在for、if块中使用的就是块级作用域)

2. 闭包

2.1 闭包概念

一个函数和对其周围状态的引用绑定到一起的组合就是闭包。也就是闭包可以让内层函数访问到外层函数的作用域。通俗的讲,闭包就是一个可以访问其他函数内部变量的函数,即一个定义在函数内部的函数,或者直接说闭包四个内嵌函数也可以。

因为通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。

function fun1() {
	var a = 1;
	return function(){
		console.log(a);
	};
}
fun1();
var result = fun1();
result();  // 1

2.2 作用域链

2.3.1 作用域链概念:

当访问一个变量时,代码解释器会首先在当前作用域找,如果没找到会去父级作用域找,直到找到该变量或没有父级作用域,这样的链就是作用域链。

var a = 1;
function fun1() {
  var a = 2
  function fun2() {
    var a = 3;
    console.log(a);//3
  }
}

从中可以看出,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。

那么这就很形象地说明了什么是作用域链,即当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。

由此可见,闭包产生的原因:当前环境中存在指向父级作用域的引用

2.3 闭包产生的原因

闭包产生的原因:当前环境中存在指向父级作用域的引用

function fun1() {
  var a = 2
  function fun2() {
    console.log(a);  //2
  }
  return fun2;
}
var result = fun1();
result();

从上面这段代码可以看出,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2 函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。

那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,我们只需要让父级作用域的引用存在即可,因此还可以这么改代码,如下所示。

var fun3;
function fun1() {
  var a = 2
  fun3 = function() {
    console.log(a);
  }
}
fun1();
fun3();

可以看出,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3 本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是 2,最后产生了闭包,形式变了,本质没有改变。

因此最后返回的不管是不是函数,也都不能说明没有产生闭包。

2.4 闭包的表现形式及应用场景

  1. 返回一个函数

  2. 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包

    // 定时器
    setTimeout(function handler(){
      console.log('1');
    },1000);
    // 事件监听
    $('#app').click(function(){
      console.log('Event Listener');
    });
    
  3. 作为函数参数传递的形式

    var a = 1;
    function foo(){
      var a = 2;
      function baz(){
        console.log(a);
      }
      bar(baz);
    }
    function bar(fn){
      // 这就是闭包
      fn();
    }
    foo();  // 输出2,而不是1
    
  4. IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量

    var a = 2;
    (function IIFE(){
      console.log(a);  // 输出2
    })();
    

    IIFE 这个函数会稍微有些特殊,算是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域

  5. aaa

3. 如何解决循环输出问题?

在面试中,解决循环输出问题是比较高频的面试题,一般都会给一段这样的代码让你来解释

for(var i = 1; i <= 5; i ++){
  setTimeout(function() {
    console.log(i)
  }, 0)
}

上面这段代码执行之后,从控制台执行的结果可以看出来,结果输出的是 5 个 6,那么一般面试官都会先问为什么都是 6?

可以围绕这两点来答:

  1. setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。
  2. 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。

如何按顺序依次输出 1、2、3、4、5 呢?

1. 利用 IIFE

可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下

for(var i = 1;i <= 5;i++){
  (function(j){
    setTimeout(function timer(){
      console.log(j)
    }, 0)
  })(i)
}

从上面的代码可以看出,通过 let 定义变量的方式,重新定义 i 变量,则可以用最少的改动成本,解决该问题。

2. 定时器传入第三个参数

setTimeout 作为经常使用的定时器,它是存在第三个参数的,日常工作中我们经常使用的一般是前两个,一个是回调函数,另外一个是时间,而第三个参数用得比较少。那么结合第三个参数,调整完之后的代码如下

for(var i=1;i<=5;i++){
  setTimeout(function(j) {
    console.log(j)
  }, 0, i)
}

从中可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现我们想要的结果,这也是一种解决循环输出问题的途径。

注意点:

由于闭包会使一些变量一直保存在内存中不会自动释放,所以如果大量使用的话就会消耗大量内存,从而影响网页性能。