闭包:JS 里的 “背包客”,背走了变量的秘密

26 阅读5分钟

一、从作用域说起:变量的 “居住法则”

(一)作用域的三种 “住所”

  • 全局作用域:最顶层的 “大别墅”,用var/let/const声明的全局变量,整个程序都能访问。比如var n = 999,在任何地方都能喊出它的名字。
  • 函数作用域:函数创建的 “独立公寓”,用var声明的变量只能在公寓内使用。函数执行完,公寓可能被 “拆除”(变量被回收)。
  • 块级作用域:ES6 新增的 “合租小房间”({}内),用let/const声明的变量只在房间内有效,比如{ let a = 1; },出了房间就找不到a啦。

(二)作用域链:变量的 “寻宝路线”

内部函数找变量时,会先在自己的 “小房间” 找,找不到就去父函数的 “公寓” 找,再找不到才去全局 “别墅” 找。比如:

var n = 999;
function f1() {
  var b = 123; // 函数作用域的变量
  {
    let a = 1; // 块级作用域的变量
    console.log(n); // 找不到a?去父函数的父级(全局)找n,输出999
  }
}

二、闭包的诞生:当函数 “打包” 了变量

(一)闭包形成的三个条件

  1. 函数嵌套函数:儿子(内部函数)住在爸爸(外部函数)的 “公寓” 里。
  2. 内部函数引用外部变量:儿子偷偷拿了爸爸的 “钥匙”(引用外部变量)。
  3. 外部函数返回内部函数:爸爸把儿子 “送” 到外部,儿子带着钥匙走了,爸爸的公寓就没法拆啦!

(二)经典例子:闭包如何 “保存” 变量

function f1() {
  var n = 999; // 自由变量,被内部函数引用
  function f2() {
    console.log(n); // f2形成闭包,记住了n的值
  }
  return f2; // 把f2交给外部
}
var result = f1(); // result就是闭包函数f2
result(); // 输出999(n还在内存里,没被回收!)

这里的n就像被闭包 “打包” 带走了,即使f1执行完,n也不会被垃圾回收,因为f2还引用着它呢~

(三)闭包的本质:作用域链的 “冻结”

闭包让外部函数的作用域在内部函数被引用时一直存活,形成一条 “冻结” 的作用域链。就像拍了张照片,把那一刻的变量状态永远保存下来。

三、闭包的两大 “超能力”

(一)让外部访问函数内部变量

正常情况下,函数内部的局部变量外部无法访问,但闭包就像开了扇 “小窗”:

function createCounter() {
  var count = 0;
  return {
    increment: function() { count++; }, // 闭包函数
    getCount: function() { return count; } // 闭包函数
  };
}
var counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 输出1(访问到了内部的count!)

这里通过返回对象的方法,用闭包暴露了对内部变量的操作,实现了 “私有变量” 的受控访问。

(二)让变量值 “常驻” 内存

闭包能记住每次调用后的变量状态,比如累加器:

function f1() {
  var n = 999;
  function nAdd() { n += 1; } // 另一个闭包函数,修改n
  function f2() { console.log(n); }
  return f2;
}
var result = f1();
result(); // 999
nAdd(); // 这里nAdd是全局变量(没加var,注意别这么写!)
result(); // 1000(n的值被记住了,下次调用还是1000)

闭包就像一个 “记忆面包”,让变量的值一直留在内存里,每次调用都能基于上次的状态继续操作。

四、闭包的 “副作用”:小心内存陷阱

(一)可能导致内存泄漏

如果闭包长期引用大对象或不再需要的变量,这些变量无法被垃圾回收,就会堆积在内存里,导致内存泄漏。比如:

function badClosure() {
  var largeData = new Array(1000000).fill('数据'); // 大数组
  return function() {
    console.log(largeData.length); // 闭包一直引用largeData
  };
}
var leak = badClosure(); // 即使不再用leak,largeData也无法回收

(二)如何避免内存问题

  1. 及时 “断舍离” :在不需要闭包时,将其设为null,切断引用:

    var result = f1();
    result(); // 用完后
    result = null; // 让闭包函数被回收,释放内存
    
  2. 避免不必要的全局引用:像nAdd = function() {}这种直接赋值给全局变量的操作要谨慎,尽量用var声明局部变量。

(三)闭包会改变父函数内部变量

闭包在外部可以修改父函数的变量,可能带来不确定性。比如:

function f1() {
  var n = 0;
  function f2() { n = 10; } // 闭包修改n
  return f2;
}
var fn = f1();
fn(); // n被改成10

所以如果把父函数当作 “对象”,闭包当作 “方法”,内部变量当作 “私有属性”,要小心控制修改,避免意外副作用。

五、实战案例:闭包在前端的经典应用

(一)解决this指向问题(经典例子)

<script>
  var name = 'The Window';
  var object = {
    name: "My Object",
    getNameFunc: function() {
      var that = this; // 用that保存当前this(指向object)
      return function() {
        return that.name; // 闭包引用that,正确获取object.name
      };
    }
  };
  console.log(object.getNameFunc()()); // 输出"My Object"
</script>

这里用闭包保存that,避免内部函数的this指向全局window,是 ES6 箭头函数普及前的经典写法~

(二)模块模式:封装私有变量

var myModule = (function() {
  var privateVar = '我是私有变量';
  function privateFunc() { console.log('私有方法'); }
  return {
    publicVar: '我是公有变量',
    publicFunc: function() {
      privateFunc(); // 公有方法可以访问私有方法(通过闭包)
      console.log(privateVar); // 也能访问私有变量
    }
  };
})();
myModule.publicFunc(); // 输出“私有方法”和“我是私有变量”

通过闭包,模块模式实现了私有成员和公有接口的分离,是 JS 模块化的基础思想。

六、总结:闭包是把 “双刃剑”

  • 优点:实现数据封装、保存变量状态、让函数拥有 “记忆”,是 JS 实现高级功能(如模块、单例、柯里化)的核心。

  • 缺点:滥用会导致内存泄漏,修改父函数变量时需谨慎控制。

理解闭包的关键,在于掌握作用域链和垃圾回收机制:当内部函数被返回并引用时,它就像背着一个 “背包”,把外部函数的变量都装了进去,走到哪儿带到哪儿。合理使用闭包,能让代码更灵活强大,但也要记得及时 “清空背包”,别让无用的变量占用内存哦~

下次遇到闭包相关的问题,想想这个 “背包客” 的比喻,是不是更清晰啦? 😉