先看一段大多数人第一眼都会答错的代码:
javascript复制var x = 10;
function foo() {
console.log(x);
}
function bar() {
var x = 20;
foo();
}
bar(); // 输出多少?
如果你凭直觉回答是 20,那你其实还没真正掌握 JavaScript 的作用域规则;如果回答是 10,但说不出为什么,那这篇内容正好适合你。
JavaScript 使用的是词法作用域(Lexical Scoping),这决定了变量的“归属权”在代码书写阶段就已经确定,跟函数在哪里调用没有关系。很多前端人在闭包、变量提升、循环里用 setTimeout 打印索引这类问题上反复踩坑,根子都在于对词法作用域和作用域链的理解停留在“好像懂了”的层面。
这篇文章我会把词法作用域、作用域链、声明提升、暂时性死区、闭包、块级作用域,以及现代引擎的优化逻辑串起来讲清楚。不只是背概念,而是把“为什么这么设计”“执行时底层做了什么”都说透,适合刚入门不久的开发者,也适合写了几年 JS 但总觉得某些行为“诡异”的工程师。
1. 一段经典面试题暴露出的认知分水岭
先拿一个前端社区几乎人人都见过的题目开刀:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
这个代码段的输出是 3 3 3,而不是 0 1 2。如果换成 let 声明:
javascript复制for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
输出就变成了 0 1 2。
很多人第一次遇到这个现象时,只记住了一句口诀:“用 let 就行,var 不行。”但这个口诀没有解释任何东西——为什么声明方式不同,输出结果会有这么大差异?
问题的本质恰恰落在词法作用域与作用域链上。
用 var 声明时,循环体内并没有构成一个独立的块级作用域,三次迭代共享同一个 i。setTimeout 里的箭头函数虽然写法上在循环体内,但它真正执行发生在 100ms 之后,那时循环早就跑完,i 已经变成 3。三个回调函数顺着各自的作用域链往外找 i,找到的是同一个变量,于是全是 3。
用 let 声明时,情况完全不同。let 支持块级作用域,而且规范要求每一轮循环都会创建一个新的词法环境,把当前这一轮的 i 值绑定进去。三个回调函数创建时分别捕获了各自那一轮的环境,虽然执行时机仍在之后,但作用域链上记录的已经是被冻结在各自迭代里的值。
也就是说,是否输出 0 1 2,关键不在于“let 比 var 聪明”,而在于每一轮循环是否为回调函数创建了独立的作用域链捕获点。
理解到这一层,你就已经踩在正确理解 JavaScript 作用域机制的门口了。接下来要解决的核心问题变成:这段变量到底归谁管?这个归属是何时确定的?函数在执行时又是沿着一条什么样的链去找到变量的?
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 词法作用域:这段变量归谁管,在写代码的那一刻就已注定
2.1 词法作用域的定义与“词法”二字的含义
词法作用域,也叫静态作用域,意思是:变量的作用域是由代码在源代码中出现的物理位置决定的。更直白一点,变量能被哪些代码访问,在解析器读到你写的代码时就已经固定了,跟程序运行时的调用关系完全无关。
为什么叫“词法”?因为 JavaScript 引擎在运行代码之前会先做词法分析,把字符流拆成一个个有意义的词法单元。在拆解过程中,引擎就能确定每个函数嵌套在哪个作用域里,从而确定标识符的解析路径。这个阶段发生在任何一行代码执行之前。
看一下这个更直接的例子:
javascript复制const a = 'global';
function outer() {
const a = 'outer';
function inner() {
console.log(a);
}
inner();
}
outer(); // 输出 'outer'
这里 inner 在 outer 内部定义,所以它的作用域链是 inner -> outer -> 全局。即使我们在全局也有一个 a,inner 往外查找时先经过 outer,找到 outer 里的 a 就停下来,于是输出 outer。
这个结果没有任何争议。真正让初学者迷惑的是下面这个变体:
javascript复制const a = 'global';
function outer() {
const a = 'outer';
function inner() {
console.log(a);
}
return inner;
}
const fn = outer();
fn(); // 输出什么?
注意,这里 inner 被返回到了全局,并在全局环境下通过 fn() 调用。很多人会想:inner 都已经离开 outer 了,现在在全局运行,是不是应该访问全局的 a,也就是输出 global?
答案依然是 outer。
因为 inner 的作用域链在定义那一刻就已经确定:它的外层是 outer 的作用域,不是调用时的全局作用域。函数走到哪儿,作用域链跟到哪儿。这也是闭包概念里最核心、也最反直觉的地方。
2.2 对比动态作用域:为什么 JS 不采用另一种模型
为了把词法作用域的重要性讲透,有必要提一下另一种模型:动态作用域。
在动态作用域的语言里(比如早期的 Lisp 方言、Perl 的某些默认配置、Bash 脚本),函数查找变量不是看自身定义在哪里,而是看调用链上每个函数的局部变量。用一个 Bash 版例子来说明:
bash复制var=global
function foo() {
echo $var
}
function bar() {
local var=bar
foo
}
bar
这段脚本输出的是 bar。为什么?因为 foo 在 bar 中被调用,动态作用域会把调用栈里最近一层的 var 找出来,也就是 bar 的局部变量 var=bar。
如果把这段逻辑换成 JavaScript 语法,大家直觉上最容易得出的答案就是这种动态作用域规则——函数在哪里被调用,就往哪里的局部作用域去找。但 JS 不是这么设计的,现代主流语言也几乎都采用词法作用域,原因非常实际:
- 可预测性:词法作用域下,看到一段代码就能判断变量指向哪里,不需要追踪运行时调用栈。
- 静态分析友好:编辑器、LSP、代码检查工具可以在不执行代码的情况下进行变量引用分析、类型推断、自动补全。
- 编译优化空间:引擎可以在执行前确定作用域链结构,提前完成变量访问优化。
动态作用域虽然在某些场景下写起来“方便”,但调试噩梦远远大于收益。JavaScript 从一开始就选择了词法作用域,并且坚持至今,这个选择深刻影响了闭包、模块化、执行上下文等几乎所有核心机制。
2.3 词法作用域的“嵌套”本质
词法作用域本质上是一棵静态的树。
全局作用域是根节点;每个函数、每个块(ES6 之后)都是树上的节点;子节点可以访问父节点上的变量,但反过来不行。这种嵌套关系与函数的调用时间完全无关,纯粹由代码的书写结构决定。
javascript复制const gl
