ToolkitX
知识库工具箱

闭包与作用域

词法作用域、闭包原理、IIFE

20min·进阶

01. 闭包是什么——函数记住它的出生地

闭包是 JavaScript 最重要的概念之一。简单说:一个函数能够记住并访问它被创建时所在的作用域里的变量,即使那个作用域已经不存在了。 打个比方:你小时候在你家树下面埋了个罐子,长大了回到那个树下面还能挖出来。对于函数来说,闭包就是它小时候的记忆——它能访问创建它的那个作用域里的变量,哪怕那个父函数早就执行完了。 所有 JavaScript 函数本质上都是闭包——因为它们都有对创建时作用域的引用。只是我们经常说的闭包场景是指函数内部返回另一个函数,并且返回的函数用到了外层函数的变量。
javascript
// 经典的闭包例子
function createCounter() {
  let count = 0;
  return function() {
    count++;
    return count;
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
// count 变量虽然定义在 createCounter 里,
// 但返回的函数一直能访问它

02. 闭包的实际用途

闭包不是用来炫技的,它有很实际的用途: 1. 数据私有化——创建只有某些函数才能访问的变量,外部完全碰不到。在 JavaScript 还没有 class private 字段之前,闭包就是唯一的私有变量方案。 2. 工厂函数——创建一系列类似但行为有细微差异的函数。比如 createAdder(5) 返回一个每次加 5 的函数。 3. 回调与事件绑定——点击按钮后执行的回调函数能访问绑定时周围作用域的变量。 4. 模块模式——IIFE(立即调用的函数表达式)加闭包是最早的 JS 模块方案,把变量封在函数作用域里,只暴露需要的接口。
javascript
// 1. 私有变量
function createPerson(name) {
  let age = 0;
  return {
    getName: () => name,
    getAge: () => age,
    birthday: () => { age++; }
  };
  // age 和 name 在外部完全访问不到
}

// 2. 工厂函数
const addFive = (x => y => x + y)(5);
addFive(10); // 15

// 3. 模块模式
const myModule = (function() {
  let privateVar = 'secret';
  return {
    getVar: () => privateVar,
    setVar: v => { privateVar = v; }
  };
})();
闭包的内存问题——闭包会保持对外层变量的引用,导致这些变量不会被垃圾回收。如果闭包里引用了大对象且闭包一直存在,内存就释放不掉。

03. 经典的闭包陷阱——循环里的闭包

这是面试必考题:用 var 在循环里创建函数引用循环变量 i,结果所有函数引用的都是循环结束后的同一个 i。 为什么?因为 var 是函数作用域而不是块作用域,整个循环共享同一个 i。循环结束后 i 变成了最终值。所有闭包引用的都是同一个 i 的最终值。 解决方案:1. 用 let 替代 var(let 是块作用域,每次迭代创建新的绑定)。2. 用 IIFE 创建一个新作用域把 i 的值传进去保存。
javascript
// 经典陷阱
for (var i = 0; i < 3; i++) {
  setTimeout(function() { console.log(i); }, 100);
}
// 输出:3, 3, 3(不是 0, 1, 2)

// 解决方案 1:用 let
for (let i = 0; i < 3; i++) {
  setTimeout(function() { console.log(i); }, 100);
}
// 输出:0, 1, 2 ✓

// 解决方案 2:IIFE
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() { console.log(j); }, 100);
  })(i);
}
// 输出:0, 1, 2 ✓
用 let 就基本不用关心这个陷阱了。但面试官喜欢考这个,理解原理还是需要的。

04. 词法作用域——作用域在写代码时就定好了

JavaScript 的作用域是静态的(词法作用域)——函数在哪定义的就继承了哪的作用域,跟在哪执行的没关系。 this 是动态的——取决于函数怎么调用的。这就是为什么闭包跟 this 的关系让人觉得迷幻:闭包记住了词法作用域的变量,但 this 的值取决于调用方式,不受闭包保护。 箭头函数的 this 是词法的——继承自外层作用域的 this,不会因为调用方式改变。用箭头函数做回调,this 指向固定,省去 bind(this) 的烦恼。 理解闭包的两个关键:词法作用域(编译时确定)、垃圾回收(闭包引用的变量不回收)。
javascript
// 词法作用域
function outer() {
  const x = 10;
  return function inner() {
    console.log(x); // x 从 outer 的作用域里找
  };
}

const f = outer();
f(); // 10,即使 outer 已经执行完了

// this 不是闭包变量
const obj = {
  name: 'Alice',
  greet: function() {
    setTimeout(function() {
      console.log(this.name); // undefined!this 是 window/global
    }, 100);
  },
  greetArrow: function() {
    setTimeout(() => {
      console.log(this.name); // Alice——箭头函数继承外层 this
    }, 100);
  }
};

05. 闭包与性能——内存管理

闭包虽好用,但滥用会导致内存泄漏: 1. 闭包持有了不必要的大对象引用——本来该被回收的 DOM 元素或大数组,因为某个闭包还引着而没法释放。 2. 全局闭包——在全局作用域创建的闭包如果引用了大变量,这些变量就跟全局变量一样永驻内存。 3. 异步回调里的闭包——setTimeout/setInterval 的闭包持有引用,timer 不清理闭包就一直存在。 如何避免泄漏:不需要时解除引用(设为 null)、用 useEffect 的 cleanup 清理(React)、在组件销毁时取消订阅。 Chrome DevTools 的 Memory 面板可以拍 heap snapshot 看哪些闭包持有了不该持有的对象。
javascript
// 潜在内存泄漏
function setup() {
  const bigData = new Array(1000000);
  // bigData 只在 init 里用
  
  const btn = document.getElementById('btn');
  btn.addEventListener('click', function() {
    // 这个闭包引用了整个 setup 作用域
    // 虽然没直接用到 bigData,但 V8 的优化可能
    // 导致整个 scope 对象都被保留
    console.log('clicked');
  });
}

// 更好的做法:拆开作用域
function setup() {
  const bigData = new Array(1000000);
  // 用完 bigData 后...
  bigData = null; // 解除引用
  
  // 再绑定事件
  const btn = document.getElementById('btn');
  btn.addEventListener('click', () => console.log('clicked'));
}
现代 JS 引擎(V8)对闭包的内存做了很多优化——不会无脑保留所有外层变量。但不代表可以随意写,养成好习惯不依赖引擎优化。

知识测验

1/5正确 0

闭包能做什么?

下一节

原型与继承

下一节