JavaScript闭包、作用域链与内存管理深度解析

作者:青柠时间:2026-10-30 06:35:07浏览:638

本文深入解析JavaScript核心概念:闭包的定义、特性及其在封装私有变量中的应用。详细阐述作用域链的构建与查找机制,对比标记清除与引用计数两种垃圾回收策略。重点分析由循环引用导致的内存泄露问题,并提供解除引用的具体解决方案,帮助开发者优化代码性能。

补充:

JavaScript闭包核心概念

闭包(closure)是JavaScript语言中极具挑战性也极具特色的概念,许多高级编程技巧都依赖于它。

闭包的核心特性

闭包具备以下三个主要特征:

1. 函数内部嵌套定义另一个函数。

2. 内部函数可以访问外部函数的参数和局部变量。

3. 这些被引用的参数和变量不会被垃圾回收机制立即回收。

闭包的定义、优缺点及应用场景

闭包是指有权访问另一个函数作用域中变量的函数。最常见的创建方式是在一个函数内部定义另一个函数,并通过内部函数访问外部函数的局部变量。

闭包的主要缺点在于它会常驻内存,从而增加内存使用量。如果使用不当,极易引发内存泄露问题。

闭包是JavaScript的一大特色,其主要应用场景包括设计私有的方法和变量。

通常情况下,函数执行完毕后,其局部活动对象会被销毁,内存中仅保留全局作用域。然而,闭包的情况有所不同。

围绕主题展开讨论

1. 什么是闭包?

参考以下几种关于闭包的定义:

1. 闭包是指有权访问另一个函数作用域中变量的函数。

2. 函数对象通过作用域链相互关联,函数体内部的变量可以保存在函数作用域内,这种特性被称为‘闭包’。

3. 内部函数可以访问定义它们的外部函数的参数和变量(不包括this和arguments)。

如需系统学习JS闭包概念,可参考本站的JS电子书栏目。

定义总结

1. 能够访问外部函数作用域中变量的函数。

2. 被内部函数访问的外部函数变量可以保存在外部函数作用域内而不被回收——这是核心要点,后续遇到闭包时,需重点关注被闭包引用的变量。

创建一个简单的闭包示例


var sayName = function(){
var name = 'jozo';
return function(){
alert(name);
}
};
var say = sayName(); 
say();

解读以下两个语句:

• var say = sayName():返回一个匿名内部函数并赋值给变量say,该函数引用了外部函数的变量name。由于垃圾回收机制的特殊处理,sayName函数执行完毕后,变量name并未被销毁。

• say():执行返回的内部函数,依然可以访问变量name,输出 'jozo'。

闭包中的作用域链机制

2. 闭包中的作用域链

理解作用域链有助于深入理解闭包。

变量在作用域中的查找方式通常涉及顺着作用域链向上查找。

当函数被调用时:

1. 首先创建一个执行环境(execution context)及相应的作用域链;

2. 将arguments和其他命名参数的值添加到函数的活动对象(activation object)。

作用域链:当前函数的活动对象优先级最高,外部函数的活动对象次之,外部函数的外部函数的活动对象依次递减,直至作用域链的末端——全局作用域。优先级决定了变量查找的先后顺序;

先来看一个普通的作用域链示例:


function sayName(name){
return name;
}
var say = sayName('jozo'); 

这段代码包含两个作用域:a. 全局作用域;b. sayName函数的作用域,即只有两个变量对象。当执行到对应的执行环境时,该变量对象会成为活动对象,并被推入到执行环境作用域链的前端,成为优先级最高的那个。 结合图示理解:

此图源自《JS高级程序设计》,此处重新绘制。

在创建sayName()函数时,会创建一个预先包含变量对象的作用域链,即图中索引为1的作用域链,并保存到内部的[[Scope]]属性中。当调用sayName()函数时,会创建一个执行环境,并通过复制函数的[[Scope]]属性中的对象构建起作用域链。此后,又有一个活动对象(图中索引为0)被创建,并被推入执行环境作用域链的前端。

一般来说,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域。但是,闭包的情况又有所不同:

再来看看闭包的作用域链:


function sayName(name){
return function(){
return name;
}
}
var say = sayName('jozo'); 

这个闭包实例比上一个例子多了一个匿名函数的作用域:

在匿名函数从sayName()函数中被返回后,它的作用域链被初始化为包含sayName()函数的活动对象和全局变量对象。这样,匿名函数就可以访问在sayName()中定义的所有变量和参数。更为重要的是,sayName()函数在执行完毕后,其活动对象也不会被销毁,因为匿名函数的作用域链依然在引用这个活动对象。换句话说,sayName()函数执行完后,其执行环境的作用域链会被销毁,但其活动对象会留在内存中,直到匿名函数被销毁。这也是后面要讲到的内存泄露的问题。

关于作用域链的内容不再赘述,编写书籍内容确实不易。

闭包的实际应用案例

3. 闭包的实例

实例1:实现累加功能


// 方式1
var a = 0;
var add = function(){
a++;
console.log(a)
}
add();
add();
//方式2 :闭包
var add = (function(){
var a = 0;
return function(){
a++;
console.log(a);
}
})();
console.log(a); //undefined
add();
add();

相比之下,方式2更加优雅,减少了全局变量,并将变量私有化。

实例2:给每个li元素添加点击事件


var oli = document.getElementsByTagName('li');
var i;
for(i = 0;i < 5;i++){
oli[i].onclick = function(){
alert(i);
}
}
console.log(i); // 5
//执行匿名函数
(function(){
alert(i); //5
}());

上面是一个经典案例。我们知道执行结果都会弹出5,也知道可以用闭包解决,但初学者往往难以理解为何每次弹出都是5,以及闭包如何解决此问题。经过梳理,逻辑如下:

