深入理解JavaScript闭包:原理、机制与实战应用

作者:异步漫步

时间:2026-10-29

浏览:181

本文深入解析JavaScript闭包的定义、创建方式及其底层执行机制。通过剖析作用域链、活动对象与垃圾回收(GC)的关系,阐明闭包如何保护变量并维持内存占用。文章结合经典代码案例,详细讲解闭包在保护变量、维持状态方面的应用,并重点分析闭包导致的常见陷阱(如循环变量引用问题)及修复方案,帮助开发者全面掌握闭包原理与最佳实践。

文章目录

JavaScript 闭包定义与基础示例

JavaScript 闭包究竟是什么?

使用 JavaScript 开发已有一年多,闭包这一概念常令人感到困惑。虽然期间接触了一些相关知识点,也曾因误解闭包而犯错,且查阅了不少资料,但仍未完全通透。最近重读《jQuery基础教程》附录,发现其中对闭包的讲解简明易懂,因此借此机会进行总结梳理。

1、定义

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

直接上例子


function a(){
 var i=0;
 function b(){
  alert(++i);
 }
 return b;
}
var c = a();
c();

这段代码具备两个显著特征:

1)、函数 b 嵌套在函数 a 内部; 2)、函数 a 返回函数 b。

执行 var c=a() 后,变量 c 实际上指向了函数 b。再次执行 c() 时,会弹出窗口显示 i 的值(首次为 1)。这段代码即创建了一个闭包。原因在于:当函数 a 的内部函数 b 被函数 a 外部的变量 c 引用时,便形成了闭包。

或许你仍对闭包感到陌生,因为尚不清楚其实际用途。接下来我们将继续探索。

闭包的作用与内存管理机制

2、闭包有什么作用?

简而言之,闭包的作用在于:当函数 a 执行完毕并返回后,JavaScript 的垃圾回收机制(GC)不会回收函数 a 占用的资源。这是因为函数 a 的内部函数 b 的执行依赖于函数 a 中的变量。这是对闭包作用较为直观的描述,虽不够专业严谨,但大致含义如此。理解闭包需要循序渐进。在上述例子中,由于闭包的存在,函数 a 返回后,a 中的 i 始终存在。因此,每次执行 c(),i 都会自加 1 后通过 alert 输出 i 的值。

试想另一种情况:如果 a 返回的不是函数 b,结果将截然不同。因为 a 执行完后,b 未被返回给 a 的外部,仅被 a 引用;此时 a 也仅被 b 引用。函数 a 和 b 互相引用却不被外界打扰(即不被外部变量引用),函数 a 和 b 就会被 GC 回收。(关于 JavaScript 的垃圾回收机制将在后文详细介绍)。

闭包内部的执行环境与作用域链

3、闭包内的微观世界

若要更深入理解闭包以及函数 a 与嵌套函数 b 的关系,需引入几个核心概念:函数的执行环境(execution context)、活动对象(call object)、作用域(scope)、作用域链(scope chain)。以下以函数 a 从定义到执行的过程为例阐述这些概念。

1)、当定义函数 a 时,JS 解释器会将函数 a 的作用域链(scope chain)设置为定义 a 时 a 所在的“环境”。若 a 为全局函数,则 scope chain 中仅包含 window 对象。 2)、当函数 a 执行时,a 会进入相应的执行环境(execution context)。 3)、在创建执行环境的过程中,首先会为 a 添加一个 scope 属性,即 a 的作用域,其值为第 1 步中的 scope chain。即 a.scope=a 的作用域链。 4)、随后执行环境会创建一个活动对象(call object)。活动对象也是一个拥有属性的对象,但它不具有原型且不能通过 JavaScript 代码直接访问。创建完活动对象后,将其添加到 a 的作用域链的最顶端。此时 a 的作用域链包含两个对象:a 的活动对象和 window 对象。 5)、下一步是在活动对象上添加一个 arguments 属性,它保存着调用函数 a 时所传递的参数。 6)、最后将所有函数 a 的形参和内部函数 b 的引用也添加到 a 的活动对象上。在这一步中,完成了函数 b 的定义,因此如同第 3 步,函数 b 的作用域链被设置为 b 被定义的环境,即 a 的作用域。

至此,函数 a 从定义到执行的步骤完成。此时 a 返回函数 b 的引用给 c,又因函数 b 的作用域链包含对函数 a 的活动对象的引用,即 b 可以访问到 a 中定义的所有变量和函数。函数 b 被 c 引用,函数 b 又依赖函数 a,因此函数 a 在返回后不会被 GC 回收。

当函数 b 执行时,亦会遵循上述步骤。因此,执行时 b 的作用域链包含 3 个对象:b 的活动对象、a 的活动对象和 window 对象。当在函数 b 中访问一个变量时,搜索顺序为先搜索自身的活动对象,若存在则返回;若不存在,则继续搜索函数 a 的活动对象,依次查找,直到找到为止。若整个作用域链上均无法找到,则返回 undefined。若函数 b 存在 prototype 原型对象,则在查找完自身的活动对象后,先查找自身的原型对象,再继续查找。这就是 JavaScript 中的变量查找机制。

