一、先搞懂闭包到底是什么

很多刚接触前端的朋友,总觉得闭包是个“玄之又玄”的概念,其实它本质上就是JavaScript语言的一个特性,我们完全可以用大白话来理解:当一个函数“记住”了它出生时的环境,哪怕后来这个环境已经消失了,它还能继续用里面的东西,这就是闭包

举个最基础的例子,大家先建立初步认知:

// 技术栈:原生JavaScript
// 外层函数,用来“孕育”内部函数
function outer() {
  // 这个变量是outer的“私有财产”,正常情况下outer执行完就会被清空
  let count = 0;
  // 内部函数,闭包的核心载体
  function inner() {
    // inner函数“记住”了outer里的count,每次调用都能修改它
    count++;
    console.log(`当前count值:${count}`);
  }
  // outer执行完,把inner函数抛出来
  return inner;
}

// 把outer返回的inner函数存下来,这里就形成了闭包
const myInner = outer();
// 第一次调用inner,count变成1
myInner(); // 输出:当前count值:1
// 第二次调用inner,count变成2(没有被重置!)
myInner(); // 输出:当前count值:2

这个例子里,outer函数执行完后,它的执行环境本来应该被垃圾回收机制清空,但因为inner函数被存到了myInner里,还“惦记”着outer里的count,所以count就被“锁”在了内存里,不会被删掉。这就是闭包的核心:内部函数对外部函数作用域的引用

二、闭包的核心作用

闭包的作用,本质上就是解决两个问题:一是“私有变量”的需求,二是“状态持久化”的需求,我们一个个拆解。

2.1 实现私有变量

JavaScript本身没有像Java、Python那样的“私有”修饰符(比如private),以前要实现变量不被外部随意修改,全靠闭包。

比如我们想做一个“计数器”,这个计数器的内部数值不能被外部直接改,只能通过特定方法操作,用闭包就能轻松实现:

// 技术栈:原生JavaScript
// 封装一个计数器,内部的count是私有变量
function createCounter() {
  let count = 0; // 外部访问不到这个count

  // 提供三个操作count的方法
  return {
    // 增加计数
    increment: () => {
      count++;
    },
    // 减少计数
    decrement: () => {
      count--;
    },
    // 获取当前计数(不能直接改count,只能通过这个方法读)
    getCount: () => {
      return count;
    }
  };
}

// 创建一个计数器实例
const counter = createCounter();
// 外部想直接改count?不行,因为count不在外部作用域
console.log(counter.count); // 输出:undefined
// 只能通过提供的方法操作
counter.increment();
counter.increment();
console.log(counter.getCount()); // 输出:2
counter.decrement();
console.log(counter.getCount()); // 输出:1

这个例子里,count就是私有变量,外部拿不到、改不了,只能通过increment、decrement、getCount这三个方法操作,这就是闭包实现私有变量的核心逻辑。

2.2 保存状态(持久化局部变量)

很多时候我们需要让某个局部变量,在函数执行完后还能保留值,比如我们想做一个“带记忆的加法函数”,每次调用都能把上一次的结果和新的数字加起来:

// 技术栈:原生JavaScript
// 生成带记忆的加法函数
function createAdd() {
  // 这个sum是局部变量,本来执行完就没了
  let sum = 0;
  // 返回的函数“记住”了sum
  return function(num) {
    sum += num;
    return sum;
  };
}

// 生成第一个带记忆的加法函数
const add1 = createAdd();
console.log(add1(2)); // 输出:2(sum变成2)
console.log(add1(3)); // 输出:5(sum变成5)
console.log(add1(4)); // 输出:9(sum变成9)

// 生成第二个带记忆的加法函数,和add1互不干扰
const add2 = createAdd();
console.log(add2(10)); // 输出:10(add2的sum是独立的)
console.log(add2(20)); // 输出:30