a. 先分析未使用闭包的情况:在for循环中,我们给每个li点击事件绑定了一个匿名函数,匿名函数中返回了变量i的值。当循环结束后,变量i的值变为5。此时再去点击每个li,即执行相应的匿名函数(见上方代码),此时变量i已经是5,所以每个点击弹出5。因为这里返回的每个匿名函数都引用了同一个变量i。如果我们新建一个变量保存循环执行时当前的i的值,然后再让匿名函数应用这个变量,最后再返回这个匿名函数,就可以达到目的,这就是运用闭包来实现的!

b. 再来分析运用闭包时的情况:


var oli = document.getElementsByTagName('li');
var i;
for(i = 0;i < 5;i++){
oli[i].onclick = (function(num){
var a = num; // 为了说明问题
return function(){
alert(a);
}
})(i)
}
console.log(i); // 5

这里for循环执行时,给点击事件绑定的匿名函数传递i后立即执行并返回一个内部的匿名函数。因为参数是按值传递的,所以此时形参num保存的就是当前i的值,然后赋值给局部变量a。然后这个内部的匿名函数一直保存着a的引用,也就是一直保存着当前i的值。所以循环执行完毕后点击每个li,返回的匿名函数执行时弹出各自保存的a的引用的值。

闭包的高级运用场景

4. 闭包的运用

我们来看看闭包的用途。事实上,通过使用闭包,我们可以做很多事情。比如模拟面向对象的代码风格;更优雅、更简洁地表达出代码;在某些方面提升代码的执行效率。

1. 匿名自执行函数

在实际开发中,常遇到一种情况:某些函数只需执行一次,其内部变量无需长期维护,例如UI初始化。此时我们可以使用闭包:


//将全部li字体变为红色
(function(){ 
var els = document.getElementsByTagName('li');
for(var i = 0,lng = els.length;i < lng;i++){
els[i].style.color = 'red';
} 
})();

我们创建了一个匿名的函数,并立即执行它。由于外部无法引用它内部的变量,因此els、i、lng这些局部变量在执行完后很快就会被释放,从而节省内存!

关键是这种机制不会污染全局对象。

2. 实现封装/模块化代码


var person= function(){ 
//变量作用域为函数内部,外部无法访问 
var name = "default"; 
return { 
getName : function(){ 
return name; 
}, 
setName : function(newName){ 
name = newName; 
} 
} 
}();
console.log(person.name);//直接访问,结果为undefined 
console.log(person.getName()); //default 
person.setName("jozo"); 
console.log(person.getName()); //jozo

3. 实现面向对象中的对象

这样不同的对象(类的实例)拥有独立的成员及状态,互不干扰。虽然JavaScript中没有类这样的机制,但是通过使用闭包,我们可以模拟出这样的机制。还是以上边的例子来讲:


function Person(){ 
var name = "default"; 
return { 
getName : function(){ 
return name; 
}, 
setName : function(newName){ 
name = newName; 
} 
} 
}; 
var person1= Person(); 
print(person1.getName()); 
john.setName("person1"); 
print(person1.getName()); // person1 
var person2= Person(); 
print(person2.getName()); 
jack.setName("erson2"); 
print(erson2.getName()); //person2

Person的两个实例person1和person2互不干扰!因为这两个实例对name这个成员的访问是独立的。

5. 内存泄露及解决方案

垃圾回收与内存泄露分析

垃圾回收机制

谈及内存管理,自然离不开JS中的垃圾回收机制。主要有两种策略来实现垃圾回收:标记清除和引用计数;

标记清除:垃圾收集器在运行时会给存储在内存中的所有变量都加上标记。然后,它会去掉环境中的变量和被环境中的变量引用的变量的标记。此后,如果变量再被标记,则表示此变量准备被删除。2008年为止,IE、Firefox、Opera、Chrome、Safari的JavaScript引擎都使用了该方式;

引用计数:跟踪记录每个值被引用的次数。当声明一个变量并将一个引用类型的值赋给该变量时,这个值的引用次数就是1。如果这个值再被赋值给另一个变量,则引用次数加1。相反,如果一个变量脱离了该值的引用,则该值引用次数减1。当次数为0时,就会等待垃圾收集器的回收。

这个方式存在一个比较大的问题就是循环引用,就是说A对象包含一个指向B的指针,对象B也包含一个指向A的引用。这就可能造成大量内存得不到回收(内存泄露),因为它们的引用次数永远不可能是0。早期的IE版本里(ie4-ie6)采用的是计数的垃圾回收机制,闭包导致内存泄露的一个原因就是这个算法的一个缺陷。

我们知道,IE中有一部分对象并不是原生的JavaScript对象,例如,BOM和DOM中的对象就是以COM对象的形式实现的。而COM对象的垃圾回收机制采用的就是引用计数。因此,虽然IE的JavaScript引擎采用的是标记清除策略,但是访问COM对象依然是基于引用计数的,因此只要在IE中涉及COM对象就会存在循环引用的问题!

举个栗子:


window.onload = function(){
var el = document.getElementById("id");
el.onclick = function(){
alert(el.id);
}
} 

这段代码为什么会造成内存泄露?


el.onclick= function () {
alert(el.id);
}; 

执行这段代码的时候,将匿名函数对象赋值给el的onclick属性;然后匿名函数内部又引用了el对象,存在循环引用,所以不能被回收;

解决方法:


window.onload = function(){
var el = document.getElementById("id");
var id = el.id; //解除循环引用
el.onclick = function(){
alert(id); 
}
el = null; // 将闭包引用的外部函数中活动对象清除
}

以上所述是小编给大家介绍的JS闭包、作用域链、垃圾回收、内存泄露相关知识小结,希望对大家有所帮助!

作者最新文章