js:闭包

那令人迷惑的背后,往往隐藏着揭开谜底的惊喜

Posted by Zi Ning on March 14, 2017

js: 闭包的理解

初学js,闭包是令人困惑但又经常会用到的特性之一。刚开始只知道怎么去使用但又不清楚背后真正的原理,这样的后果就是遇到不同的场景会出现一脸懵逼的现象。好在看过一些关于它的文章后,终于对它有了进一步的认识,索性写一篇笔记记录一下,作为开过blog后的第一篇吧~

先看一下定义。

定义

In computer science, a closure is a function together with a referencing environment for the nonlocal names (free variables) of that function. ——Wikipedia

闭包是指那些能够访问独立(自由)变量的函数 (变量在本地使用,但定义在一个封闭的作用域中)。换句话说,这些函数可以“记忆”它被创建时候的环境。 ——MDN

闭包是指有权访问另一个函数作用域中的变量的函数 ——《JavaScript高级程序设计》

我的理解闭包就是用来访问不是自己作用域中的变量(其他函数内部)的函数。真正的理解闭包则需要知道几个重要的概念。

执行环境

当我们声明一个函数并且去调用它的时候,会生成一个自己的执行环境。这个执行环境有着一个与之相关的变量对象(variable object),环境中(这里就是函数)定义的所有变量和函数都保存在这个对象中。当执行环境里的代码执行完毕后,该环境销毁,保存在其中的所有变量和函数也会随之销毁。

作用域

闭包的本质之一就是作用域,了解作用域对认识闭包至关重要!

什么是作用域呢?一般来说,作用域是一套规则,用于确定是在何处以及如何查找变量。具体来说,就是这套规则是用来管理js引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。比如说,遇到一个变量,js引擎怎么知道它有没有声明过呢,要知道没有声明而对变量进行操作会带来一系列的错误。

作用域可以控制变量和参数的可见性及生命周期,它减少了命名冲突并且提供了自动内存管理。想一下,我们在不同函数里可以定义相同的变量,但它们却不会有所冲突这其实都是作用域的功劳。

作用域是怎么具体工作的,这涉及到作用域链的概念。

作用域链

作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。

当一个函数被调用时,会创建一个执行环境及相应的变量对象的一个作用域链。作用域链初始化为当前运行函数的[[Scope]](作用域)属性中的对象。这些值按照它们出现在函数中的顺序,被复制到执行环境的作用域链中。这个过程一旦完成,一个被称”活动对象“(activation object)的新对象就为执行环境创建好了。活动对象作为函数运行时的变量对象,包含了所有局部变量,命名函数,参数集合以及this。然后此对象被推入作用域链的最前端。在作用域链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位……直到为作用域链终点的全局执行环境。这样,在函数调用过程中,会查找当前环境下的活动对象(当前作用域),如果找的到就会使用这个变量,找不到就会继续查找第二位活动对象(上一级作用域),直到抵达最外层的全局作用域,这时候无论找到还是没找到,查找过程都会终止。当函数执行完毕时,局部活动对象就会销毁。

显然,作用域的本质就是一个指向变量对象的指针列表,它不是变量对象的副本,引用但不实际包含变量对象。

一个函数的作用域链大致如图:

看一个例子:

var b = 3;

function foo() {

    var a = 4;

    return a + b;

}

首先在全局环境中声明了变量b和函数foo()。函数内部语句 var a = 4首先会在函数内声明一个a变量,这时候变量a被添加到函数的作用域中。由于已经存在于当前作用域,紧接着开始执行赋值语句。接下来return a + b首先会在作用域查找变量a,由于上一句已经声明很容易在当前作用域中找到a的值,此时js引擎又会开始在当前环境查找b的值,发现查找不到,于是向上一级活动对象(这里是全局环境)继续查找,终于发现找到了b!进行计算并执行return语句。返回后,销毁函数环境中的变量。

闭包

严格来说上面的例子并不能算是闭包,闭包的情况实际上又复杂些。

看一个例子:

function myName() {
    var name = "zining";
  
    function sayName() {
        alert(name);
    }
  
    return sayName;
}

var myFunc = myName();
myFunc();

函数声明myName()里创建了一个name变量和一个sayName()函数。sayName是一个内部函数,只能在myName()中使用,它没有自己的局部变量,但是它却可以根据作用域链访问到外部函数(myName)中的变量。myName()最后将sayName返回。

在一个函数内部定义的函数会将包含函数(即外部函数)的活动对象添加到它的作用域链(第二级)中。因此sayName()的作用域链中就包括了name这个活动对象。

在调用阶段,myName()将返回的函数指针赋予变量myFunc。按照之前的说法,函数在执行完后内部变量会被销毁,我们会很合理的认为name应该会被销毁最后被垃圾收集器回收变得不可用。但实际上却不是这样。这是因为sayName()函数的作用域链依然保持着对这个活动对象的引用。换句话说,当sayName()函数返回后,其执行环境的作用域链依然会被销毁,但它的活动对象仍然会留在内存中。直到内部函数销毁后,它的活动对象才会被销毁。

