恶补JavaScript基础之闭包

提醒:本文篇幅较大,请确保有足够的时间再行阅读,新手建议2小时以上。

闭包的定义

Closures (闭包)是使用被作用域封闭的变量,函数,闭包等执行的一个函数的作用域。通常我们用和其相应的函数来指代这些作用域。(可以访问独立数据的函数) 闭包是指这样的作用域,它包含有一个函数,这个函数可以调用被这个作用域所封闭的变量、函数或者闭包等内容。通常我们通过闭包所对应的函数来获得对闭包的访问。 —-MDN web docs

要深入了解闭包就必须先了几个知识点,作用域作用域链执行环境活动对象,你也可以阅读本系列的《执行上下文》篇章,更加详细的了解前置知识。

作用域

几行代码初步了解一下作用域
js中有局部作用域和全局作用域,他们的范围内的变量分别为局部变量和全局变量。

  1. var name = 'zhangsan'
  2. function fun() {
  3. console.log(name)
  4. var name = 'lisi'
  5. }
  6. fun() // undefined
  7. console.log(name) // 1

第一个输出undefined是因为在函数内部的作用域链中的活动对象里面有name这个变量(因为有name变量所以不会去顺着作用域链网外层去查找),只是在预编译阶段被复制为了undefined,而在使用name的时候还没有被赋值。第二个name直接是使用全局活动对象里面的值,也就是全局变量。

这段解释如果有疑问的话不妨继续往下看哈,我会在下面详细解释一波。请耐心把下面的文字看完,这么多字我自己也是醉醉的。。。

深入理解作用域

提醒:本节内容由本人自己理解总结,某些词汇不当或者理解错误的请大家指出

作用域其实是一套查找规则,当你定义一个变量的时候,首先会在当前的作用域去查找是否存在这个变量,如果没有则创建一个,有则忽略。这个过程是在js的预处理过程实现的,更准确来说在词法语法分析阶段。如果你在全局作用域环境下运行代码,那么解释器会创建一个全局作用域,这个全局作用域下包含了一个列表,这个列表就是作用域链,作用域链上包含了一个对象,这个对象被称之为活动对象

理解闭包 - 图1

其实词法分析的预处理就是一行一行的扫描代码,在词法分析阶段如果遇到了声明语句比如var afunction a( ) {}等),解释器会首先检测一下当前的活动对象里面是否有属性a,如果有的话就忽略,如果没有的话就创建一个属性a,并赋值为undefined。预处理结束后,就会生成可执行的代码,然后进入代码执行阶段。

在代码执行阶段当你对 a 进行赋值的时候,解释器会根据作用域链先去查找活动对象,看一下活动对象是否存在属性 a ,如果存在的话就对 a 进行赋值,如果不存在就创建属性 a 并且对他赋值。

同样的,当运行一个函数的时候(比如函数b),也是在全局的活动对象去查看是否有属性b,如果有就直接执行,如果没有的话会自己创建一个b,并赋值为undefined,然后去执行b(这个b不是函数,而是一个值为undefined的属性,所以这时候浏览器报错的时候是b不是一个函数)。

理解作用域链和活动对象

详细解释一下定义变量用var 和不用的区别,以此来更深刻理解以上几个概念。

全局环境运行一段代码的时候会有一个作用域链,这个作用域链只包含一个活动对象(全局活动对象);而你的代码在函数里面运行的时候,在定义这个函数的时候,这个函数就会包含一个作用域链,这个作用域链也只有一项,就是全局活动对象,而当你运行这个函数的时候会创建一个新的活动对象,并将它推入到这个作用域链中;这时候这个函数所对应的作用域链就包含了两个活动对象了,一个是函数声明的时候就包含了一个活动对象,指向全局活动对象;另一个是函数执行的时候创建的新的活动对象。

这个新的活动对象被推入到函数作用域链后也会进行词法语法分析,当遇到申明语句的时候,解释器只会查看当前活动对象(这里指函数自己创建的那个活动对象)中是否含有该属性,如果有则忽略,没有的话,就会创建一个。在代码执行阶段,当你对一个变量赋值的时候,解释器会从作用域链里面查看第一个活动对象(这里指函数自己创建的那个活动对象),如果有则赋值,如果没有,会查看作用域链里面查看第二个活动对象(这里指全局活动对象),如果还是没有的话,这时候会在全局活动对象中创建对应属性并赋值。

