JavaScript 闭包

本文来自《精通JavaScript》一书
在本书中,我们看到过很多以函数作为参数的jQuery方法。在我们所举的例子中,也曾经反复的创建、调用和传递函数。虽然我们平时只需粗略地了解JavaScript的内部工作机制,就可以这样使用函数,但是,如果缺乏对这个语言特性深入理解,那么这些操作的副作用也会时不时给我们带来意想不到的结果。在本附录中,我们再额外探讨一种深奥(也很流行)的函数类型,这就是闭包。

1 内部函数

能够跻身支持内部函数声明的编程语言行列,对JavaScript来说应该算是一种幸运。许多传统的编程语言(例如C),都会把全部函数集中在顶级作用域中。而支持内部函数的语言,则允许开发者在必要的地方集合小型实用函数,以避免对命名空间的干扰。

所谓内部函数,就是定义在另一个函数中的函数。例如:

function outerFun() {
    function innerFun() {
        alert("hello");
    }
}

innerFun()就是一个被包含在outFun()作用域的内部函数。这意味着,在outerFun()内部调用innerFun()是有效的,而在outerFun()调用innerFun()则是无效的。下列代码会导致一个JavaScript错误。

function outerFun() {
    function innerFun() {
        alert("hello");
    }
}
innerFun();

不过,为了出发警告框的显示,我们可可以在outFun()内部调用innerFun()

function outerFun() {
    function innerFun() {
        alert("hello");
    }
    innerFun();
}
outerFun();

这种技术特别适合于小型、单用途的函数。例如:递归但却带有非递归API包装的算法通常最适合通过内部函数来表达。

2 伟大的逃脱

在函数应用参与进来之后,问题就变得复杂了。有些语言中,比如Pascal,允许通过内部函数实现代码隐藏,而这些函数因此也会永远被埋没在他们的父函数中。然而,JavaScript则允许开发者像传递任何类型的数据一样传递函数。也就是说,JavaScript总的内部函数能够逃脱定义它们的外部函数。
逃脱的方式有很多种。例如,可以将内部函数指定给一个全局变量:

var globVar;
function outerFun() {
    function innerFun() {
        alert("hello");
    }
    globVar = innerFun();
}
outerFun();
globVar();

在函数定义之后调用outerFun()会修改全局变量globVar,此时它引用的是innerFun()。这意味着,后面调用golbVar的操作就如同调用innerFun()一样,也会导致显示警告框。但此时在outerFun()外部调用innerFun()仍然会导致错误!这是因为虽然内部函数通过把引用保存在全局变量中实现了逃脱,但这个函数的名字仍然被截留在outerFun()作用域中。
另外,也可以通过在父函数中返回值来营救出内部函数的引用:

function outerFun() {
    function innerFun() {
        alert("hello");
    }
    return innerFun;
}
var globVar = outerFun();
globVar();

这里并没有在outerFun()内部修改全局变量,而是从outerFun()返回一个对innerFun()的引用。通过调用outerFun能够取得这个引用,而且,这个引用可以保存在变量中,也可以自己调用自己,从而触发警告框。
这种即使在离开函数作用域的情况下,仍然能够通过引用调用内部函数的事实,意味着只要存在这些内部函数的可能,JavaScript就需要保存被引用的函数。而且,JavaScript运行时程序要跟踪这个内部函数的所有变量,直至最后一个变量废弃,JavaScript的垃圾收集齐才能释放相应的内存空间。

3 变量作用域

内部函数当然也可以拥有自己的变量,只不过这些变量都被限制在内部函数的作用域中:

function outerFun() {
    function innerFun() {
        var innerVar = 0;
        innerVar++;
        alert(innerVar);
    }
    return innerFun;
}

每当通过引用或其他方式调用这个内部函数时,都回创建一个新的innerVar变量,然后递增,最后显示:

var globVar = outerFun();
globVar();  //警告显示:1
globVar();  //警告显示:1
var innerVar2 = outerFun();
innerVar2(); //警告显示:1
innerVar2(); //警告显示: 1

内部函数可以向其他函数一样引用全局变量:

var globVar = 0;
function outerFun() {
    function innerFun() {
        golbVar++;
        alert(golbVar);
    }
    return innerFun;
}

现在,每次调用内部函数都回持续地递增这个全局变量的值:

