JavaScript 作用域与闭包:停止死记硬背,开始理解
Source: Dev.to
让我们坦率直言
当你走进一次 JavaScript 技术面试时,面试官并不在找会从 Stack Overflow 复制粘贴的人。他们想要的是了解机器工作原理的人。
有两个概念比其他任何东西更容易让开发者卡壳:Scope 和 Closure。
大多数开发者对它们只有模糊的、“概念性”的理解。他们知道它能工作,却不知道为什么。在面试中,这种“概念性”根本不够。当你无法解释为什么一个变量是 undefined,或为什么循环打印出错误的数字时,你就会挂掉。
要掌握这些概念,别再把 JavaScript 当作黑箱,而要像 JavaScript 引擎一样思考。
第1部分 – 范围是一套规则
开发者常犯的第一个错误是把 作用域 当作变量所在的物理“位置”。
更好的理解是把作用域看作 JavaScript 引擎用来确定根据标识符名称去哪里查找变量的 一套规则。
JavaScript 使用一种叫做 词法作用域 的机制。这是你今天会看到的最重要的术语。“词法”与编译过程中的 词法分析(lexing)或解析阶段有关。
通俗解释: 作用域由你(作者)实际编写代码的位置决定。函数的嵌套层级决定了作用域的嵌套层级。一旦写好,这些规则(大多数情况下)在代码运行前就已经固定。
思维模型:办公大楼
想象你的程序是一座多层办公大楼。
| 层级 | 描述 |
|---|---|
| 底层(大堂) | 全局作用域 |
| 每个你声明的函数 | 在当前所在层之上新增的私有层 |
当引擎在函数内部执行代码(比如在第3层)并且需要查找变量(我们称之为 starthroat)时,它会遵循以下严格的步骤:
- 先在本层查找 – 第3层是否有自己的
starthroat?如果有,直接使用。 - 向外查找 – 如果没有,向下走楼梯到第2层。那层有吗?
- 重复 – 一层层向下,直到到达大堂(全局作用域)。
- 放弃 – 如果大堂也没有,则抛出
ReferenceError。表示变量不存在。
关键面试提示: 作用域查找只能向上(向外)进行,永远不会向下(向内)查找。大堂看不到第3层发生了什么。
var buildingName = "JS Towers"; // Lobby (global)
function floorTwo() {
var manager = "Kyle"; // 2nd‑floor scope
function floorThree() {
var developer = "You"; // 3rd‑floor scope
// Engine looks here (floor 3), finds nothing.
// Goes down to floor 2, finds `manager`. Success.
console.log(manager);
}
}
Source: …
第 2 部分 – 闭包不是魔法
如果说作用域是查找规则的集合,闭包 就是当你弯曲这些规则时会发生的事情。
很多人对闭包的定义模糊。我们来给出精确定义:
闭包 是指当一个函数在其词法作用域之外执行时,仍然能够访问该作用域的现象。
通常情况下,当函数执行完毕,它的作用域会被垃圾回收,内存被释放。闭包阻止了这种情况的发生。
思维模型:背包
如果在 函数 A 中定义 函数 B,函数 B 会获得指向函数 A 所在作用域的“隐藏链接”。
当函数 B 被从函数 A 中传出以供其他地方使用时,它并不会空手而出。它会把那条隐藏链接一起带走。
可以把它想象成一个背包。函数 B 背着一个背包,里面装着函数 A 在创建函数 B 时所存在的所有变量。无论函数 B 在哪里运行,或者在时间上相隔多久,它都可以打开这个背包并访问这些变量。
function outer() {
var secret = "XYZ_123"; // 按照作用域规则,这应该在 outer() 完成时消失。
function inner() {
// `inner` 对 `secret` 变量形成闭包。
// 它把 `secret` 放进自己的背包里。
console.log("The secret is: " + secret);
}
return inner; // 我们把 `inner` 发送到外部世界。
}
// outer() 完全运行并结束。
var myRef = outer(); // `inner` 现在存储在 `myRef` 中
// ... 几个小时后 ...
// `myRef` 在全局作用域中被执行。
// 然而,它仍然记得 `outer` 的作用域。
myRef(); // "The secret is: XYZ_123"
在面试中,不要只说“它记得”。应该说:
“由于闭包,
inner保留了对outer词法作用域的引用,防止该作用域被垃圾回收。”
Source: …
第 3 部分 – 经典面试陷阱
如果在面试中被问及闭包,你有约 90 % 的概率会看到下面这个循环题的变体。它用于检验你是否理解 作用域边界 与 值引用 之间的区别。
题目
for (var i = 1; i // (original code omitted in source)
专业提示: 要解决此问题,可以为每次迭代创建一个新的词法环境(使用 let 而不是 var),或使用 IIFE 或 bind 捕获当前值。
使用 let(块级作用域)
// Using let (block‑scoped)
for (let i = 1; i console.log(i), i * 1000);
}
使用 IIFE
// Using an IIFE
for (var i = 1; i console.log(j), j * 1000);
})(i);
}
结论
- 作用域 = 引擎定位标识符时遵循的 规则。
- 闭包 = 让函数在其原始词法环境本应消失后仍能访问这些规则的 机制。
掌握这些思维模型,你就能自信地回答任何关于作用域或闭包的面试问题——更重要的是,你会明白 为什么 JavaScript 会表现出这样的行为。
Source: …
修复方案(Pre‑ES6 IIFE 模式)
你需要为循环的每一次迭代创建一个新作用域,以“捕获”当前的 i 值。
for (var i = 1; i <= 3; i++) {
// 创建一个立即调用函数表达式 (IIFE)
// 这会为每一次循环迭代创建一个新的作用域气泡。
(function (j) {
setTimeout(function timer() {
// timer 现在闭包了 'j',它在本次迭代的作用域中是唯一的
console.log(j);
}, j * 1000);
})(i); // 传入当前的 'i' 值
}
(注意:在现代 JavaScript 中,你只需把 for 循环头部的 var i 改为 let i,因为 let 会为每一次迭代自动创建一个新的块级作用域。这里同时提及两种写法是为了展示历史背景和现代用法。)
摘要
不要死记硬背代码片段。要记住模型。
- Scope(作用域) 关注变量在何处可访问,由写入时(词法上)决定。可以把它想象成办公楼的楼层。
- Closure(闭包) 关注变量何时可访问。函数即使在以后执行,也会记住其词法作用域。可以把它想象成背包。
在面试中查看代码时,脑中追踪作用域线。自问:
- “这个变量属于哪个作用域桶?”
- “这个函数的背包里装的是什么?”
这样做,你不仅能通过面试,还能真正理解你每天使用的语言。