通过以上过程,在我们声明变量的时候,用了var的话,在词法分析阶段会把对应的变量放到函数对应的活动对象中,而不使用var的话,则会跳过词法分析阶段,直接进入到函数的执行阶段。在执行阶段,解释器会顺着作用域链逐个查找上面的活动对象,直到最后一个活动对象(全局活动对象),如果最后一个还是没找到的话,就会在最后一个活动对象上创建这个属性。所以你不用var创建的变量就变成了全局变量。

另外一点,通过这个过程可以发现,js的解释器在确定作用域的时候,是通过词法分析阶段来遍历活动对象来实现的,所以在js中,作用域更准确来说应该叫做词法作用域

我们修改一下最开始的例子

  1. var name = 'zhangsan'
  2. function fun() {
  3. console.log(name)
  4. name = 'lisi' // 去掉了var
  5. }
  6. fun() // zhangsan
  7. console.log(name) //lisi

在函数里面没有使用 var 定义,所以是全局变量,在函数内部活动对象里面找不到 name 所以取全局找了,然紧接着 name 被修改成了lisi,所以第二次打印出来的是lisi。
注意:

  • 作用域链的创建是在函数定义的时候,每一个函数都有自己的作用域链,只不过在函数执行的时候被推入了一个新的活动对象用于词法分析
  • 在函数执行完成并且返回结果之后,垃圾回收机制会将函数对应的活动对象删除,因此在函数外面访问不到函数里面的变量原因有两个:
    1. 你从函数外面访问变量的时候,是通过查找作用域链上的活动对象来实现的,这个作用域链跟函数作用域链没有交集。
    2. 函数的作用域链上包含的活动对象在函数执行完成后已经被删除了。所以综合这两点,会导致在函数外面你访问不到函数里面的变量。

你觉得此时我们可以来看看闭包了嘛?还不行!。

我们上节主要阐述作用域链和活动对象,难道你对预编译过程没有兴趣吗?

预编译时的变量提升和函数提升

我们来了解一下变量提升函数提升,回到最开始那个例子(为了大家看的方便我复制过来)

  1. var name = 'zhangsan'
  2. function fun() {
  3. console.log(name)
  4. var name = 'lisi'
  5. }
  6. fun() // undefined
  7. console.log(name) // zhangsan

我们详细分解一下这段代码的预编译阶段和执行阶段吧。
1.预编译阶段

  1. // 第1行,遇到 var 关键字,解析到全局活动对象
  2. name = undefined
  3. // 第2行,遇到 function 关键字,解析到全局活动对象
  4. fun = function fun(){
  5. console.log(name)
  6. var name = 'lisi';
  7. }
  8. // 第6行,没有遇到关键字,不解析
  9. // 第7行,没有遇到关键字,不解析

2.执行阶段

  1. 1行,遇到表达式 name = 'zhangsan', name 被赋值成 zhangsan
  2. 6行,遇到函数调用 fun() ,开始预编译局部

2.1预编译函数里面的作用域(此时会在作用域链上面推入一个新的活动对象)

  1. // 第3行,没有遇到关键字,不解析
  2. // 第4行,遇到 var 关键字,解析到此时的活动对象(函数内部的活动对象)
  3. name = undefined

2.2开始执行函数作用域里面的代码

  1. 3行,打印出 undefined
  2. 4行,遇到表达式,把局部 name undefined 改成 lisi

此时函数作用域代码执行完成,继续执行全局代码
好了,变量提升的过程就差不多说完了,如果还不清楚的话可以去阅读一下下面的参考4.

此时,我们终于可以来看看闭包了。

初识闭包

接下来,在介绍闭包之前我们先看一段代码

  1. var product = function(id) {
  2. var prodID=id //赋值ID,外部不可调用
  3. this.prodName="" //这个属性外部可以访问,有this. 实例化后就是属性
  4. function loadProd(obj) {
  5. // 我们假设这个过程需要复杂的获取
  6. if(prodID===1) obj.prodName="霸王油"
  7. if(prodID===2) obj.prodName="老鼠药"
  8. }
  9. loadProd(this)
  10. }
  11. var p = new product(1)
  12. console.log(p.prodName) //霸王油