var globVar = outerFun();
globVar(); //警告框显示:1
globVar();  //警告框显示: 2
var globVar2 = outerFun();
globVar2();  //警告显示:3
globVar2();  //警告显示:4

但是,如果这个变量是父函数的局部变量又会怎么样呢?因为内部函数会继承父函数的作用域,所以内部函数也可以引用这个变量:

function outerFun() {
    var outerVar = 0;
    function innerFun() {
        outerVar++;
        alert(outerVar);
    }
    return innerFun;
}

这一次,对内部函数的调用会产生有意思的行为:

var globVar = outerFun();
globVar(); //警告框显示:1
globVar();  //警告框显示: 2
var globVar2 = outerFun();
globVar2();  //警告显示:1
globVar2();  //警告显示:2

我们看到了前面两种情况合成的效果。通过每个引用调用innerFun()都回独立递增innerVar。也就是说,第2次调用outerFun()没有继续沿用innerVar的值,而是在第2次函数调用的作用域中创建了一个新的innerVar的实例。结果,就造成了在上面的调用之后继续调用globVar会导致警告框显示3,而再次调用globVar2()也会导致警告框中显示3。这两个计数器是完全无关的。
当内部函数是在定义它的作用域外部被引用时,就创建了该内部函数的一个闭包。在这种情况下,我们称不是内部函数既不变量的变量为自由变量,称外部函数的调用环境为封闭闭包环境。从本质上讲,如果内部函数引用了位于外部函数重的变量,相当于授权该变量能够被延迟使用。因此当外部函数调用完成之后,这些变量的内存不会释放,因为闭包仍然需要使用它们。

4 闭包之间的交互

当存在多个内部函数时,很可能出现意料之外的闭包。假设我们又定义了一个递增函数,这个函数重的增量为2:

function outerFun() {
    var outerVar = 0;
    function innerFun() {
        outerVar++;
        alert(outerVar);
    }
    function innerFun2() {
        outerVar = outerVar + 2;
        alert(outerVar);
    }
    return {'innerFun' : innerFun, 'innerFun2' : innerFun2};
}

这里我们通过映射返回俩个内部函数的引用(这也是犯了内部函数的引用逃脱父函数的另外一种方式)。可以通过返回的引用调用任何一个内部函数:

var globVar = outerFun();
globVar.innerFun(); //警告框显示:1
globVar.innerFun2();  //警告框显示: 3
globVar.innerFun2();  //警告显示:4
var globVar2 = outerFun();
globVar2.innerFun();  //警告显示:1
globVar2.innerFun2();  //警告显示:3
globVar2.innerFun();  //警告显示:4

这两个内部函数引用了同一个局部变量,因此它们共享同一个封闭环境。当innerFun()outerVar递增1时,就为调用innerFun2()设置了outerVar的新的起点值。同样,我们也看到对outerFun()的后续调用还会创建这些闭包的新的实例,同时也会创建相应的新封闭环境。面向对象编程的爱好者们会注意到,这本质上是创建一个新对象,自由变量就是这个对象的实例变量,而闭包就是这个对象实例的实例方法。而且,这些变量也是私有的,因为不能封装在它们的作用域外部直接引用这些变量,从而且包了面向对象数据专有特性。

5 jQuery中的闭包

我们曾经介绍过的jQuery库中许多方法都至少要接受一个函数作为参数。为了方便起见,我们通常都在这种情况下使用匿名函数,以便在必需时再定义函数的行为。当属,这也意味着我们很少在顶级命名空间中定义函数;也就是说,这些函数都是内部函数,而内部函数很容易就会变成闭包。

5.1 $(document).ready()的参数

我们使用jQuery编写的几乎全部代码都要放在作为$(document).ready()参数的一个函数内部。这样做是为了保证在代码运行之前DOM已经就绪,而DOM就绪通常是运行jQuery代码的一个必要条件。当创建了一个函数并把它传递给.ready()之后,这个函数的引用就会被保存为jQuery对象的一部分。在稍后的某个时间——当DOM就绪时,这个引用就会被调用。
由于我们通常把$(document).ready()放在代码结构的顶层,因而这个函数不会成为闭包。但是,我们的代码通常是在这个函数的内部编写的,所以这些代码都处于一个内部函数中:

