作用域链与闭包机制

第二编 · 第二章:作用域链与闭包机制的深入分析


作用域是什么

作用域(Scope)决定了 JavaScript 引擎如何查找变量。

JavaScript 有三种作用域:

全局作用域:最外层的作用域,在任何地方都能访问。

函数作用域:由函数创建的作用域,函数内部定义的变量只能在这个函数内部访问。

块级作用域:由 letconst{} 块内创建的作用域。

var global = 'I am global';

function foo() {
  var functionScoped = 'I am in foo';
  console.log(global); // 能访问

  if (true) {
    let blockScoped = 'I am in block';
    const alsoBlockScoped = 'Me too';
    console.log(blockScoped); // 能访问
  }

  // console.log(blockScoped); // ReferenceError!块级作用域在块外访问不到
}

console.log(functionScoped); // ReferenceError!函数作用域在函数外访问不到

作用域链是什么

当 JavaScript 引擎在当前作用域找不到变量时,它会沿着作用域链(Scope Chain)向外层查找,直到找到为止,或者到达全局作用域,如果还找不到就返回 undefined

const name = 'Global';

function outer() {
  const name = 'Outer';

  function inner() {
    const name = 'Inner';
    console.log(name); // 输出 'Inner'
  }

  function middle() {
    // 这里没有定义 name
    console.log(name); // 找到 outer 的 name,输出 'Outer'
  }

  inner();
  middle();
}

outer();

inner 函数的执行上下文形成的作用域链是:innerouterglobal。当 inner 查找 name 时,先在 inner 自己的作用域找到,就不再往外找了。


词法作用域

JavaScript 的作用域是词法作用域(Lexical Scope),意思是作用域在代码定义时就确定了,而不是在调用时确定。

function outer() {
  const x = 10;

  function middle() {
    console.log(x); // 输出 10,不是 20
  }

  function inner() {
    const x = 20;
    middle(); // 调用时 x 是 20,但 middle 定义时的作用域决定了它能访问 outer 的 x
  }

  inner();
}

outer();

middle 函数在定义时就已经确定了它的词法环境包含 outerx,所以无论 middle 被谁调用,它能访问的 x 都是 outer 里定义的那个 10

这就是闭包的基础。


闭包是什么

闭包(Closure)是 JavaScript 最强大也最容易被误解的特性之一。

简单来说,闭包是指一个函数能记住它定义时的词法环境,即使这个函数在定义时的词法环境之外被执行

function createCounter() {
  let count = 0;

  return function() {
    count += 1;
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

createCounter 返回的函数形成了一个闭包。它记住了 createCounter 执行时的词法环境,包括 count 变量。即使 createCounter 的执行上下文已经从调用栈弹出,counter 函数仍然能访问和修改 count

这就是闭包的价值:让函数能携带"记忆"


闭包的经典应用

模块模式

function createModule(prefix) {
  const name = prefix;

  return {
    getName() {
      return name;
    },
    setName(newName) {
      name = newName;
    }
  };
}

const module = createModule('MyModule');
console.log(module.getName()); // 'MyModule'
module.setName('NewModule');
console.log(module.getName()); // 'NewModule'

name 变量被隐藏在闭包里,外部无法直接访问,只能通过返回的对象提供的方法操作。这是一种实现模块私有变量的方式。

防抖函数

function debounce(fn, delay) {
  let timer = null;

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

防抖函数的实现利用了闭包:timer 变量被闭包持有,每次调用返回的函数都能访问和修改同一个 timer,从而实现"最后一次调用后才执行"的功能。

for 循环中的闭包问题

这是最常见的闭包坑:

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 3、3、3,而不是 0、1、2
  }, 100);
}

因为 var 是函数作用域,不是块级作用域,所以循环结束后 i 的值是 3。所有三个 setTimeout 的回调共享同一个 i,而且 setTimeout 是在循环结束后才执行的。

解决方式是使用 let(块级作用域)或者创建闭包来捕获每次循环的值:

// 方式一:使用 let
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 0、1、2
  }, 100);
}

// 方式二:使用 IIFE 创建闭包
for (var i = 0; i < 3; i++) {
  (function(capturedI) {
    setTimeout(() => {
      console.log(capturedI); // 输出 0、1、2
    }, 100);
  })(i);
}

let 在每次循环迭代都会创建一个新的绑定,所以每个 setTimeout 回调捕获的是不同的 i


闭包的内存问题

闭包会阻止 JavaScript 引擎垃圾回收那些本该被回收的变量。如果闭包长期存在,对应的词法环境就不会被垃圾回收。

function createLargeArray() {
  const hugeArray = new Array(1000000).fill('data');

  return function() {
    return hugeArray.length;
  };
}

const getter = createLargeArray(); // hugeArray 永远不会被回收,因为 getter 闭包持有它

这是一个潜在的内存泄漏源。解决方案是:在不需要闭包时解除对它的引用,或者在使用完毕后把不再需要的引用设为 null

现代浏览器的垃圾回收器已经能处理循环引用,但在某些场景下(尤其是 IE 浏览器的旧版本),不正确的闭包使用仍然可能导致内存泄漏。


这一章想说的

作用域决定了变量的可访问范围,作用域链让 JavaScript 引擎能逐层向外查找变量。词法作用域是 JavaScript 的默认行为,作用域在代码定义时就确定了。

闭包是函数能记住它定义时的词法环境的特性。这让函数能携带"记忆",是实现模块模式、防抖、节流等高级功能的基础。但闭包也可能导致意外的内存问题,使用时需要谨慎。