JavaScript 闭包机制的详解

JavaScript 的闭包是老生常谈的问题了,包括面试时也喜欢问,所以是时候总结一波了。

在我看来,解决一个碰到的问题有两个思路:一是找到解决这个问题的方法就OK。二是尝试从根源上去解析这个问题,以避免被其他类似的问题困扰。

一.什么是闭包?

闭包是什么?我们先看看 MDN 上对它的解释:

闭包是函数和声明该函数的词法环境的组合。

MDN上的官方说明都比较言简意赅,解释的比较经典,也许有些伙计可能会有些迷糊,所以我简单给大家翻译下上面那句话什么意思:

1.“闭包一定与函数有关,没有函数就没有闭包”。
2.“闭包不仅仅只有函数,还包括函数的执行上下文”。

单纯的理论太干巴巴了,咱们看点实际的:

function aa(){
	var bb = 'This is a Test';
	function cc(){
        console.log(bb)
    }
    return cc;
}
var dd = aa();
dd();  // logs 'This is a Test'

上面这个函数,就是一个基础的闭包,我们可以看到,在函数 aa( ) 中有一个 函数内变量 bb 和一个函数内函数 cc( ),cc( )函数中引用了aa( ) 作用域内的 bb 变量,然后被 aa 的作用域返回,暴露给了 window 环境。

这么一个简单的闭包,我们可以简单归纳出闭包三个特性中的前两个:

1.闭包一定是函数内嵌套函数。
2.闭包可以让我们在函数外部访问函数内部的私有变量和私有函数。

OK,我们再来看一个例子:

function aa(){
	var count = 0;
	function cc(){
        count++;
        console.log(count)
    }
    return cc;
}
var dd = aa();
dd(); // logs 1
dd(); // logs 2
dd(); // logs 3

那么它使用起来与普通函数有什么区别呢?我们进行一个对比就清楚了:

function aa_normal(){
	var count = 0;
	function cc(){
        count++;
        console.log(count)
    }
    return cc();
}
aa_normal();  // logs 1
aa_normal();  // logs 1
aa_normal();  // logs 1

我们还可以更大胆,更奔放一些:

function aa_normal(){
	var count = 0;
	count++;
	console.log(count)
}
aa_normal();  // logs 1
aa_normal();  // logs 1
aa_normal();  // logs 1

这下是不是对比就很鲜明了?根据 JavaScript 的 GC 机制,普通的函数在每一次调用结束时,都会被内存回收,所以普通函数无论你执行多少次,那个 count 都只会是1,你问我为什么?因为你永远叫不醒一个装睡的人嘛。而对于闭包来说就不一样了,它会一直留在内存中,除非你手动清除它。

so~我们得出了闭包三个特性中的最后一个特性:
3.带有闭包的词法环境中的变量和函数不会被GC机制回收。

okok~我们现在总结性的看一下闭包的总的三条特性:

1.闭包一定是函数内嵌套函数。
2.闭包可以让我们在函数外部访问函数内部的私有变量和私有函数。
3.带有闭包的词法环境中的变量和函数不会被GC机制回收。

二.循环中的闭包

闭包其实还有很多其他高级的用法,比如说以下列表:

  1. 结果缓存
  2. 封装
  3. 实现类和继承

但是这些并不是我们这篇文章的重点。本文的重点是啥?是解决刚需!什么叫刚需,就是你跟你女朋友擦枪走火,她怀孕你们得结婚了,房子就是刚需了。那么闭包对于大家来说什么情况下是刚需?那就是闭包出问题的时候是刚需!所以本文重点就是探讨,什么情况下它会出问题,以及怎么解决它。

下面我说说第一种思路。
咱们先看一个业务中常见的需求:后台给一个类数组,里面都是对象,你拿到这个数据后要在一个 ul 中生成相应的 li 列表,并给对应的 li 添加点击事件。我们来看代码:

<!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>Document</title>
</head>
<body>
<ul class="data-list"></ul>
</body>
</html>
<script>
var data = [{name:'aaa'}, {name:'bbb'}, {name:'ccc'}];
function initDom(){
    for (var i= 0;i< data.length ;i++){
    		var p = i;
            var ulDom = document.querySelector('.data-list');
            var liDom = document.createElement('li');
            var text = document.createTextNode(data[i].name);
            liDom.className = 'li' + i;
            liDom.appendChild(text);
            ulDom.appendChild(liDom)
        	document.querySelector('.data-list .li'+i).onclick = function() {
				alert(data[p].name);
			}
    }
};
initDom();
</script>