$(document).ready(function() {
    var readyVar = 0;
    function outerFun() {
        function innerFun() {
            readyVar++;
            alert(readyVar);
        }
        return innerFun;
    }
    var readyVar2 = outerFun();
    readyVar2();
});

这看上去同前面的全局变量的例子有些类似,只不过它们同其他代码一样都被封装在对$(document).ready()的一次调用中,这意味着readyVar不是全局变量,而是匿名函数的局部变量。但是,变量readyVar2这取得了一个对闭包的引用,而闭包的环境封闭着readyVar
把大多数jQuery代码都放在一个函数体中很有用,因为这样可以避免某些命名空间的冲突。例如:正是这个特性可以使我们通过调用jQuery.noConflict()为其他库释放简写方式$,但我们仍然能够定义在$(document).ready()中使用的局部简写方式。

5.2 事件处理程序

$(document).ready()结构通常用于包装其他jQuery代码,包括事件处理程序的赋值。因为处理程序是函数,他们也就变成了内部函数;为妾,因为这些内部函数会被保存并在以后调用,于是它们也变成了闭包。以一个简单的单击处理程序为例:

$(document).ready(function() {
    var readyVar = 0;
    $('.trigger').click(function () {
        readtVar++;
        alert(readyVar);
    });
});

由于变量readyVar是在.ready()处理程序中声明的,所以它只对位于这个快中的jQuery代码有效,对.ready()处理程序外部的代码的无效。然而,这个变量可以被.click()处理程序中的代码引用,在这个例子中.click()应用程序会递增并通过警告框显示该变量。由于创建了闭包,每次单击按钮都回引用readyVar的同一个实例。也就是说,警告框会显示持续递增的一组值,而不是每次都显示1。
事件处理程序同其他函数一样,也能够共享它们的封闭环境:

$(document).ready(function() {
    var readyVar = 0;
    $('.trigger').click(function () {
        readtVar++;
        alert(readyVar);
    });
    $('.subtract').click(function () {
        readyVar--;
        alert(readyVar);
    });
});

因为这两个函数应用的是同一个变量,所以俩个按钮的递增和递减操作会影响同一个变值,而不是各自独立的值。
这些例子都和我们常规的jQuery代码一样使用了匿名函数。但是,这不会影响到闭包的构建。例如,我们可以编写一个匿名函数,报告jQuery对象中每个项的索引:

$(document).ready(function() {
    $('li').each(function (index) {
        $(this).click(function () {
            alert(index);
        });
    });
});

由于最里面的函数是在.each()回调函数中定义的,因而以上代码实际上创建了同存在的列表项一样多的函数。这些函数分别作为一个单击处理程序被添加给了相应的列表项。而且,由于.each()回调函数拥有参数index,所以在这些函数的封闭环境中各有各的index变量。这就如同把单击处理程序的代码写成一个命名的函数:

$(document).ready(function() {
    $('li').each(function (index) {
        function clickHanlder() {
            alert(index);
        }
        $(this).click(clickHanlder);
    });
});

只不过使用匿名函数的版本更短一点。然而,这个命名函数的位置也是很重要的:

$(document).ready(function() {
    function clickHandler () {
        alert(index);
    }
    $('li').each(function (index) {
        $(this).click(clickHandler);
    });
});

这个版本会带导致无论单机哪个列表项都回被触发一个JavaScript错误,因为在clickHandler()的封闭环境中找不到index。此时index仍然是一个自由变量,但它在这个环境中没有定义。

6 内存泄露的危险

JavaScript使用一种称为垃圾收集的技术来管理分配给它的内存。这与C这样的低级语言不同,同样C要求程序员明确预定内存空间,并在这些内存不再使用时释放它们。其他语言,比如Objective-C,思想了一个引用计数器来辅助程序员完成这些工作。通过这个引用计数器,程序员能够了解到到有多少个程序块使用了一个特定的内存段,因而可以在不需要时清理这些内存段。另一方面,JavaScript是一种高级语言,它一般是通过后台来维护这种计数系统。
当JavaScript代码生成一个新内存驻留项时(比如一个对象或函数),系统就会为这个项留出一个内存空间。因为这个对象可能会被传递很多函数,并且会被指定给很多变量,所以很多代码都会指向这个对象的内存空间。JavaScript会跟踪这些指针,当最后一个指针废弃不用时,这个对象占用的内存会被释放。以下面的指针链接为例:
闭包1
图中的对象A有一个属性指向B,而B也有一个属性指向C。即使当前作用域中只有对象A有效,但由于指针的关系所3个对象必须都保留在内存中。当离开A的当前作用域时(例如代码执行到声明A的函数的末尾处),垃圾收集器就可以释放A占用的内存。此时,由于没什么指向B,因此B可以释放,最后C也可以释放。
然而,当对象间的引用关系变得复杂时,处理起来也会更加困难:
闭包2
这里,我们又为对象C添加了一个引用B的属性,在这种情况下,当A释放时,仍然有来自C的指针指向B。这种应用循环需要由JavaScript进行特殊处理,但必须考虑到整个循环与作用域中的其他变量已经处于隔离状态。