闭包的典型应用场景

4、闭包的应用场景

1)、保护函数内的变量安全。以最开始的例子为例,函数 a 中 i 只有函数 b 才能访问,而无法通过其他途径访问到,因此保护了 i 的安全性。 2)、在内存中维持一个变量。依然如前例,由于闭包,函数 a 中 i 一直存在于内存中,因此每次执行 c(),都会给 i 自加 1。 以上两点是闭包最基本的应用场景,许多经典案例皆源于此。

JavaScript 垃圾回收与闭包代价

5、Javascript 的垃圾回收机制

在 JavaScript 中,如果一个对象不再被引用,那么这个对象就会被 GC 回收。如果两个对象互相引用,而不再被第 3 者所引用,那么这两个互相引用的对象也会被回收。因为函数 a 被 b 引用,b 又被 a 外的 c 引用,这就是为什么函数 a 执行后不会被回收的原因。

在 JavaScript 中没有块级作用域,一般为了给某个函数申明一些只有该函数才能使用的局部变量时,我们就会用到闭包,这样我们可以很大程度上减少全局作用域中的变量,净化全局作用域。

使用闭包有如上的好处,当然这样的好处是需要付出代价的,代价就是内存的占用。

如何理解上面的那句话呢?

每个函数的执行,都会创建一个与该函数相关的函数执行环境,或者说是函数执行上下文。这个执行上下文中有一个属性 scope chain(作用域链指针),这个指针指向一个作用域链结构,作用域链中的指针又都指向各个作用域对应的活动对象。正常情况,一个函数在调用开始执行时创建这个函数执行上下文及相应的作用域链,在函数执行结束后释放函数执行上下文及相应作用域链所占的空间。


//声明函数
function test(){
 var str = "hello world";
 console.log(str);
}
//调用函数
test();

在调用函数的时候会在内存中生成如下图的结构:

JavaScript 闭包定义与基础示例

但是闭包的情况就有点特殊了,由于闭包函数可以访问外层函数中的变量,所以外层函数在执行结束后,其作用域活动对象并不会被释放(注意,外层函数执行结束后执行环境和对应的作用域链就会被销毁),而是被闭包函数的作用域链所引用,直到闭包函数被销毁后,外层函数的作用域活动对象才会被销毁。这也正是闭包要占用内存的原因。

所以使用闭包有好处,也有坏处,滥用闭包会造成内存的大量消耗。

闭包的副作用:变量最终值陷阱

使用闭包还有其他的副作用,可以说是 bug,也可以说不是,相对不同的业务可能就会有不同的看法。

这个副作用是闭包函数只能取到外层函数变量的最终值。

测试代码如下:(这里使用了jquery对象)


 /*闭包缺陷*/
 (function($){
  var result = new Array(),
  i = 0;
  for(;i<10;i++){
   result[i] = function(){
    return i;
   };
  }
  $.RES1 = result;
 })(jQuery);
 // 执行数组中的函数
 $.RES1[0]();

上面的代码先通过匿名函数表达式开辟了一块私有作用域,这个匿名函数就是我们上面所说的外层函数,该外层函数有一个参数$,同时还定义了变量 result 和 I , 通过 for 循环给数组 result 赋值一个匿名函数,这个匿名函数就是闭包,他访问了外层函数的变量 I , 理论上数组 resulti 会返回相应的数组下标值,实际情况却不如所愿。

如上代码 $.RES10 的执行结果是 10.

为什么会这样呢,因为 i 的最终值就是 10.

下面我们通过下图来详细说明下,上面的那段代码执行时在内存中到底发生了什么:

JavaScript 闭包定义与基础示例

那么这个副作用有没有办法可以修复呢?当然可以!

我们可以通过下面的代码来达到我们的预期。


 /*修复闭包缺陷*/
 (function($){
  var result = new Array(),
  i = 0;
  for(;i<10;i++){
   result[i] = function(num){
    return function(){
     return num;
    }
   }(i);
  }
  $.RES2 = result;
 })(jQuery);
 //调用闭包函数
 console.log($.RES2[0]());

上面的代码又在内存中发生了什么?我们同样用下面的一幅图来详细解释。看懂了上面的图,我们也就不难理解下面的图。

JavaScript 闭包定义与基础示例

闭包实战:DOM 事件绑定与修复

6.简单的例子

首先从一个经典错误谈起,页面上有若干个 div, 我们想给它们绑定一个 onclick 方法,于是有了下面的代码


<div id="divTest">
 <span>0</span> <span>1</span> <span>2</span> <span>3</span>
