深入理解 JavaScript 闭包:概念、原理与应用

89 阅读7分钟

深入理解 JavaScript 闭包:概念、原理与应用

img

在 JavaScript 的世界里,闭包是一个既神秘又强大的概念。它看似复杂,却在许多实际场景中发挥着关键作用。本文将深入剖析 JavaScript 闭包,从基础概念到形成原理,再到实际应用,带你揭开它的神秘面纱。

一、闭包的概念

闭包的核心可以简单概括为函数嵌套,并且内部函数能够访问外部函数的变量。更准确地说,闭包是指有权访问另一个函数作用域中变量(自由变量)的函数。在这个概念中,内部函数被称为闭包函数而外部函数则被称为宿主函数

这个定义听起来有些抽象,我们可以通过一个形象的比喻来理解。把外部函数看作一个 “大房间”,里面存放着各种变量(自由变量),而内部函数就是 “大房间” 里的 “小房间”。正常情况下,“小房间” 只能访问自己内部的东西,但闭包赋予了 “小房间” 特殊的权限,让它能够访问 “大房间” 里的物品,即使 “大房间” 的主人已经离开(外部函数执行完毕)。

二、闭包的形成

闭包的形成需要满足三个关键条件:

  1. 函数嵌套函数:这是形成闭包的基础,通过函数的层层嵌套,构建起作用域链的嵌套结构。当内部函数在外部函数内部定义时,就如同在 “大房间” 里划分出了 “小房间” 。

    ​ 此时,即使外部函数执行结束,按照常规理解函数内部的变量应该被销毁,但由于闭包的存在,自由变量不会被销毁。这是因为内层函数引用了外层函数的变量,使得该变量的引用计数增加。

    ​ JavaScript 的垃圾回收机制会根据引用数量来判断是否回收内存,只要引用数量不为 0,变量就会一直存在,这也是闭包被称为 “背包” 的原因,它背着自由变量 “四处游走”。至于自由变量何时会被销毁,当没有任何对象再引用这些自由变量时,垃圾回收机制就会将其回收,释放内存。

  2. 内部函数引用外部函数的变量:仅仅有函数嵌套是不够的,只有当内部函数引用了外部函数的变量时,才会真正形成闭包。如果内部函数没有引用外部函数的变量,那么它们之间就不存在这种特殊的关联,也就无法构成闭包。例如:

    function fn1() {
      var n = 100;
      function fn2() {
        console.log(n);
      }
      return fn2;
    }
    var res = fn1();
    res(); // 100
    

    在这段代码中,fn2函数引用了fn1函数中的变量n,满足内部函数引用外部函数变量的条件,从而形成了闭包。

  3. 外部函数返回内部函数:外部函数将内部函数作为返回值返回,使得内部函数能够在外部函数的作用域之外被调用,进一步巩固了闭包的存在。这样,即使外部函数执行结束,通过返回的内部函数仍然可以访问到外部函数作用域中的自由变量。

三、证明自由变量没有被销毁

我们可以通过多次执行闭包函数来直观地证明自由变量没有被销毁。以下面的代码为例:

function f1() {
  var n = 99;
  nAdd = function () {
    n++;
  };
  function f2() {
    console.log(n);
  }
  return f2;
}

var test = f1();
test(); //99
nAdd(); 
test(); //100

在这段代码中,f1函数内部定义了变量n和两个函数nAddf2f2函数引用了变量n,并且f1函数返回了f2,形成了闭包。

当我们第一次调用test()时,输出99,说明此时n的值为99;调用nAdd()函数对n进行自增操作后,再次调用test(),输出100。这表明在f1函数执行结束后,变量n并没有被销毁,而是一直存在于内存中,并且其值可以被闭包函数修改和访问 。

在这个例子中,存在两个闭包函数,分别是nAddf2,它们都引用了f1函数作用域中的变量n

四、作用域与闭包的关系

在 JavaScript 中,存在三种主要的作用域:全局作用域、函数作用域和块级作用域。

  • 全局作用域:包含全局变量和全局函数,在整个程序中都可以访问。例如:

    var n = 99;
    
    function fn() {
      b = 100;
      // 函数作用域
      var n = 10;
      console.log(n, b);
    }
    
    fn();
    

    在这段代码中,n是在全局作用域中定义的变量,fn是全局函数。需要注意的是,在非严格模式下,如果一个变量未声明就直接赋值,如代码中的b,那么这个变量会自动成为全局变量。

  • 函数作用域:函数内部的变量和函数只在该函数内部可见。在上述代码中,fn函数内部定义的n就是函数变量,它的作用域仅限于fn函数内部。

  • 块级作用域:通过letconst关键字定义的变量具有块级作用域,它们只在包含它们的代码块内有效。

闭包与作用域密切相关,它利用了作用域链的特性。当内部函数形成闭包时,它会记住创建时所在的作用域链,即使外部函数执行完毕,仍然可以通过作用域链访问到外部函数作用域中的自由变量。

五、闭包的应用

  1. 记忆函数:闭包可以用于实现记忆函数,记住函数的执行结果,避免重复计算,提高性能。以计算斐波那契数列为例,使用闭包实现的记忆函数如下:

    function memoizeFib() {
      const cache = {};
      return function fib(n) {
        if (n in cache) { // 若n已经被计算过了
          return cache[n];
        }
        if (n <= 1) { 
          return n;
        }
        // 递归求值
        const result = fib(n - 1) + fib(n - 2);
        cache[n] = result;
        return result;
      };
    }
    
    const memoizedFib = memoizeFib();
    
    console.log(memoizedFib(10)); // 记忆 1~10 之间的全部结果
    console.log(memoizedFib(9)); // 直接在cache中获取
    

    在这个例子中,memoizeFib外层函数执行后返回内层函数fib,内层函数fib利用闭包记住了cache对象,在后续计算中如果发现要被计算的n已经存在于cache中,就直接返回结果,避免了重复计算。

  2. 柯里化:柯里化是将一多参数函数转换为多个单参数函数的过程,闭包在柯里化中发挥着重要作用。通过闭包,我们可以保存函数的部分参数,逐步构建出完整的函数调用。例如:

function add(a) {
  return function (b) {
    return a + b;
  };
}
const add5 = add(5);

console.log(add5(3)); // 输出 8

在这段代码中,add函数返回的内部函数通过闭包记住了参数a的值,当调用add5(3)时,实际上是在a5的基础上加上3,实现了柯里化的效果。

六、使用闭包的注意事项

虽然闭包功能强大,但在使用过程中也需要注意一些问题:

  1. 内存消耗与内存泄漏:由于闭包会使函数的作用域链不被释放,自由变量一直存在于内存中,这可能导致内存消耗过大,甚至引发内存泄漏。为了避免这种情况,在退出函数之前,应该将不再使用的局部变量设置为null或者使用delete操作符进行删除,以便垃圾回收机制能够及时回收内存。

  2. 变量值的意外改变闭包会在父函数的外部改变父函数内部变量的值,这可能会导致一些难以调试的问题。在编写代码时,需要谨慎处理闭包对变量的修改,确保程序的逻辑正确性。