JavaScript 作用域与闭包:停止死记硬背,开始真正理解
说实话。当你走进 JavaScript 技术面试时,面试官寻找的不是一个能从 Stack Overflow 复制粘贴的人。他们寻找的是理解机器如何工作的人。
有两个概念比其他任何概念都更容易让开发者栽跟头:作用域和闭包。
大多数开发者对它们只有一种模糊的、只能靠比划来解释的理解。他们知道它能工作,但不知道为什么。在面试中,这种模糊的理解是行不通的。当你无法解释为什么一个变量是 undefined,或者为什么一个循环打印出错误的数字时,你就无法通过测试。
要掌握这些概念,你必须停止把 JavaScript 当作一个黑盒子,并开始像 JavaScript 引擎一样思考。
以下是你需要内化的心智模型。
第一部分:作用域是一套规则
开发者犯的第一个错误是认为作用域是变量存放的物理"位置"。
更好的理解是,将作用域视为 JavaScript 引擎使用的一套规则,用于根据标识符名称来查找变量。
JavaScript 使用一种叫做词法作用域的东西。这是你今天读到的最重要的术语。"词法"与编译的词法分析或解析阶段有关。
通俗地说: 作用域是由你(代码作者)物理上编写代码的位置决定的。你的函数嵌套方式决定了你的作用域的嵌套方式。一旦编写完成,这些规则在代码运行之前就(基本)固定不变了。
心智模型:办公楼
想象你的程序是一栋多层办公楼。
- 一楼(大堂)是全局作用域。
- 你声明的每个函数都会在你当前所在的楼层之上创建一个新的、私有的楼层。
当引擎在函数内部(比如,在 3 楼)执行代码,并且它需要查找一个变量(比如 manager)时,它会遵循一个严格的程序:
- 本地查找: 三楼有自己的
manager吗?如果有,使用它。 - 向外查找: 如果没有,走下楼梯到二楼。他们有吗?
- 重复: 继续一次下一层楼,直到到达大堂(全局作用域)。
- 放弃: 如果大堂里也没有,抛出一个
ReferenceError。它不存在。
关键的面试注意点: 作用域查找只向上(向外)进行。它们从不向下(向内)进行。大堂看不到三楼发生的事情。
var buildingName = "JS Towers"; // 大堂 (全局)
function floorTwo() {
var manager = "Kyle"; // 二楼作用域
function floorThree() {
var developer = "You"; // 三楼作用域
// 引擎在这里(三楼)查找,没找到。
// 下到二楼,找到了 'manager'。成功。
console.log(manager);
}
}
第二部分:闭包并非魔法
如果作用域是用于查找的规则集,那么闭包就是你弯曲这些规则时发生的情况。
许多人对闭包的定义很模糊。让我们来精确地定义它。
闭包是这样一个现象:一个函数即使是在它的词法作用域之外被执行,它仍然能够访问定义时的那个作用域。
通常,当一个函数执行完毕后,它的作用域会被垃圾回收。内存被释放。闭包阻止了这种情况的发生。
心智模型:背包
如果你在函数 A 内部定义了函数 B,那么函数 B 会获得一个指向其外围函数 A 作用域的"隐藏链接"。
当函数 B 被传递到函数 A 之外,在你程序的其他地方使用时,它并非空手而去。它带走了那个隐藏链接。
这就像一个背包:函数 B 携带了一个背包,里面装着函数 B 被创建时,函数 A 中存在的所有变量。
无论函数 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();
// ... 几小时后 ...
// myRef 在全局作用域中执行。
// 然而,它仍然记得 'outer' 的作用域。
myRef(); // "The secret is: XYZ_123"
在面试中,不要只说"它记住了"。应该说:"由于闭包,inner 保留了对 outer 词法作用域的引用,阻止了该作用域被垃圾回收。"
第三部分:经典的面试陷阱
如果你在面试中被问到闭包,有 90% 的几率你会看到这个循环问题的变体。它旨在测试你是否理解作用域边界和值引用之间的区别。
问题:
for (var i = 1; i <= 3; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
初级开发者期望的结果: 分别打印 1、2、3,间隔一秒。
实际发生的情况: 分别打印 4、4、4,间隔一秒。
如何像专家一样解释:
- 识别作用域: "变量
i是用var声明的。这意味着它属于全局作用域(或者如果被包裹在函数中,则属于函数作用域),而不是循环块级作用域。" - 识别闭包: "
setTimeout内部的timer函数闭包了那个单一的、共享的全局变量i。" - 执行过程: "循环几乎瞬间就运行完了。当它结束时,
i的值是4(因为此时循环条件i <= 3不再满足)。" - 结果: "当三个 timer 函数在几秒钟后最终执行时,它们都查看自己的闭包背包,找到了对
i的完全相同的引用,并打印出它当前的值:4。"
修复方法(ES6 之前的 IIFE 模式):
你需要为循环的每次迭代创建一个新的作用域来"捕获" i 的当前值。
for (var i = 1; i <= 3; i++) {
// 创建一个立即执行函数表达式 (IIFE)
// 这为每次循环迭代创建了一个新的作用域气泡。
(function(j) {
setTimeout(function timer() {
// timer 现在捕获了 'j',这个 'j' 是本次迭代作用域所独有的
console.log(j);
}, j * 1000);
})(i); // 传入当前的 'i' 值
}
(注意:在现代 JS 中,你只需在 for 循环头中将 var i 改为 let i,因为 let 会自动为每次迭代创建一个新的块级作用域。同时提到两者,表明你了解历史背景和现代知识。)
总结
不要死记硬背代码片段。要记住模型。
- 作用域是关于变量在哪里可访问,在编写时(词法上)决定。想想办公楼的楼层。
- 闭包是关于变量何时可访问。一个函数即使在稍后执行时也能记住它的词法作用域。想想背包。
当你在面试中查看代码时,在心里追踪作用域线。问自己:"这个变量属于哪个作用域桶?"以及"这个函数在它的背包里携带了什么?"
这样做,你不仅能通过面试;你还会真正理解你每天使用的语言。
共同学习,写下你的评论
评论加载中...
作者其他优质文章