JavaScript 的作用域链究竟是如何解析变量的
解释了变量查找背后的词法作用域链机制、为何使用var会导致循环错误,以及闭包是如何由此自然产生的。
变量查找与花括号无关
人们通常最先了解作用域的方式是认为“作用域就是花括号里面的内容”。这种描述虽然能帮你通过测验,但无法帮助你预测代码的实际功能。真正的机制比这种简化的解释更为机械且可预测,一旦理解了它,闭包就不再显得像魔法一样。
作用域由代码的编写位置决定,而非执行方式
JavaScript是按词法顺序解析变量的。这意味着一个变量的作用域是在编写代码时根据其在源文件中的实际位置来确定的,而非由程序运行过程中哪个函数调用了另一个函数来决定。
const value = "outer";
function readValue() {
console.log(value);
}
function runWithDifferentValue() {
const value = "inner";
readValue(); // still logs "outer", not "inner"
}
runWithDifferentValue();
readValue 并不关心是谁调用了它,也不关注调用者环境中的变量是什么。它唯一在意的就是自己被定义的位置——就在 const value = „outer“ 旁边。无论最终从程序的哪个位置调用它,它都只能看到这个 value。这正是那些来自具有动态作用域语言的开发者(或者实际上,那些从未需要考虑过这个问题的人,因为几乎所有地方默认都是词法作用域)会感到困惑的地方。作用域在代码被编写时就已确定,不会随着运行时的调用栈而改变。
真正的查找机制:遍历作用域链
当引擎需要解析变量引用时,它不会搜索整个代码库。它会从该变量被使用的位置开始,逐个向外查找嵌套的作用域,一旦找到匹配项就会立即停止:
const a = "global";
function outer() {
const b = "outer";
function inner() {
const c = "inner";
console.log(a, b, c); // "global outer inner"
}
inner();
}
outer();
inner首先检查c,发现它就在当前作用域内,无需进一步查找。接着它检查b,由于该变量未在当前作用域中定义,搜索便扩展到outer的作用域,在那里找到了它。随后再检查a,由于该变量既不在inner作用域也不在outer作用域中,搜索便持续向外扩展,直到到达全局作用域才最终找到它。这种从内部作用域开始,依次向外查找直至全局作用域的流程,就是整个变量查找算法。其原理并不复杂,无非就是“先在这里查找,若未找到则再往上一层作用域查找,如此重复直至找到目标”。
同样的机制无需额外规则即可解释变量遮蔽现象。如果 inner 声明了自己的 const b,搜索会在找到该局部 b 时立即停止,根本不会触及 outer 中定义的 b。在这种情形下没有任何内容会被覆盖;只是因为更接近的匹配项先被找到,所以搜索没有继续的必要。
为何 var 的行为不同,以及这为何会导致一个众所周知的错误
let 和 const 会绑定到最近的封闭块,也就是任意一对大括号,无论是 if 语句还是循环体。而 var 完全不遵循这一规则,它反而会绑定到最近的封闭函数,完全忽略块边界。
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3
这段代码中恰好只有一个i变量,它属于函数作用域,循环的每次迭代都会使用同一个变量。当setTimeout设置的回调函数被触发时,循环早已结束,i的值也已经变为3。这三个回调函数都引用的是同一个变量,而非各自独立的副本,因此它们都会返回该变量最终持有的值。
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2
由于 let 具有块级作用域,且该语言会在每次循环迭代时为 i 创建新的绑定,因此每个回调最终都会绑定其自身独立的 i,该值会被冻结在对应迭代时的状态。这段代码与前面的示例看起来几乎相同,但底层的作用域规则不同,因此产生的结果也更为可预测。
闭包并非独立特性,只是作用域链的必然结果
一旦你理解了作用域链的运作原理,闭包就几乎无需额外解释。闭包并非叠加在作用域之上的某种附加机制,而只是每当你在另一个函数内部定义函数时自然而然会出现的现象——那个内部函数会在外部作用域本应被丢弃之后仍在某处被使用。
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
const debouncedSearch = debounce((query) => runSearch(query), 300);
debounce 只会执行一次。如果你习惯使用没有闭包的语言,可能会认为 debounce 执行完毕后 timeoutId 应该立即消失。但实际上它并不会消失,因为返回的 debounce 函数是在其内部定义的,这意味着该返回函数的作用域链会永久包含 debounce 自身的作用域,以及其中的 timeoutId。此后无论何时再次调用 debouncedSearch,都会沿着相同的作用域链找到同一个 timeoutId。这正是 debounce 逻辑能够生效的原因:需要有一个单一的、持久存在的变量来记录每次调用时待处理的超时任务,而闭包正是实现这一点的保障。
造就闭包的同一特性也可能导致内存泄漏
闭包之所以有用,恰恰是因为导致特定重复问题的同一特性:闭包会保留其整个外部作用域,而不仅仅是它实际引用的变量;同时,它持有的是对变量本身的活跃引用,而非在闭包创建时该变量值的冻结副本。
function createHandlers() {
let clickCount = 0;
const massiveDataset = loadHugeArray(); // large, no longer needed after setup
return {
onClick: () => {
clickCount++; // only this variable is actually used
console.log(clickCount);
},
};
}
onClick 的闭包捕获了属于 createHandlers 的整个作用域,包括 massiveDataset,尽管 onClick 实际上从未读取过它。只要 onClick 仍然存在,它所捕获的其他所有内容也会一直存在,这就构成了一个真正的内存问题——虽然通常影响不大——尤其是当某个长期存在的闭包持有对大型或不必要的数据的引用时。由于该闭包持有的是原始变量而非复制值,因此它始终能看到最新的状态。这与让防抖示例正常工作的原理相同,也是导致 var 循环输出 3, 3, 3 的原因,这是一种机制,在某种情境下是实用的功能,而在另一种情境下则会成为错误。
理解机制比记忆定义更重要
教材中“闭包是与其词法环境配对在一起的函数”这一表述在技术上是正确的,但除非你亲自多次追踪作用域链并观察查找过程究竟在何处停止,否则很难真正理解它。一旦追踪作用域链成为习惯,闭包就不再需要特别解释了——它们只不过是作用域机制一直以来所具有的普通结果,只是应用到了那些生命周期比其创建环境更长的函数上而已。
相关阅读
- Node.js 对 TypeScript 的原生支持究竟能做什么和不能做什么 —— 本文解释了 Node.js 如何通过类型剥离来原生运行 .ts 文件、为何会跳过类型检查,以及何时仍需要真正的构建步骤。