这里add1和add2的sum是互相独立的,因为每次调用createAdd都会生成一个新的闭包环境,各自的sum不会互相影响,这就是闭包保存状态的优势。

三、闭包的常见使用场景

闭包的使用场景其实都是围绕它的两个核心作用展开的,我们结合实际开发中常用的场景来举例。

3.1 模块封装(早期模块化方案)

在ES6的import/export出现之前,前端的模块化全靠闭包实现,比如我们想封装一个“工具模块”,把一些工具函数和内部变量封装起来,不污染全局作用域:

// 技术栈:原生JavaScript
// 封装一个工具模块,内部变量和方法不暴露到全局
const utilsModule = (function() {
  // 私有变量,外部访问不到
  const secretKey = 'abc123';
  // 私有方法,外部访问不到
  function encrypt(str) {
    return str.split('').reverse().join('') + secretKey;
  }

  // 只把需要对外暴露的方法抛出来
  return {
    // 对外暴露的加密方法
    publicEncrypt: (str) => {
      return encrypt(str);
    },
    // 对外暴露的解密方法
    publicDecrypt: (str) => {
      // 解密逻辑:去掉末尾的secretKey,再反转
      const realStr = str.slice(0, -secretKey.length);
      return realStr.split('').reverse().join('');
    }
  };
})();

// 外部使用模块
console.log(utilsModule.secretKey); // 输出:undefined(拿不到私有变量)
const encrypted = utilsModule.publicEncrypt('hello');
console.log(encrypted); // 输出:ollehabc123
console.log(utilsModule.publicDecrypt(encrypted)); // 输出:hello

这种“立即执行函数+闭包”的模式,是早期前端模块化的标准方案,直到现在还有很多老项目在用。

3.2 事件监听中的状态保存

在前端开发中,我们经常需要给元素绑定事件监听,同时需要保存一些和这个元素相关的状态,比如给多个按钮绑定点击事件,每个按钮点击时要显示自己的索引:

// 技术栈:原生JavaScript
// 模拟页面上有3个按钮
const buttons = document.querySelectorAll('button');

// 给每个按钮绑定点击事件,保存按钮的索引
for (let i = 0; i < buttons.length; i++) {
  // 这里用let声明i,每次循环都会生成新的i(ES6的块级作用域)
  // 事件监听函数“记住”了当前循环的i,形成闭包
  buttons[i].addEventListener('click', function() {
    console.log(`点击了第${i + 1}个按钮`);
  });
}

如果是ES5的代码(没有let),以前我们只能用闭包来解决这个问题:

// 技术栈:原生JavaScript(ES5)
const buttons = document.querySelectorAll('button');

for (var i = 0; i < buttons.length; i++) {
  // 用立即执行函数生成闭包,保存当前的i
  (function(index) {
    buttons[index].addEventListener('click', function() {
      console.log(`点击了第${index + 1}个按钮`);
    });
  })(i);
}

这个例子里,每个事件监听函数都形成了独立的闭包,保存了自己对应的按钮索引,点击时就能正确显示。

3.3 函数柯里化

函数柯里化是把一个多参数的函数,拆成多个单参数的函数,闭包是实现柯里化的核心,比如我们想做一个“固定前缀的拼接函数”:

// 技术栈:原生JavaScript
// 柯里化函数,固定前缀
function curryPrefix(prefix) {
  // 返回的函数“记住”了prefix
  return function(str) {
    return `${prefix}-${str}`;
  };
}

// 生成两个固定前缀的函数
const addMr = curryPrefix('Mr');
const addMs = curryPrefix('Ms');

console.log(addMr('Li')); // 输出:Mr-Li
console.log(addMs('Wang')); // 输出:Ms-Wang

柯里化在函数式编程中非常常用,闭包是实现它的基础。

四、闭包的潜在风险

闭包虽然好用,但它也有自己的“副作用”,如果用不好,会给项目带来麻烦,最主要的风险就是内存泄漏。