</div>
<div id="divTest2">
 <span>0</span> <span>1</span> <span>2</span> <span>3</span>
</div>


$(document).ready(function() {
  var spans = $("#divTest span");
  for (var i = 0; i < spans.length; i++) {
   spans[i].onclick = function() {
    alert(i);
   }
  }
});

很简单的功能可是却偏偏出错了,每次 alert 出的值都是 4,简单的修改就好使了


var spans2 = $("#divTest2 span");
$(document).ready(function() {
 for (var i = 0; i < spans2.length; i++) {
  (function(num) {
   spans2[i].onclick = function() {
    alert(num);
   }
  })(i);
 }
});

内部函数与闭包的创建机制

7.内部函数

让我们从一些基础的知识谈起,首先了解一下内部函数。内部函数就是定义在另一个函数中的函数。例如:


function outerFn () {
 functioninnerFn () {}
}

innerFn 就是一个被包在 outerFn 作用域中的内部函数。这意味着,在 outerFn 内部调用 innerFn 是有效的,而在 outerFn 外部调用 innerFn 则是无效的。下面代码会导致一个 JavaScript 错误:


function outerFn() {
 document.write("Outer function<br/>");
 function innerFn() {
  document.write("Inner function<br/>");
 }
}
innerFn();//Uncaught ReferenceError: innerFn is not defined

不过在 outerFn 内部调用 innerFn,则可以成功运行:


function outerFn() {
   document.write("Outer function<br/>");
   function innerFn() {
    document.write("Inner function<br/>");
   }
   innerFn();
  }
  outerFn();

8、伟大的逃脱(内部函数如何逃脱外部函数)

JavaScript 允许开发人员像传递任何类型的数据一样传递函数,也就是说,JavaScript 中的内部函数能够逃脱定义他们的外部函数。

逃脱的方式有很多种,例如可以将内部函数指定给一个全局变量:


//定义全局变量逃脱
var globalVar;
function outerFn() {
 document.write("Outer function<br/>");   
 function innerFn() {
  document.write("Inner function<br/>");
 }
 globalVar = innerFn;
}
outerFn(); //Outer function Inner function
globalVar(); //Outer function Inner function
innerFn(); //ReferenceError: innerFn is not defined

调用 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 的垃圾收集器才能释放相应的内存空间(红色部分是理解闭包的关键)。

说了半天总算和闭包有关系了,闭包是指有权限访问另一个函数作用域的变量的函数,创建闭包的常见方式就是在一个函数内部创建另一个函数,就是我们上面说的内部函数,所以刚才说的不是废话,也是闭包相关的 ^_^

变量作用域与自由变量解析

9、变量的作用域

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


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 实例,两个计数器完全无关。

当内部函数在定义它的作用域的外部被引用时,就创建了该内部函数的一个闭包。这种情况下我们称既不是内部函数局部变量,也不是其参数的变量为自由变量,称外部函数的调用环境为封闭闭包的环境。从本质上讲,如果内部函数引用了位于外部函数中的变量,相当于授权该变量能够被延迟使用。因此,当外部函数调用完成后,这些变量的内存不会被释放(最后的值会保存),闭包仍然需要使用它们。

多内部函数的闭包交互与共享环境

10.闭包之间的交互

当存在多个内部函数时,很可能出现意料之外的闭包。我们定义一个递增函数,这个函数的增量为 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 的后续调用还会创建这些闭包的新实例,同时也会创建新的封闭环境,本质上是创建了一个新对象,自由变量就是这个对象的实例变量,而闭包就是这个对象的实例方法,而且这些变量也是私有的,因为不能在封装它们的作用域外部直接引用这些变量,从而确保了了面向对象数据的专有性。

11.解惑 现在我们可以回头看看开头写的例子就很容易明白为什么第一种写法每次都会 alert 4 了。


for (var i = 0; i < spans.length; i++) {
 spans[i].onclick = function() {
  alert(i);
 }
}

上面代码在页面加载后就会执行,当 i 的值为 4 的时候,判断条件不成立,for 循环执行完毕,但是因为每个 span 的 onclick 方法这时候为内部函数,所以 i 被闭包引用 (闭包引用传的是引用),内存不能被销毁,i 的值会一直保持 4,直到程序改变它或者所有的 onclick 函数销毁(主动把函数赋为 null 或者页面卸载)时才会被回收。这样每次我们点击 span 的时候,onclick 函数会查找 i 的值(作用域链是引用方式),一查等于 4,然后就 alert 给我们了。

而第二种方式是使用了一个立即执行的函数又创建了一层闭包,函数声明放在括号内就变成了表达式,后面再加上括号括号就是调用了,这时候把 i 当参数传入,函数立即执行,num 保存每次 i 的值。

这一通下来想必大家也和我一样,对闭包有所了解了吧,当然完全了解的话需要把函数的执行环境和作用域链搞清楚。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章