以上代码中 var申明的 prodID可以理解为私有变量,而this.prodName是被构造出来的属性,被实例化后是可以被外部访问的loadProd理解为私有函数,外部是无法调用的,我们在product里面直接执行它,就将内部变量prodName的赋值给了父函数productthis.prodName属性,此时从外部去实例化product后就可以访问到内部的变量,可以看出内部函数loadProd赋值后,this.prodName的值一直保存在内存中。这可以初步理解为闭包,不过我们还得在内部传入this,这未免太低端了。

看看下面的代码你是不是更熟悉了?

  1. var product = function(id) {
  2. var prodID=id; // 赋值ID,外部不可调用
  3. var prodName=" "; // 这个属性外部不可以访问
  4. return function() {
  5. // 我们假设这个过程需要复杂的获取
  6. if(prodID==1) prodName="霸王油";
  7. if(prodID==2) prodName="老鼠药";
  8. return prodName;
  9. }
  10. }
  11. var p=new product(1);
  12. console.log(p()); //霸王油

在上面代码中,全部变量定义为私有变量,然后将私有变量经过“处理”后从内部匿名函数直接return出来。父级函数被调用之后按照常理此时活动对象应该被销毁,但是因为闭包的存在,父级函数的活动对象会被保存在返回的这个函数的活动对象中,此时父级函数中的变量就被保存下来了,所以闭包是可以保存父函数的内部变量的,这样我们就可以从外部实例访问到函数块里面的变量了。如果要销毁这种保存,只需把实例化的对象设为null(这里为p,p=null),下次垃圾回收机制就会将闭包回收。

从原理理解闭包

耐心把下面的文字看完,这么多字我也是醉醉的。。。

假设你在全局定义了变量 a 和函数 b ,此时全局的 活动对象 就包含了两个属性,a 和 b,同时函数 b 还包含一个作用域链,里面只有一个全局的活动对象。如果在 b 中定义了一个函数 c ,那么在运行 b 的时候,此时 b 的作用域链上就多了一个活动对象,这个活动对象里面包含一个属性就是 c ,而 c 也包含一个作用域链,c 的作用域链里就有两个活动对象了,第一个是 b 的活动对象,第二个是全局活动对象。如果把 c 作为返回值并且赋值给 a,此时 a 与 c 就建立了一个引用关系,这个引用关系是在函数 b 运行的时候创建的,由于建立了这个引用关系,所以在 b 执行完成之后,b 的作用域链上的活动对象不会被删除,而当运行 a 的时候,实际上运行了 c ,此时 c 的作用域链上会被推入一个新的活动对象,此时 c 的作用域链上就有三个活动对象了。

  1. var a = null;
  2. function b () {
  3. function c () {
  4. //do something
  5. }
  6. a = c;
  7. }

第一个是c自己的活动对象,这个活动对象是新创建的,要经历词法分析的,会按照c中的代码初始化一遍。第二个是 b 的活动对象第三个是全局活动对象

所以当 c 中的代码要操作某个变量的时候会依次查找这三个活动对象,如果这个变量是在 b 中被定义的,那么就会在 b 的活动对象中找到并且进行相应的操作。而操作的结果也会被保存在 b 的活动对象中。那么,函数 a 运行完成后或者说函数 a 对应的函数 c 运行完成之后, c 自己的活动对象被垃圾回收机制回收(删除)了,然而此时,c 中的作用域链中的其他两个活动对象( b 的活动对象和全局活动对象)就被保存下来了。因为 b 的活动对象没有被删除,那么 b 中的变量就被保存下来了。

如果想要删除 b 的活动对象,那么你就要断开 a 和 c 的联系,并且把 a 赋值为 null,那么在下一次垃圾回收机制运行的时候就把 b 给回收了。

再看一个例子

var xxoo = null; 
function fuck() { 
  var abc = 2;
  function innnerFuck() { 
    console.log(abc); 
  } 
  xxoo = innnerFoo; // 将 innnerFuck的引用,赋值给全局变量中的xxoo 
} 

function shit() { 
  xxoo(); // 这里保存了innnerFuck的引用
} 

fuck(); //执行闭包,让xxoo与innnerFuck建立引用关系
shit(); // 2

