关注「索引目录」公众号,获取更多干货。
如果你是从其他语言转而学习 JavaScript,你可能会感到困惑。为什么 JavaScript 有三个不同的关键字来声明变量?一个关键字难道不够吗?
答案并非仅仅是“var 已经过时,请改用 let/const”。这些关键字与 JavaScript 执行模型的交互方式存在根本差异——具体来说,就是引擎创建和管理执行上下文和词法环境的方式不同。
让我们深入探讨一下,当你声明一个变量时,究竟发生了什么。
JavaScript 运行你的代码时究竟发生了什么
在讨论 `get` var、let`get` 或 ` get` 之前const,我们需要了解 JavaScript 如何准备运行你的代码。
当 JavaScript 进入任何可执行上下文(函数、代码块或全局代码)时,它会经历两个阶段:
- 创建阶段
:设置环境 - 执行阶段
:实际逐行运行您的代码
在创建阶段,JavaScript 会创建一个称为词法环境(Lexical Environment)的东西。可以把它想象成一个数据结构,它由以下部分组成:
- 环境记录
:存储变量和函数的地方。 - 外部引用
:指向父级词汇环境的链接(用于作用域链)
关键在于:var,,let并且const与这些结构的交互方式完全不同。
变量:功能级环境记录
让我们来追踪一下发生了什么var:
function checkAge() {
console.log(age); // undefined
var age = 25;
console.log(age); // 25
}
创建阶段(任何代码运行之前):
-
JavaScript 会为函数环境记录创建一个函数环境记录。 checkAge -
它会扫描整个函数体,查找 var声明。 -
对于每个 var,它在环境记录中创建一个属性并将其初始化为undefined -
该赋值语句( = 25)尚未执行。
因此,内部环境记录如下所示:
FunctionEnvironmentRecord {
age: undefined // created during setup
}
执行阶段:
console.log(age)- 摘自《环境记录》→ undefinedage = 25- 更新环境记录中的现有属性 console.log(age)- 摘自《环境记录》→ 25
这就是var“提升”的原因——并不是声明向上移动,而是变量从函数执行之初就存在于环境记录中。
块作用域问题:为什么 var 会忽略块
function test() {
if (true) {
var x = 10;
}
console.log(x); // 10
}
以下是其内部运作原理:
创建阶段:
-
JavaScript 会为整个函数创建一个函数环境记录。 test -
它在方块 var x内部。if -
它会添加 x: undefined到功能环境记录中。 -
该 if模块不会创建自己的环境记录(对于var)
执行阶段:
-
进入 if方块 x = 10更新 x函数环境记录-
退出 if阻塞(但环境记录保留——它是函数的记录) console.log(x)从同一函数环境记录读取 → 10
规范中并没有规定在使用代码块时,代码块本身就拥有环境记录var。代码块只是控制流程,它们不会创建新的作用域。
循环之谜:为什么所有回调函数都共享同一个变量
接下来才是真正有趣的部分:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3
让我们一步一步地追踪执行过程:
会发生什么:
-
循环初始化会 i在函数环境记录中创建(如果没有函数,则在全局环境中创建)。 -
第一次迭代: i = 0setTimeout 会安排一个回调函数 -
第二次迭代: i = 1setTimeout 会安排另一个回调函数 -
第三次迭代: i = 2setTimeout 会安排另一个回调函数 -
循环条件检查: i = 3条件不成立,循环退出。 -
大约 100 毫秒后,回调函数执行
为什么它们都打印了3:
每个箭头函数都会创建一个闭包。闭包是一个函数加上对创建该函数的词法环境的引用。这三个箭头函数都对同一个词法环境(包含循环的词法环境)进行闭包。
当回调函数执行时,它们会i从共享的环境记录中读取数据。此时,循环已经i结束3。
视觉上:
Outer Lexical Environment:
EnvironmentRecord: { i: 3 } // Final value after loop completes
Callback 1: () => console.log(i) ──┐
Callback 2: () => console.log(i) ──┼─→ All reference the same environment
Callback 3: () => console.log(i) ──┘
内存中只有一个 i变量,所有回调函数共享该变量。
let 和 const:块级环境记录
现在,JavaScript 的行为从根本上发生了变化:
{
let x = 10;
}
console.log(x); // ReferenceError
会发生什么:
let当 JavaScript 遇到带有 ` <br>` 或 `<br>` 的代码块时const,它会创建一个新的词法环境,并拥有自己的环境记录(称为代码块的声明式环境记录)。
创建阶段(进入区块):
-
为该代码块创建一个新的词法环境 -
查找该代码块中的所有 let/const声明 -
在环境记录中创建未初始化的绑定 -
将外部引用设置为父环境
执行阶段:
-
当代码执行到此处时 let x = 10;,绑定将被初始化。 -
退出代码块后,词法环境将被丢弃(如果没有闭包引用它)。
关键区别在于:该变量从一开始就存在于环境记录中(已注册),但在初始化之前访问它会引发错误。
时间死区:不仅仅是一条规则,而是一种状态
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
事情的真相是这样的:
当 JavaScript 进入作用域时,它会x在环境记录中创建一个绑定,但该绑定处于一种特殊状态:未初始化。这与undefined.
在内部,环境记录可能如下所示:
DeclarativeEnvironmentRecord {
x: <uninitialized> // Special internal state, not undefined
}
如果你尝试读取数据x,JavaScript 会检查:“此绑定是否已初始化?” 如果没有,它会抛出一个 ReferenceError 异常。这就是 TDZ——绑定创建到初始化之间的时间差。
当执行到 时let x = 5;,绑定从 转换<uninitialized>到5。
它存在的意义是什么?
设想以下情景:
let x = x + 1; // ReferenceError
如果没有 TDZ,x右侧会是什么?undefined这会悄无声息地造成错误(undefined + 1 = NaN)。TDZ 会强制报错,使错误显而易见。
循环修复:每次迭代创建新环境
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2
这里规范做了一件非常巧妙的事情。let在循环中for,JavaScript 会为每次迭代创建一个新的词法环境。
以下是实际流程:
迭代 0:
-
创建一个新的词汇环境: { i: 0 } -
在此环境中执行循环体 setTimeout回调函数会关闭此环境
迭代 1:
-
创建一个新的词汇环境: { i: 1 } -
在此环境中执行循环体 setTimeout回调函数会在这个新环境中关闭。
迭代 2:
-
创建一个新的词汇环境: { i: 2 } -
在此环境中执行循环体 setTimeout回调函数会在这个新环境中关闭。
视觉上:
Environment 0: { i: 0 } ← Callback 1 references this
Environment 1: { i: 1 } ← Callback 2 references this
Environment 2: { i: 2 } ← Callback 3 references this
每个回调函数都有自己的i环境变量,因为每次迭代都会创建一个独立的词法环境。这在 ECMAScript §13.7.4.8 中有明确规定——该规范明确指出要创建一个新的环境并将循环变量复制到其中。
这与以下情况不同:
let i = 0;
// Create environment 0
i = 1;
// Create environment 1
i = 2;
// Create environment 2
那只是对同一个变量进行重新赋值。规范明确地创建新的绑定。
const:环境记录中的不可变绑定
const x = 10;
x = 20; // TypeError: Assignment to constant variable
引擎层面发生了什么?
当你声明时const x = 10,JavaScript 会在环境记录中创建一个绑定,但将其标记为不可变。在内部,这可能表示为:
DeclarativeEnvironmentRecord {
x: {
value: 10,
mutable: false // Internal flag
}
}
当你尝试这样做时x = 20,JavaScript 会检查该mutable标志。如果该标志为 false false,则会抛出一个 TypeError 异常。
重要提示:这里指的是绑定,而不是值:
const obj = { count: 1 };
obj.count = 2; // OK
obj = {}; // TypeError
绑定obj是不可变的(你不能让它指向其他对象),但它指向的对象仍然是可变的。环境记录只控制绑定,而不控制堆分配的对象。
奇异的互动:阴影与重新宣告
let x = 1;
{
var x = 2; // SyntaxError: Identifier 'x' has already been declared
}
为什么在解析时(执行前)会失败?
当 JavaScript 解析这段代码时,它会看到:
let x- 将在外部环境中形成结合 var x- 尝试在……等等,在哪儿创建绑定?
var由于是函数作用域的,它会尝试在与外部函数相同的x环境记录中创建。但规范规定同一作用域内不能有重复的词法声明。解析错误。let x
但这样做可行:
var x = 1;
{
let x = 2; // OK
console.log(x); // 2
}
console.log(x); // 1
因为在代码块的let x环境记录中创建了一个绑定,这与函数/全局环境记录所在的环境记录不同。var x
环境链如下所示:
Block Environment:
Record: { x: 2 }
Outer → Function/Global Environment:
Record: { x: 1 }
x在代码块内部访问时,JavaScript 首先会在最内层环境中查找(查找 `this` 2)。在代码块外部,它会在函数/全局环境中查找(查找 `this` 1)。
性能如何?
你可能会想:如果let每次循环迭代都创建新的环境,那岂不是会更慢吗?
现代 JavaScript 引擎(V8、SpiderMonkey、JavaScriptCore)对此进行了深度优化。如果循环变量没有被闭包捕获,引擎实际上不会创建单独的环境——它足够智能,能够识别出该变量未被共享。
额外的环境只有在语义上必要的时候才会存在(当闭包捕获它们时)。
摘要:关键在于词汇环境
var`<script>`、let`<script>` 和` <script>` 之间的真正区别const不在于语法,而在于它们如何与 JavaScript 的词法环境系统交互:
- var
:在函数环境记录中创建绑定,立即初始化 undefined,函数作用域 - let
:在块环境记录中创建绑定,从 TDZ 开始,块级作用域,每次循环迭代一个环境 - const
:与 相同 let,但绑定被标记为不可变。
理解这一点有助于预测行为和调试问题。当您遇到奇怪的作用域错误时,可以追踪环境的创建过程,并准确查看变量所在的位置。
下次当你编写let循环代码时,你就会知道 JavaScript 在幕后创建了单独的环境——以及为什么这对闭包很重要。
关注「索引目录」公众号,获取更多干货。

