Dark Dwarf Blog background

闭包

闭包

1. 作用域

a.a. 基本概念

JavaScript 有如下的作用域:

  • 全局作用域 (Global Scope): 在所有函数和代码块 {} 外面定义的变量,在代码的任何地方都可以被访问。

过度使用全局变量会造成“全局污染”。

  • 函数作用域 (Function Scope): 使用 var 在一个函数内部声明的变量,它的“地盘”就是整个函数。
  • 块级作用域 (Block Scope): ES6 引入的 letconst 声明的变量,它的“地盘”是离它最近的一对花括号 {}。这让代码控制更精确。
let globalAge = 23; // 全局作用域

function printAge (age) { // 函数作用域开始
  var varAge = 34; // 函数作用域

  if (age > 0) { // 块级作用域开始
    // constAge 只在这个 if 的“房间”里有效
    const constAge = age * 2;
    console.log(constAge); // 正确
  } // 块级作用域结束

  // console.log(constAge); // 错误! 访问不到
} // 函数作用域结束

printAge(globalAge);

// console.log(varAge); // 错误! 访问不到

b.b. var 的局限性

letconst 的引入解决了 var 带来的许多问题(如变量提升、无块级作用域),是现代 JavaScript 的首选。下面我们详细讲讲 var 的这些问题。

i.i. 变量提升

在使用 var 声明变量时,该变量的声明会被 JavaScript 引擎“提升”到其所在作用域的顶部,但赋值操作会留在原地。这会导致如下奇怪的结果:

console.log(myVar); // 输出:undefined
var myVar = 10;
console.log(myVar); // 输出:10

它允许你在声明变量之前就使用它(尽管值是 undefined),这不符合正常的编程逻辑,并且是很多潜在 bug 的来源。而 letconst 就避免了这个问题。

b.b. 无块级作用域

var 声明的变量只遵从函数作用域或全局作用域,它会无视 {} 块的存在。这会导致一些非常反直觉的结果。例如下面的例子:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}

这个代码会连续打印5次5。这是因为 var 没有块级作用域,所以整个 for 循环中,从始至终都只有一个 i 变量。而 setTimeout 里的函数是异步的,它会在循环全部结束之后才开始执行。当它们开始执行并准备打印 i 时,它们访问的都是那个全局唯一的、值已经变成5的 i 变量

而假如我们用 let 声明 i 的话,由于 let 具有块级作用域,在 for 循环中使用 let 时,JavaScript 引擎在每一次循环都会创建一个新的、独立的 i 变量。这就避免了使用 var 造成的问题。

letconst 提供了块级作用域,变量的生命周期被限制在最小的必要范围内,极大地减少了变量污染和命名冲突的风险

2. 闭包

闭包 (Closure) 是 JavaScript 一个核心且强大的概念。

a.a. 基本概念

闭包是一个函数以及其**被创建时所在的词法环境(Lexical Environment)**的组合。

想象一个函数是一个“魔法背包”。当这个函数被创建时,它会把它“出生地”周围的所有局部变量都装进这个背包里。之后,无论这个函数被带到哪里去执行,它都可以随时打开这个背包,使用里面存放的变量。这个“魔法背包”就是闭包。它让函数可以 “记住”并访问它被创建时的环境

function makeAdding (firstNumber) {
  // "first" 是 makeAdding 函数这个“出生地”的局部变量
  const first = firstNumber;

  // 返回的这个函数,会背上“魔法背包”
  return function resulting (secondNumber) {
    // 关键!它可以访问背包里的 "first",即使 makeAdding 已经执行完毕
    return first + secondNumber;
  }
}

// add5 是一个背着背包的函数,背包里装着 { first: 5 }
const add5 = makeAdding(5);

// 当我们调用 add5 时,它打开背包,拿出里面的 5,和新传入的 2 相加
console.log(add5(2)) // 输出 7

b.b. 闭包原理

JavaScript 采用的是词法作用域(Lexical Scoping),意味着函数的作用域在它被定义时就已经确定了,而不是在它被调用时。

在上面的例子中,resulting 函数在 makeAdding 内部被定义,所以它的作用域链包含了 makeAdding 的作用域。即使 makeAdding 执行完毕,因为 add5 变量仍然引用着 resulting 函数,垃圾回收机制就不会销毁被 resulting 函数引用的 first 变量

3. 工厂函数

工厂函数是一个不使用 new 关键字、不依赖 this、只负责返回一个新对象的普通函数。

// 构造函数 (旧方法)
const User = function (name) {
  this.name = name;
  this.discordName = "@" + name;
}
// 使用: const user1 = new User("Bob");
// 缺点: 忘记 new 会污染全局对象,this 指向不明确,instanceof 不可靠。

// 工厂函数 (推荐)
function createUser (name) {
  const discordName = "@" + name;
  // 直接创建并返回一个对象,清晰直观
  return { name, discordName };
}
// 使用: const user2 = createUser("Alice");

工厂函数结合闭包,能创造出拥有私有变量的强大对象,这是实现**封装(Encapsulation)**的关键。

function createPlayer (name) {
  // 私有变量,外界无法直接访问
  let _reputation = 0;

  // 私有函数
  const _changeReputation = (val) => _reputation += val;

  // 返回的对象是暴露给外界的“公共接口”
  return {
    name,
    giveReputation: () => _changeReputation(1),
    takeReputation: () => _changeReputation(-1),
    getReputation: () => _reputation // 只提供一个“读取”方法
  };
}

const josh = createPlayer("josh");
josh.giveReputation();
josh.giveReputation();

// josh._reputation = 100; // 无效!_reputation 是私有的
console.log(josh.getReputation()); // 输出 2