在上面的例子中,当fuck( )执行完毕之后,按照常理来说,fuck的作用域链会被清理,所占用的内存也会被垃圾收机制释放。但是通过xxoo = innerFuck,函数innerFuck的引用被保留了下来,。这个行为,导致了 fuck 的变量对象也被保留了下来。所以,函数xxoo在函数shit内部执行时,依然可以访问这个被保留下来的变量对象。

闭包的注意

由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE浏览器中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的函数私有变量设置为null即可。

有些时候我们需要在、一些事件(比如点击或者按键)上面,触发某种行为。

看看这个例子(来自MDN)。点击三个连接会切换字体大小,将想要触发的这个行为(改变字体大小)保存在一个函数中,返回出去。

模拟私有方法(来自MDN)

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment: function() {
      changeBy(1);
    },

    decrement: function() {
      changeBy(-1);
    },

    value: function() {
      return privateCounter;
    }
  }   
})();



console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

我们先用一个立即执行函数来创建这个计数器,以便于外部可以直接访问,不需要再实例化。

这里有很多细节。在以往的示例中,每个闭包都有它自己的环境;而这次我们只创建了一个环境,为三个函数所共享:Counter.increment,Counter.decrement 和 Counter.value。

该共享环境创建于一个匿名函数体内,该函数一经定义立刻执行。环境中包含两个私有项:名为 privateCounter 的变量和名为 changeBy 的函数。 这两项都无法在匿名函数外部直接访问。必须通过匿名包装器返回的三个公共函数访问。

这三个公共函数是共享同一个环境的闭包。多亏 JavaScript 的词法范围的作用域,它们都可以访问 privateCounter 变量和 changeBy 函数。

上面这种方式已经比较接近于模块化开发的方式了。在这里可以了解模块化开发

经典的面试题

第一种(没有timeout的,但类似于timeout)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script type="text/javascript">
    var nodes = document.getElementsByTagName("div");
    for(var i = 0; i < nodes.length; i++){
      nodes[i].onclick = function(){
        alert(i);
      }
    }
  </script>
</head>

<body>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>5</div>
</body>
</html>

上栗的原本需求是分别点击1到5弹出0到4,但是很不巧,1到5的div弹出的都是5。下面我们来解决这个问题:先说说为什么会弹出都是5,这里我觉得重点在于“异步”还是“同步”。其实在执行事件监听的那个函数的时候for循环就已经执行完成了,此时i为5,所以每次弹出的都是5.

好了,下面开始说解决办法。

方法一:用立即执行函数(IIFE)传一个参数过去(这是我头脑里最快想到的方法)

var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length; i++){
  nodes[i].onclick =(function(j){ //这里专门用j来区分,此时里面的弹出的遍量已经不是for条件里面的i了
     return function () {    
       alert(j);
     }
  })(i);这里传入i
}

这种情况下,在每次for循环的时候,“当时”的i就被传到了事件监听函数,并作为局部变量“保存了下来”,显然每次传入的i不同。注意,这里就是“同步”保存下来的,而不是像原来代码中是“异步”去取的。所以呢,最后alert的是那些当时存下来的局部的i,而不是for循环中的循环变量i。两者根本就没有任何关系了。当然,此时的循环变量i已经是5了。

方法二:给nodes[i]定义一个属性i并赋值为for循环里面的i(这是在网上看到的,好像有点皮哦)

var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length; i++){
  nodes[i].i = i
  nodes[i].onclick = function(){
    alert(this.i);
  }
}

我觉得这跟第一种的思想类似,在dom对象上定义一个属性,在循环里面赋值,此时i是属性,this.i不然是不一样的额。

方法三

var nodes = document.getElementsByTagName("div")
for(var i = 0; i < nodes.length;i++){
 (function(j){ 
   nodes[i].onclick = function () {    
     alert(j);
   }
 })(i);这里传入i
}

此方法其实跟方法一是差不多的,方法一是吧事件直接绑在IIFE上的(实际上是里面return的函数上),本方法是直接把事件绑定到IIFE里面的函数上面的,其实是一个意思。保存i的方式也是一样的。

方法四

var nodes = document.getElementsByTagName("div");
for(var i = 0; i < nodes.length;i++){
  nodes[i].onclick =(function(){ 
     alert(i);
  })();
}