正常情况下,我们想要的结果自然是点击aaa,弹出aaa,点击bbb,弹出bbb;但是事实上却是无论单击谁,弹出的都是ccc。原因就在于闭包了。在 initDom( )这个函数中,我们给 li 绑定了点击事件,所以说,其实是有三个闭包,每次循环都有一个,如果这三个闭包都有各自的作用域链,那大家相安无事,自然也不会出现这种情况;但是现在是循环结束了,点击事件还未触发,这就会造成三个闭包公用一个父级作用域链,此时循环已经结束,p都是2;

那么我们怎么解决这个问题呢?有三种方式:
第一种:立即执行函数

var data = [{name:'aaa'}, {name:'bbb'}, {name:'ccc'}];
function initDom(){
    for (var i= 0;i< data.length ;i++){
      (function () {
                var ulDom = document.querySelector('.data-list');
                var liDom = document.createElement('li');
                var text = document.createTextNode(data[i].name);
                liDom.className = 'li' + i;
                liDom.appendChild(text);
                ulDom.appendChild(liDom)
                var p = i;
                document.querySelector('.data-list .li' + i).onclick = function () {
                    alert(data[p].name);
                }
            })()
   }
};
initDom();

这时我们再去点击对应的 li,就能弹出对应的name了。

第二种:工厂函数

function showMsg(msg){
     alert(msg)
 }

function alertMsg(msg) { 
   return function () { 
      showMsg(msg)
   }
}

function initDom() {
    var data = [{
       name: 'aaa'
       }, {
       name: 'bbb'
       }, {
       name: 'ccc'
    }];
    for (var i = 0; i < data.length; i++) {
          var item = data[i];
          var ulDom = document.querySelector('.data-list');
          var liDom = document.createElement('li');
          var text = document.createTextNode(data[i].name);
          liDom.className = 'li' + i;
          liDom.appendChild(text);
          ulDom.appendChild(liDom)    
          document.querySelector('.data-list .li' + i).onclick = alertMsg(item.name)
      }
  };
initDom();

这种工厂模式,其实与我之前在总结闭包的第三个特性时举的例子很相似,它的重点在于document.querySelector('.data-list .li' + i).onclick = alertMsg(item.name)这一行代码,实际上它的作用就是创建了循环次数个的工厂函数实例,同过实例化来使每一个闭包的作用域的互干扰,最终达到目的;

第三种:ES6 中的 let
过多的闭包会占用大量的内存,特别是你存储的数据还比较大的时候,更会影响性能。所以如果你处于ES 2015 的环境,你可以尝试使用 let,它使得每一个块级作用域都有自己的作用域,简单粗暴解决问题。

function initDom() {
     var data = [{
          name: 'aaa'
          }, {
          name: 'bbb'
          }, {
         name: 'ccc'
     }];
     for (let i = 0; i < data.length; i++) {  
            var ulDom = document.querySelector('.data-list');
            var liDom = document.createElement('li');
            var text = document.createTextNode(data[i].name);
            liDom.className = 'li' + i;
            liDom.appendChild(text);
            ulDom.appendChild(liDom)
            document.querySelector('.data-list .li' + i).onclick = function () {
                alert(data[i].name);
            }
     }
};

三.闭包以外的世界

从上面的知识点我们可以发现,想更深入的使用闭包,我们还需要了解 JavaScript 以下内容:

  1. 变量作用域链
  2. JavaScript 的内存回收机制

什么是作用域链?

简单来说,就是当函数在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。何为有序?比如说有三个for循环,他们在各自的循环里都定义了一个局部变量,假设从外到内是a,b,c。对于最里面的那层循环来说,它的作用域链就是 c到b到a到全局变量。

什么是内存回收机制?

和 java 一样 JavaScrip t也是自动回收机制,把不再使用的内存回收。什么情况下不再使用呢?比如说在一个函数中定义的局部变量,在这个函数执行完毕后它就被销毁了。但是当你在一个函数中定义了另一个匿名函数,并在里面定义了一个局部变量,在这个匿名函数外部又调用了这个内部的局部变量,这时回收机制就不会生效,直到这个闭包彻底失效。

有趣的是,在我遇到这个问题不就后我又碰到一个类似的情况,那就是settimeout( )函数。使用场景是,处于某种需要,我想将一个for循环遍历的时间缩短,然后直接在for中加入了settimeout后发现打印的是乱码,这与之前的闭包问题有异曲同工之妙。

为什么会这样呢?因为js是单线程的执行顺序,而settimeout()却是一个异步的方法。如下:

var a=[1,2,3];  
var len=a.length;  
for(i=0;i<len;i++){  
    setTimeout{function(){  
       console.log(i);  
    },100}  
}  

会打印3个undefined。但是修改一下代码

var a = [1, 2, 3];
var len = a.length;
for (i = 0; i < len; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        }, i * 100)
    })(i)
}

就可以正确的log出来了。

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