4.1 内存泄漏的原理

我们之前说过,闭包会让外部函数的作用域被内部函数引用,导致垃圾回收机制不会回收这个作用域。如果这个作用域里有很大的对象(比如大数组、大的DOM元素),而我们又一直持有内部函数的引用,那这个作用域就会一直占用内存,直到页面关闭,这就是闭包导致的内存泄漏。

举个例子:

// 技术栈:原生JavaScript
function outer() {
  // 假设这个是一个很大的数组,占用很多内存
  const bigArray = new Array(1000000).fill('big');
  // 返回的函数“记住”了bigArray
  return function() {
    console.log(bigArray.length);
  };
}

// 把返回的函数存下来,形成闭包
const leakFunc = outer();
// 调用一次后,我们再也不用这个函数了,但它还一直占用内存
leakFunc(); // 输出:1000000

这个例子里,leakFunc一直被全局变量持有,所以bigArray所在的作用域永远不会被回收,内存就这么被浪费了。如果项目里有很多这样的闭包,内存占用会越来越高,最后导致页面卡顿甚至崩溃。

4.2 其他潜在风险

除了内存泄漏,闭包还有两个常见的风险:

  1. 变量意外修改:如果多个内部函数引用同一个外部变量,一个函数修改了这个变量,其他函数也会受到影响,比如:
// 技术栈:原生JavaScript
function outer() {
  let count = 0;
  return {
    add: () => count++,
    reset: () => count = 0
  };
}

const counter = outer();
counter.add();
counter.add();
console.log(counter.count); // 输出:undefined
counter.reset();
console.log(counter.getCount()); // 输出:0

这里如果不小心在某个内部函数里修改了count,其他依赖count的函数都会出错,而且因为count是私有变量,很难调试。

  1. 性能问题:闭包会让函数的作用域链变长,访问变量的速度会比普通函数慢一点。如果是在性能要求很高的场景(比如动画、高频触发的事件),大量闭包可能会影响性能。

五、闭包的使用注意事项

为了避免闭包的风险,我们在使用时要注意以下几点:

  1. 及时释放引用:如果某个闭包函数不再使用,要把它的引用设为null,让垃圾回收机制能回收对应的作用域,比如:
// 技术栈:原生JavaScript
// 不再使用leakFunc时,把它设为null
leakFunc = null;

这样bigArray所在的作用域就没有引用了,下次垃圾回收时就会被清理。

  1. 避免闭包持有大对象:如果闭包不需要用到某个大对象,不要让它引用这个对象。比如我们可以用解构的方式,只引用需要的变量:
// 技术栈:原生JavaScript
function outer() {
  const bigArray = new Array(1000000).fill('big');
  const smallNum = 10;
  // 只引用smallNum,不引用bigArray
  return function() {
    console.log(smallNum);
  };
}

这样bigArray在outer执行完后就会被回收,不会被闭包持有。

  1. 优先用ES6的块级作用域:在很多场景下,ES6的let/const块级作用域可以替代闭包,比如事件监听的例子,用let就不需要手动写闭包了,代码更简洁,也更安全。

  2. 合理使用模块方案:现在ES6的模块、TypeScript的private修饰符已经能很好地实现私有变量和模块封装,不需要再用早期的闭包模块化方案,减少不必要的闭包使用。

六、总结

闭包是JavaScript中一个非常强大的特性,它的核心是内部函数对外部函数作用域的引用,主要作用是实现私有变量和保存状态,常见于模块封装、事件监听、函数柯里化等场景。

但闭包也有潜在的风险,最主要的是内存泄漏,还可能导致变量意外修改和性能问题。所以我们在使用闭包时,要注意及时释放引用、避免持有大对象、合理利用ES6的新特性,让闭包为我们的开发服务,而不是带来麻烦。

总的来说,闭包不是什么“洪水猛兽”,只要理解了它的原理和风险,合理使用,就能发挥它的最大价值。