这种方法就是让事件“同步”执行,事件绑定一个自执行函数,这里不像原问题中,绑定的是一个“函数的定义”而不是执行,这里的话就是绑定了一个自己执行了的函数,此时就是循环一次执行一次,变成“同步”了,当然是每次弹出不同的数字。

方法五:使用es6中的let关键字

var nodes = document.getElementsByTagName("div")
for(let i = 0; i < nodes.length; i++){ //仅仅在这里吧var改成let
    nodes[i].onclick = function(){
       alert(i);
    }
}

let关键字会申明一个会计作用域,在这里每次循环都会生成一个块级作用域,并且它们是相互独立的,没有关系的,所以每次弹出的i都是在取不同作用域的i,所以是可以解决的。

除了上面集中方法,方法一,三。四还有很多变种,大体思想都差不多,为事件创建一个块级作用域,将i进行保存;或者是让“异步”变成“同步”执行。

第二种(有timeout的)

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

这种跟上面的类似额,setTimeout将循环体里面变成了一个“异步”的操作,会打印出5个6以下我写几种方法

方法一:直接用IIFE创建一个闭包环境

for (var i=1; i<=5; i++) { //!也可以创建函数表达式哦
  !function (a) {
    setTimeout( function timer() {
      console.log(a);
    }, i*1000 );
  }(i)
}

方法二:在setTimeout的第一个参数那设置闭包环境

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

方法三:let关键字

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

——————————————————-2021-04-12更新—————————————————-

我发现了这篇文章,使用 Chrome 的调试工具来理解闭包,我谈谈一个个疑问:

闭包是指外层函数的执行环境,Chrome好像确实是这么显示的,跟很多经典书籍不一样。很多书籍还是认为内层那个去访问外层函数中的变量的函数是闭包。

本文作者观点是以 Chrome 为准,书籍都是错的。

我的理解:

我认为 Chrome 调试面板中的 Closure 指的是当前闭包形成所在的执行环境!书籍上的观点跟 Chrome 是一致的

而作者说的闭包只有在函数被调用的时候才会被创建,这个确实是的,但是为什么呢?我上面已经提到过了,因为函数的执行环境是在被调用的时候才会形成。而执行环境包含三部分:this值、Scope、变量对象VO(ES5之前是这三部分,现在的标准已经加了很多东西了)。而内层函数(执行环境)为什么能访问外层函数(执行环境)的变量呢。因为当前执行环境的 Scope 中保存的它父级以及祖先的执行环境,而在执行环境中变量对象里面保存了当前执行环境的变量列表。

要想更清楚的理解闭包,首先就要明确闭包这个概念,下图是我在网上找到的资料

理解闭包 - 图2

我很赞同的观点:在 JavaScript 中与闭包这个定义基本一致的就是正在执行的 JavaScript 函数!

是不是觉得 JavaScript 函数,这个概念太「普通」了,跟神秘的闭包不太搭嘎?反正我之前理解闭包是一愣一愣的,但是只要理解了JavaScript 代码执行方式,以及执行环境,发现这些东西不是那么难理解。可以看看我最近写的 事件循环机制,帮助理解一下JavaScript 代码是怎么执行的。

其实只要函数被调用了(执行环境生成的时候)就形成了闭包。闭包其实不是啥高深的概念,可以直接简单的理解为 携带祖先的执行环境的函数在被调用的时候就形成了闭包。至于说垃圾回收机制为什么不回收闭包呢?这就是垃圾回收的机制啊,他只跟执行环境有关系啊,闭包只是函数执行环境中的一种情况,语言在被设计的时候就对这种情况做了对应的处理机制而已。

以上纯属个人观点!

参考文章

[1].http://www.cduyzh.com/js-closure/
[2].http://www.jianshu.com/p/21a16d44f150
[3].极好的文章
[4].闭包

恶补JavaScript基础系列


恶补JavaScript基础系列目录地址:https://www.sixtyden.com/archive
恶补JavaScript基础系列是我在从学校毕业入坑前端的学习产物,它主要是我看完书以及其他资料后的一个浓缩总结。以下是我参考的主要资料:
JavaScript高级程序设计
你不知道的JavaScript(上卷)
陪你读书(JavaScript web前端)
王福朋的博客
冴羽写博客的地方
本人能力有限,如果有错误或者不严谨的地方,请务必给予指出,十分感谢!愿与君共勉。

(完)