本文深入解析JavaScript闭包的核心机制,涵盖作用域链、变量生命周期及内存管理原理。通过内部函数逃逸、回调函数嵌套及块级作用域模拟等实例,详解闭包在保护变量安全、实现私有变量和特权方法中的实际应用,并探讨其可能导致的内存泄漏风险及规避策略。
JavaScript基础概念回顾
一、充电
在JavaScript中,一切引用类型本质上都是对象,对象即属性的集合。
函数也是一种对象,但其与数组等普通对象的关系并非简单的子集包含。函数与对象之间的关系更为复杂,甚至呈现出某种互为因果的逻辑特征。
function Fn() {this.name = '王福朋';this.year = 1988;}
var fn1 = new Fn();
var obj = { a: 10, b: 20 };等价于var obj = new Object();
obj.a = 10;obj.b = 20;
var arr = [5, 'x', true];等价于var arr = new Array();
arr[0] = 5;arr[1] = 'x';arr[2] = true;
对象由函数创建,而函数本身又具备对象的特性。
每个函数都拥有一个名为prototype的属性,该属性值是一个对象(即属性的集合,此处再次强调)。默认情况下,该对象仅包含一个名为constructor的属性,指向函数本身。
每个对象都拥有一个__proto__属性,指向创建该对象的函数的prototype。这是一个隐藏属性,JavaScript官方并不建议开发者直接访问,部分低版本浏览器甚至不支持此属性。其关系表现为:obj.__proto__ === Object.prototype。
闭包的核心机制与原理
二、闭包
1、闭包的基本概念
闭包是指有权访问另一个函数作用域中变量的函数。通俗理解,即“定义在另一个函数内部的函数”。例如:
function createComparisonFunction(propertyName){
return function(object1,object2){//匿名函数
value1=object1[propertyName];
value2=object2[propertyName];
if(value1<value2){
return -1;
}else if(value1>value2){
return 1;
}else{
return 0;
}
}
}
//创建函数
var compareNames=createComparisonFunction("name");
//调用函数
var result=compareNames({name:"Nicolas"},{name:"Greg"});
//解除对匿名函数的引用(以便释放内存)
compareNames=null;
alert(result);//1
(1). 优势:能够保护函数内部变量的安全性,增强了封装性;能够在内存中维持一个变量(起到缓存作用);常用于匿名自执行函数;可用于模拟面向对象编程。
(2). 应用场景:使用闭包替代全局变量;在函数外部或其他函数中访问特定函数内部的参数;封装相关功能模块;为节点循环绑定click事件时,在事件处理函数中使用当次循环的值或节点,而非最后一次循环的值或节点。
(3). 劣势:由于常驻内存,会增加内存使用量,使用不当极易造成内存泄漏。更严重的是,不当使用闭包可能导致无效内存的产生。
只要存在调用内部函数的可能性,JavaScript引擎就必须保留被引用的函数。JavaScript运行时需要跟踪引用该内部函数的所有变量,直到最后一个变量被废弃,垃圾回收器才能释放相应的内存空间。父函数定义的变量存在于子函数的作用域链中,若子函数未被销毁,其作用域链中的所有变量和函数也将被维护而不会被销毁。
闭包的实际应用场景
2、闭包的用途
闭包主要有两个用途:一是方便实现嵌套的回调函数,二是隐藏对象内部细节。
前者在NodeJS的编程风格中已得到充分体现;后者指函数内部的局部变量对外不可见,但可通过提供访问函数来读取和修改相应的局部变量,从而实现面向对象封装的意图。
(1)、简单的例子
首先从一个经典错误案例谈起。页面上有若干个div,我们希望给它们绑定一个onclick方法,于是写出了以下代码:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<script language="javascript" src="jquery.js"></script>
<script>
$(document).ready(function() {
var spans = $("#divTest span");
for (var i = 0; i < spans.length; i++) {
spans[i].onclick = function() {
alert(i);
}
}
});
</script>
<style>
#divTest{
margin-top:30px;
margin-bottom:40px;
}
span{
border-color:#3C0;
border-style:solid;
margin-bottom:5px;
padding:10px 30px 10px 30px;
border-radius:10px
}
</style>
<title>无标题文档</title>
</head>
<body>
<div id="divTest">
<span>0</span> <span>1</span> <span>2</span> <span>3</span>
</div>
</body>
</html>
功能看似简单,却出现了错误,每次alert弹出的值都是4。只需稍作修改即可解决。
$(document).ready(function() {
var spans = $("#divTest span");
for (var i = 0; i < spans.length; i++) {
(function(num){//匿名函数表达式
spans[i].onclick = function() {
alert(num);
}
})(i);//立即执行,并且把i的值传给num
}
});
上述代码在页面加载后执行。当i的值为4时,判断条件不成立,for循环结束。但由于每个span的onclick方法作为内部函数,i被闭包引用,内存无法释放,i的值保持为4,直到程序修改它或所有onclick函数销毁(主动赋值为null或页面卸载)时才会被回收。因此,每次点击span时,onclick函数会沿作用域链查找i的值(引用方式),结果为4,从而alert出4。第二种方式通过立即执行函数又创建了一层闭包,将函数声明置于括号内变为表达式,随后加括号调用,并将i作为参数传入,函数立即执行,num保存了每次i的值。
(2)、内部函数
让我们从基础知识谈起,首先了解内部函数。内部函数即定义在另一个函数中的函数。例如:
function outerFn () {
function innerFn () {}
}
innerFn是一个被包裹在outerFn作用域中的内部函数。这意味着,在outerFn内部调用innerFn是有效的,而在outerFn外部调用innerFn则是无效的。以下代码会导致JavaScript错误:
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
document.write("Inner function<br/>");
}
}
innerFn();
不过在outerFn内部调用innerFn,则可以成功运行:
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
document.write("Inner function<br/>");
}
innerFn();
}
outerFn();
(3)、伟大的逃脱
JavaScript允许开发人员像传递任何类型数据一样传递函数,也就是说,JavaScript中的内部函数能够“逃脱”定义它们的外部函数。
逃脱方式有多种,例如可以将内部函数指定给一个全局变量:
var globalVar;
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
document.write("Inner function<br/>");
}
globalVar = innerFn;
}
outerFn();
globalVar();
调用outerFn时会修改全局变量globalVar,此时其引用变为innerFn,此后调用globalVar与调用innerFn效果相同。这时在outerFn外部直接调用innerFn仍然会导致错误,这是因为内部函数虽然通过保存引用到全局变量实现了“逃脱”,但该函数的名字依然只存在于outerFn的作用域中。 也可以通过在父函数的返回值中获得内部函数引用:
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
document.write("Inner function<br/>");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
这里并未在outerFn内部修改全局变量,而是从outerFn中返回了一个对innerFn的引用。通过调用outerFn可以获得这个引用,且该引用可以保存在变量中。
这种即使离开函数作用域的情况下仍然能够通过引用调用内部函数的事实,意味着只要存在调用内部函数的可能,JavaScript就需要保留被引用的函数。而且JavaScript运行时需要跟踪引用这个内部函数的所有变量,直到最后一个变量废弃,JavaScript的垃圾收集器才能释放相应的内存空间。
说了这么多终于与闭包有关了,闭包是指有权限访问另一个函数作用域的变量的函数,创建闭包的常见方式就是在一个函数内部创建另一个函数,就是我们上面说的内部函数,所以刚才说的不是废话,也是闭包相关的。
(4)、变量的作用域
内部函数也可以有自己的变量,这些变量都被限制在内部函数的作用域中:
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
var innerVar = 0;
innerVar++;
document.write("Inner function\t");
document.write("innerVar = "+innerVar+"<br/>");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();
每当通过引用或其它方式调用这个内部函数时,就会创建一个新的innerVar变量,然后加1,最后显示
Outer function
Inner function innerVar = 1
Inner function innerVar = 1
Outer function
Inner function innerVar = 1
Inner function innerVar = 1
内部函数也可以像其他函数一样引用全局变量:
var globalVar = 0;
function outerFn() {
document.write("Outer function<br/>");
function innerFn() {
globalVar++;
document.write("Inner function\t");
document.write("globalVar = " + globalVar + "<br/>");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();
现在每次调用内部函数都会持续地递增这个全局变量的值:
Outer function
Inner function globalVar = 1
Inner function globalVar = 2
Outer function
Inner function globalVar = 3
Inner function globalVar = 4
但是如果这个变量是父函数的局部变量又会怎样呢?因为内部函数会引用到父函数的作用域(有兴趣可以了解一下作用域链和活动对象的知识),内部函数也可以引用到这些变量
function outerFn() {
var outerVar = 0;
document.write("Outer function<br/>");
function innerFn() {
outerVar++;
document.write("Inner function\t");
document.write("outerVar = " + outerVar + "<br/>");
}
return innerFn;
}
var fnRef = outerFn();
fnRef();
fnRef();
var fnRef2 = outerFn();
fnRef2();
fnRef2();
这一次结果非常有意思,也许或出乎我们的意料
Outer function
Inner function outerVar = 1
Inner function outerVar = 2
Outer function
Inner function outerVar = 1
Inner function outerVar = 2
我们看到的是前面两种情况合成的效果,通过每个引用调用innerFn都会独立的递增outerVar。也就是说第二次调用outerFn没有继续沿用outerVar的值,而是在第二次函数调用的作用域创建并绑定了一个一个新的outerVar实例,两个计数器完全无关。
当内部函数在定义它的作用域的外部被引用时,就创建了该内部函数的一个闭包。这种情况下我们称既不是内部函数局部变量,也不是其参数的变量为自由变量,称外部函数的调用环境为封闭闭包的环境。从本质上讲,如果内部函数引用了位于外部函数中的变量,相当于授权该变量能够被延迟使用。因此,当外部函数调用完成后,这些变量的内存不会被释放(最后的值会保存),闭包仍然需要使用它们。
(5)、闭包之间的交互
当存在多个内部函数时,很可能出现意料之外的闭包。我们定义一个递增函数,这个函数的增量为2
function outerFn() {
var outerVar = 0;
document.write("Outer function<br/>");
function innerFn1() {
outerVar++;
document.write("Inner function 1\t");
document.write("outerVar = " + outerVar + "<br/>");
}
function innerFn2() {
outerVar += 2;
document.write("Inner function 2\t");
document.write("outerVar = " + outerVar + "<br/>");
}
return { "fn1": innerFn1, "fn2": innerFn2 };
}
var fnRef = outerFn();
fnRef.fn1();
fnRef.fn2();
fnRef.fn1();
var fnRef2 = outerFn();
fnRef2.fn1();
fnRef2.fn2();
fnRef2.fn1();
我们映射返回两个内部函数的引用,可以通过返回的引用调用任一个内部函数,结果:
Outer function
Inner function 1 outerVar = 1
Inner function 2 outerVar = 3
Inner function 1 outerVar = 4
Outer function
Inner function 1 outerVar = 1
Inner function 2 outerVar = 3
Inner function 1 outerVar = 4
innerFn1和innerFn2引用了同一个局部变量,因此他们共享一个封闭环境。当innerFn1为outerVar递增一时,久违innerFn2设置了outerVar的新的起点值,反之亦然。我们也看到对outerFn的后续调用还会创建这些闭包的新实例,同时也会创建新的封闭环境,本质上是创建了一个新对象,自由变量就是这个对象的实例变量,而闭包就是这个对象的实例方法,而且这些变量也是私有的,因为不能在封装它们的作用域外部直接引用这些变量,从而确保了了面向对象数据的专有性。
模拟块级作用域与私有变量
3、模仿块级作用域
JavaScript是函数作用域(function scope),没有块级作用域。无论函数体内的变量在什么地方声明,对整个函数都是可见的,即JavaScript函数里声明的所有变量都被提前到函数体的顶部,只是提前变量声明,变量的赋值还是保留在原位置。
匿名函数可以用来模仿块级作用域避免变量提前声明为全局变量,用作块级作用域(通常称为私有作用域)的匿名函数的语法如下:
(function(){
//这里是块级作用域
})();
例如:
function output(count){
(function(){
for(var i=0;i<count;i++){
alert(i);
}
})();
alert(i);
}
output(4);
在匿名函数中定义的任何变量,都会在执行结束时被销毁。因此,变量i只能在循环中使用,使用后即被销毁。
4、私有变量
我们把有权访问私有变量和私有函数的公有方法称为特权方法。
(1)、构造函数中定义特权方法
function Person(name){
//name为私有变量
this.getName=function(){//特权方法
return name;
};
this.setName=function(value){//特权方法
name=value
};
}
var person=new Person("zoumiao");
alert(person.getName());
person.setName("zouzou");
alert(person.getName());
var person1=new Person("zou");
alert(person1.getName());//"zou"
alert(person.getName());//"zouzou"
在构造函数中定义特权方法也有一个缺点,那就是你必须使用构造函数模式来达到这个目的,构造函数模式的缺点就是针对每一个实例都会创建同样一组新方法,使用静态私有变量来实现特权方法就可以避免这个问题。
(2)、静态私有变量
通过在私有作用域中定义私有变量或函数,同样也可以创建特权方法,其基本模式如下:
(function(){
var name='';//静态的、由所有实例共享的属性
Person=function(value){//全局构造函数
name=value;
}
Person.prototype.getName=function(){//公有/特权方法
return name;
}
Person.prototype.setName=function(value){//公有/特权方法
name=value;
}
})();
var person=new Person("zoumiao");
alert(person.getName());
person.setName("zouzou");
alert(person.getName());
var person1=new Person("zou");
alert(person1.getName());//"zou"
alert(person.getName());//"zou"
这个模式与构造函数中定义特权方法的区别,就在于私有变量和函数是由实例共享的。