JavaScript 作用域与闭包

作用域(scope)决定变量"在哪儿能被访问";闭包(closure)是函数"记住"自己诞生地的变量,即使那个地方已经离开。这两个概念是 JS 进阶的分水岭——理解了它们,你就跨过了新手的门槛。

1. 三种作用域

// JS 三种作用域:全局、函数、块级(ES6)

// 1. 全局作用域:在任何地方都能访问
const globalName = "我是全局";

// 2. 函数作用域:只在函数内有效
function test() {
  const localName = "函数内的";
  console.log(globalName);   // ✅ 能访问外层
  console.log(localName);    // ✅
}
test();
// console.log(localName);   // ❌ 函数外访问不到

// 3. 块级作用域(ES6 let/const):只在 {} 内有效
{
  const blockName = "块内的";
}
// console.log(blockName);   // ❌ ReferenceError

// var 没有块级作用域,会泄漏
if (true) {
  var leaked = "泄漏了";
}
console.log(leaked);         // ✅ "泄漏了"(var 的坑)

一句话:let/const 是块级作用域(只在 {} 内),var 是函数作用域(无视 {}),全局变量到处都能访问。

2. 词法作用域(静态作用域)

JS 是词法作用域(也叫静态作用域):函数定义时就决定了它能访问哪些变量,跟在哪里被调用无关。这就像你的"出生地"决定了你的国籍,跟后来去哪儿无关。

// JS 是"词法作用域"(静态作用域)
// 函数的作用域在"定义时"就决定了,跟在哪调用无关

const x = "外层";

function outer() {
  const x = "中层";
  function inner() {
    console.log(x);   // "中层"(访问定义时的 x)
  }
  return inner;
}

const fn = outer();
fn();    // "中层"(虽然在外层调用,但 inner 记住的是定义时的作用域)

// 作用域链:由内向外找
const a = 1;
function f1() {
  const b = 2;
  function f2() {
    const c = 3;
    console.log(a, b, c);   // 1 2 3(逐层往外找)
  }
  f2();
}
f1();

函数找变量时,沿作用域链由内向外一层层找:先在自己内部找,找不到就去定义它的外层找,再找不到继续往外,直到全局。如果全局也没有,就报 ReferenceError

3. 什么是闭包

闭包 = 函数 + 它访问的外层变量。当一个内部函数被拿到外面执行,却仍然能访问定义时的外层变量,就形成了闭包。

// 闭包:函数 + 它记住的外层变量
// 最简单的闭包:函数可以访问外层作用域的变量

function makeCounter() {
  let count = 0;                    // 被闭包"捕获"的变量
  return function () {
    count++;
    return count;
  };
}

const c1 = makeCounter();
console.log(c1());   // 1
console.log(c1());   // 2
console.log(c1());   // 3

// 每次调用 makeCounter 都创建新的 count,互不影响
const c2 = makeCounter();
console.log(c2());   // 1(独立的计数器)
console.log(c1());   // 4(c1 不受影响)

// count 从外部访问不到,但 c1 内部一直"持有"它
// console.log(count);   // ReferenceError

关键现象:makeCounter() 执行完后,按理说它的局部变量 count 应该被销毁。但因为返回的函数引用了它,JS 引擎会保留 count 不销毁——这就是闭包的"记忆"效果。

每次调用 makeCounter() 都创建一个独立的 count,所以 c1c2 互不影响。

4. 闭包实战:私有变量

JS 没有 private 关键字(类有 # 私有字段是 ES2022 才加的),闭包是经典实现私有变量的方式:

// 闭包实现"私有变量"(在没有 class 时的常用模式)

function createAccount(initialBalance) {
  let balance = initialBalance;     // 私有变量

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) {
        console.log("余额不足");
        return null;
      }
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const acc = createAccount(100);
console.log(acc.getBalance());   // 100
acc.deposit(50);
console.log(acc.getBalance());   // 150
acc.withdraw(30);
console.log(acc.getBalance());   // 120
// acc.balance;     // undefined(外部访问不到私有变量)

外部只能通过 depositwithdraw 这些"公开方法"操作 balance,无法直接修改。这种封装让代码更安全、更可控。

5. 模块模式

结合 IIFE(立即调用函数)和闭包,可以创建一个独立的命名空间,只暴露公开接口:

// 模块模式:用 IIFE + 闭包,创建独立命名空间
const Counter = (function () {
  let privateCount = 0;          // 私有

  function changeBy(val) {        // 私有方法
    privateCount += val;
  }

  return {
    increment() { changeBy(1); },
    decrement() { changeBy(-1); },
    value() { return privateCount; }
  };
})();

Counter.increment();
Counter.increment();
console.log(Counter.value());    // 2
Counter.decrement();
console.log(Counter.value());    // 1
// Counter.changeBy(5);          // 报错,私有方法访问不到
// Counter.privateCount;         // undefined

// ES6 之后,用真正的模块(import/export)代替

这是 ES6 模块(import/export)出现之前,JS 实现"模块化"的主流方式。现在新项目直接用 ES 模块,但维护老代码时仍会遇到。

6. 循环里的闭包陷阱

这是闭包最经典的面试题:

// 经典坑:循环里的闭包

// ❌ 用 var:所有闭包共享同一个 i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 输出: 3 3 3(等定时器触发时,i 已经是 3)

// ✅ 解决方案 1:用 let(每轮都是新的 i)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 输出: 0 1 2

// ✅ 解决方案 2:用 IIFE 创建独立作用域
for (var j = 0; j < 3; j++) {
  (function (k) {
    setTimeout(() => console.log(k), 100);
  })(j);
}
// 输出: 0 1 2

为什么会输出 3 3 3:var 是函数作用域,整个循环共享同一个 i。等 setTimeout 100ms 后真正执行时,循环早已结束,i 已经是 3。三个回调函数闭包到的是同一个 i

let 的解法:每轮循环 let 都创建一个新的 i,每个闭包捕获的是不同值。这是现代 JS 推荐写法。

7. 闭包的应用场景

8. 闭包的代价

闭包会让外层变量不被垃圾回收,如果滥用会导致内存占用变高。所以:

// 函数防抖(debounce):经典闭包应用
function debounce(fn, delay) {
  let timer = null;          // 闭包里持有定时器
  return function (...args) {
    clearTimeout(timer);     // 清掉上次的
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

const log = debounce((msg) => console.log(msg), 300);
// 连续调用只执行最后一次
log("a");
log("b");
log("c");   // 300ms 后输出 c

小结

下一篇我们看 JS 的对象——键值对的集合,日常开发用得最多的数据结构。

← 上一篇 JavaScript 函数

下一篇 JavaScript 对象

✈️💬