这个函数在定义时的作用域以外的地方被调用,因为闭包使得函数可以继续的访问定义时的作用域。

应用

总结一下,当函数可以记住,并访问所在的作用域,即使函数是在当前的作用域之外执行,这时就产生了闭包。

闭包可以说是无处不在,但又常常被人们忽视。在定时器,事件绑定,Ajax请求,跨窗口通信,Web Works或者其他的异步(或同步)的任务中,只要使用了回调函数,实际上就是在使用闭包。

特别的,闭包还经常被用作私有变量保存的一种方法。

由于JavaScript在类的设计上的问题,它并不支持声明私有变量。作为替代,可以使用闭包来模拟私有变量和方法。这种方式也称为模块模式。用于给单例创建私有变量和特权方法。

var myObject = (function () {
    var value = 0;
  
    return {
        setValue: function () {
            value++;
        },
        getValue: function () {
            return value;
       }
    }
}())

变量myObject实际上等于在匿名函数中返回的对象。由于作用域链的缘故,这个对象可以访问在匿名函数中定义的私有变量value。即使匿名函数执行完毕,value由于被return的方法引用,仍会保留在内存中。这就给取得修改变量提供了可能,也进一步保护了私有变量不被其他方法修改。

副作用

作用域链这种配置机制也引出了闭包一个值得注意的副作用,即闭包只能取得包含函数中任何变量的最后一个值。别忘了,闭包所保存的是整个变量对象,而不是某个特殊变量。

最常见的例子可能就是在循环绑定事件了。

for (var i = 0, len = btns.length; i < len; i++) {
    btns[i].onclick = function (e) {
        alert(i);
    }
}

这段代码的本意是想给每个按钮绑定一个唯一的i值,即位置索引0的按钮返回0,位置1的函数返回1,以此类推。但它并没有想想象的那样运行,反而会在每次点击按钮的时候alert同一个数字5,即循环结束后i对自身+1的值。

这是因为事件绑定函数绑定了变量i的本身,而不是函数在构造时的变量i点的值,也就是说,这5个i都被封闭在了一个共享的全局作用域中,实际上只有一个i,回想一下作用域链中实际上是指针而不是副本。当我们点击事件的时候,实际上循环已经结束,内部函数里i所引用的活动对象就是循环过后的i值了,正如上面所说,i只会保留最后一个值。

这时候,我们可以通过创建另一个匿名函数让闭包的行为符合我们的预期。

for (var i = 0, len = btns.length; i < len; i++) {
    btns[i].onclick = (function (i) {
      return function() {
          return alert(i);
      }
    })(i);
}

这样程序就能按照我们所想的方式执行了。在定义了一个匿名函数后,我们将循环时的i作为参数传递给匿名函数,而匿名函数内部的函数因为作用域的缘故,一直引用着匿名函数的活动对象(即参数i),每个函数都拥有了i变量的一个副本,因此就可以使用各自不同的数值了。

与之类似,setTimeout()也会出现这个问题。

所以,以后在循环中创建函数时要多加小心!如果可能,避免它是最好的了。

闭包中的this

在闭包中使用this对象也会导致一些问题。

this对象在运行是基于函数的执行环境绑定,全局函数中,this等于window,当函数被作为某个对象的方法调用时,this等于那个对象。匿名函数的执行环境具有全局性,因此其this对象通常指向window。

var name = "The Window";

var object = {
    name: "my object",
    getName: function() {
        return function() {
            return this.name;
        };
    }
};

alert(object.getName()());    //"this Window"

在这里例子里,第一眼看上去,似乎this会指向object。但this是在匿名函数中定义,且调用时被返回到全局作用域,所以this.name表示的值是全局变量中的,而不是对象中的name。

var name = "The Window";

var object = {
    name: "my object",
    var that = this;
    getName: function() {
        return function() {
            return that.name;
        };
    }
};

alert(object.getName()());    //"my object"

稍作改动,结果就不一样了。在匿名函数外声明声明了一个变量that用来保存this的值,这里this显然指向的是对象(object),用这种方法就可以达到在匿名函数内访问对象属性的目的了!

闭包与内存管理

一直以来人们对闭包有着诸多的误解,其中一种是闭包会造成内存泄漏,所以要尽量减少闭包的使用。其实,这是因为IE9之前的版本对JScript对象和COM对象(IE中的BOM和DOM对象就是使用C++以COM对象的方式实现的)使用不同的垃圾收集机制,因此闭包在IE这些版本里会导致一些特殊的问题。具体来说,如果闭包的作用域链中保存着一个HTML元素,这意味着该元素无法被销毁。所以,在这些版本中,要注意对无法回收的元素设置为null,使用手动来帮助销毁变量。

总结

闭包其实就是一种通过词法作用域来达到访问其他函数作用域目的的方法,最常用的形式是在函数内部嵌套一个函数,在函数内部返回一个函数的时候,如果那个函数还保持着对外部函数变量的引用,那么该值就不会随着外部函数销毁,直到内部函数执行完失去引用为止。使用闭包要有其注意在循环中导致的副作用