6.1 意外的引用循环

闭包可能会导致在不经意间创建引用循环。因为函数是必须保存在内存中的对象,所以位于函数封闭环境中的所有变量也需要保存在内存中:

function outerFun() {
    var outerVar = {};
    function innerFun() {
        alert(outerVar);
    }
    outerVar.innerFun = innerFun;
    return innerFun;
}

这里创建了一个名为outerVar的对象,该队现在内部函数innerFun()中被引用。然后,为outerVar创建了一个指向innerFun()的属性,之后返回了innerFun()。这样就在innerFun()上创建了一个引用outerVar的闭包,而outerVar又引用了innerFun()。但是,也可能会出现比这种情况更隐蔽的引用循环:

function outerFun() {
    var outerVar = {};
    function innerFun() {
        alert('hello');
    }
    outerVar.innerFun = innerFun;
    return innerFun;
}

这里我们修改了innerFun(),使他不再引用outerVar。但是,这样仍然没有断开循环。即使innerFun()不再引用outerVarouterVar也仍然位于innerFun()的封闭环境中。由于闭包的原因,位于outerFun()中德所有变量都隐含地被innerFun()所引用。因此,闭包惠是意外地创建这些引用循环变得易如反掌。

6.2 IE中德内存泄露问题

上述这种情况通常那个不是什么问题,因为JavaScript能够检测到这些情况并且在它们孤立时将其清除。然而,IE中存在一种难以处理的引用循环问题。当一个循环中同时包含DOM元素和常规JavaScript对象时,IE无法释放任何一个对象——因为这两类对象是由不同的内存管理程序负责管理的。换句话说,除非关闭浏览器,否则这种循环在IE中永远得不到释放。为此,随着时间的推移,这可能会导致大内存被无效地占用。导致这种循环的一个常见原因是简单的事件处理程序:

$(document).ready(function () {
    var div = document.getElementById('foo');
    div.onclick = function () {
        alert('hello');
    }
});

当指定单击事件处理程序时,就创建了一个在其封闭环境中包含div变量的闭包。而且,现在的div也包含一个指向闭包的引用。这样,就导致了在IE中即使离开当前页面也不会释放这个循环。

6.3 好消息

下面我们通过常规的的jQuery结构来编写同样的代码:

$(document).ready(function () {
    var $div= $('foo');
    $div.click(function () {
        alert('hello');
    });
});

即使此时仍然会创建一个闭包,并且也会同样导致前面一样的循环,但这里的代码却不会使IE发生内存泄露。由于jQuery考虑到了内存泄露的潜在危害,所以它会手动释放自己制定的所有事件处理程序。只要坚持使用jQuery的时间绑定方法,就无需这种特定的常见原因导致的内存泄露而担心。
但是这并不意味着我们完全脱离了险境。当对DOM元素进行其他操作时,仍然要处处留心。只要是将JavaScript对象指定给DOM元素,就可能在IE中导致内存泄露。jQuery只是有助于减少这种情况的可能性。

7 结束语

JavaScript闭包是一种强大的语言。通过使用这个语言来隐藏变量,可以避免覆盖其他地方使用的同名变量。由于jQuery经常依赖与把函数作为方法的参数,所以在编写jQuery代码时也会经常在不经意间创建闭包。理解闭包有助于编写更有效也更简洁的代码,如果在加上一些小心并且利用好jQuery内置的安全措施,则可以有效地防止闭包可能引发的内存泄露问题。

发表评论

电子邮件地址不会被公开。 必填项已用*标注