闭包与作用域链

1. 作用域

作用域 是指变量和函数的可访问范围。JavaScript 中主要有三种作用域:

  1. 全局作用域:在该作用域声明的变量在代码任何地方都能被访问。
  2. 函数作用域:在函数内部声明的变量只能在函数内部访问。
  3. 块级作用域:ES6 引入了 letconst,使 {} 包裹的代码块也形成作用域。

2. 作用域链

当查找变量时,JavaScript 引擎会沿着作用域链向上查找,直到找到变量或到达顶层作用域(全局作用域)。如果在全局作用域中仍未找到,则抛出 ReferenceError

作用域链是在函数定义时确定的,而不是在函数调用时。这就是所谓的词法作用域

3. 闭包

闭包 是指一个函数能够访问其词法作用域中的变量,即使该函数在其词法作用域之外被调用。

简单来说:闭包 = 函数 + 该函数能访问的自由变量

核心特性

  1. 函数嵌套:必须有函数嵌套函数。
  2. 内部函数引用外部变量:内部函数引用了外部函数的变量。
  3. 外部函数返回内部函数:外部函数执行完毕后,其执行环境虽然销毁了,但其变量对象因为被内部函数引用而无法释放,这就形成了闭包。

示例

function createCounter() {
  let count = 0; // 私有变量
  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2

在这个例子中:

  • createCounter 执行完毕后,count 变量本应被回收。
  • 但因为返回的匿名函数引用了 count,形成了闭包。
  • count 变量一直保存在内存中,直到 counter 被销毁。

4. 闭包的应用场景

(1) 数据私有化 / 模拟私有变量

利用闭包,我们可以隐藏变量,只通过特定的 API 暴露操作接口,防止全局污染和外部直接修改。

const Person = (function() {
  let _name = 'Unknown'; // 私有变量
  
  function Person(name) {
    _name = name;
  }
  
  Person.prototype.getName = function() {
    return _name;
  };
  
  return Person;
})();

(2) 柯里化

将多参数函数转换为一系列单参数函数的过程。

function add(x) {
  return function(y) {
    return x + y;
  };
}
const add5 = add(5);
console.log(add5(3)); // 8

(3) 防抖和节流

这是前端最常见的闭包应用。

function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

5. 闭包与内存泄漏

闭包的主要缺点是内存占用。由于闭包会引用外部函数的变量,导致这些变量无法被垃圾回收机制回收。

如何避免内存泄漏?

  1. 尽量避免不必要的闭包。
  2. 在不再需要闭包时,手动解除引用(例如将变量赋值为 null)。
let element = document.getElementById('button');
element.onclick = function() {
  console.log(element.id);
};
// 循环引用:element -> onclick -> function -> element

// 解决办法:
// element.onclick = null;
// element = null;

6. 面试常见问题

Q: 循环中的闭包陷阱?

for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 1000); // 输出 5 个 5
}

原因var 只有函数作用域,循环结束时 i 已经是 5。回调函数引用的是同一个 i

解决

  1. 使用 let (推荐):let 有块级作用域,每次循环都会创建一个新的绑定。
  2. IIFE (立即执行函数):构造一个新的函数作用域来捕获当前的 i
    for (var i = 0; i < 5; i++) {
      (function(j) {
        setTimeout(() => console.log(j), 1000);
      })(i);
    }