var/let/const:三种绑定的规范级差异与 TDZ
现代 JavaScript 的声明原则:默认 const,需要重赋值时用 let,不用 var
var、let、const 不只是"新旧语法的区别",它们在 ECMAScript 规范层面对应三套不同的绑定创建机制、不同的提升行为、不同的作用域规则。理解这三者的差异,能够解释一大类令人困惑的运行时行为——包括为什么 for 循环里的 let 能解决闭包陷阱、为什么 typeof 对 TDZ 变量报错、以及为什么 const 声明的对象属性仍然可以修改。
三种声明对应的工程实践规则是明确的:const 是默认选择,因为它最严格、意图最清晰;let 用于需要重新赋值的场合;var 只在维护历史代码时出现。这不是风格问题,是可预测性问题——var 的函数作用域和无限制提升会制造隐藏的共享状态,在现代代码库里这几乎永远是 bug 的温床。
从规范演进的视角来看,var 是 JavaScript 1.0(1995 年)的唯一声明方式,存在至今仅是向后兼容。let 和 const 是 ES2015(2015 年)引入的,弥补了 var 二十年来在工程实践中暴露出的所有设计缺陷。理解这个历史背景,能够帮助你在遇到遗留代码时快速识别潜在的 var 相关 bug,并在代码审查中做出正确的修改建议。
🔹 Level 1 · 你需要知道的
const:绑定不可变,值不一定不可变
const 是现代 JavaScript 里最常用的声明方式。它声明一个不可重新赋值的绑定(binding)。理解"绑定不可变"和"值不可变"的区别非常重要:绑定是变量名指向值的连接,const 阻止的是"把这个名字指向另一个值(重新赋值)",而不是"修改这个名字指向的对象的内容(改变属性)":
const x = 42;
x = 100; // TypeError: Assignment to constant variable.
const arr = [1, 2, 3];
arr.push(4); // 正常:修改数组内容,不改变 arr 指向的对象
console.log(arr); // [1, 2, 3, 4]
arr = []; // TypeError:试图把 arr 指向另一个对象
const obj = { name: 'Alice' };
obj.name = 'Bob'; // 正常:修改对象属性
obj = {}; // TypeError:试图重新赋值
const 必须在声明时初始化,不能先声明后赋值:
const y; // SyntaxError: Missing initializer in const declaration
const z = 1; // 正确
let:块级作用域,可重新赋值
let 是 const 的"可重新赋值"版本。当一个变量的值需要在生命周期内改变时(比如循环计数器、累加器、状态机的当前状态),使用 let。它与 const 共享块级作用域和 TDZ 机制,区别只在于可以重新赋值。
let count = 0;
count = 1; // 正常
for (let i = 0; i < 3; i++) {
// 每次迭代,i 是独立的绑定
setTimeout(() => console.log(i), 0);
}
// 输出:0, 1, 2(不是 3, 3, 3)
// 对比 var:
for (var j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}
// 输出:3, 3, 3(所有闭包共享同一个 j)
var:函数作用域,提升,不要用
var 的函数作用域意味着它无视所有的 {} 块边界,只认函数边界(或全局边界)。在 if 块、for 块、while 块里用 var 声明的变量,都会"泄漏"到外层函数的作用域里,即使那个块执行完了,变量还在。这种泄漏是 JavaScript 早期代码中大量 bug 的根源。
function example() {
console.log(x); // undefined(var 提升,但未赋值)
if (true) {
var x = 1; // var 不被 {} 限制,x 属于 example 函数
}
console.log(x); // 1
}
example();
console.log(typeof x); // 'undefined'(x 不在全局作用域)
var 的声明会被提升到函数顶部(或全局),但赋值不会提升。这制造了"变量在声明前存在但值是 undefined"的混乱状态。
三种声明在全局作用域的不同行为
var a = 1;
let b = 2;
const c = 3;
console.log(window.a); // 1 ← var 挂到了 window(全局对象)
console.log(window.b); // undefined ← let 不挂到 window
console.log(window.c); // undefined ← const 不挂到 window
🔸 Level 2 · 它是怎么运行的
三种绑定的规范级差异对比
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数(或全局) | 块 | 块 |
| 提升行为 | 提升 + 初始化为 undefined | 提升 + TDZ(未初始化) | 提升 + TDZ(未初始化) |
| 重复声明 | 允许(同一作用域) | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 必须初始化 | 否 | 否 | 是 |
| 全局时挂到 globalThis | 是 | 否 | 否 |
| TDZ | 无 | 有 | 有 |
| 在 for 循环中的行为 | 共享单一绑定 | 每次迭代新建绑定 | 不能在 for 中重赋值(编译错误) |
| delete 可删 | 否(global var 也不行) | 否 | 否 |
| 规范对应的 ER 类型 | GlobalER[[ObjectRecord]] / FunctionER | DeclarativeER | DeclarativeER(immutable) |
for 循环中 let 的每次迭代新建绑定机制
for 循环里 let 的行为是 ES2015 规范设计中最精妙的细节之一,它彻底解决了困扰 JavaScript 开发者多年的"循环闭包陷阱"。理解它的机制,需要明确:for (let i = 0; i < 3; i++) 不是一个简单的块级作用域——规范专门为 for 循环的 let 定义了每次迭代创建新环境的算法(CreatePerIterationEnvironment),其行为比块级作用域更复杂。
for (let i = 0; i < 3; i++) 在规范层面会为每次迭代创建一个全新的 DeclarativeEnvironmentRecord:
第 0 次迭代:
创建新 ER₀,绑定 i = 0
执行循环体(此时闭包捕获的是 ER₀ 里的 i)
迭代结束:读出 i 的值(0),执行 i++,得到 1
第 1 次迭代:
创建新 ER₁,绑定 i = 1(从上一次迭代复制过来)
执行循环体(闭包捕获 ER₁ 里的 i)
迭代结束:读出 i 的值(1),执行 i++,得到 2
第 2 次迭代:
创建新 ER₂,绑定 i = 2
执行循环体
迭代结束:i++ → 3,不满足 i < 3,循环结束
这就是为什么 for (let i=0; i<3; i++) setTimeout(()=>console.log(i)) 输出 0, 1, 2:每次迭代的闭包捕获的是不同的 ER 里的不同的 i。
┌─────────────────────────────────────────────────────────────┐
│ for 循环的 ER 与闭包关系示意 │
│ │
│ for 循环的外层 ER(forBodyEnv) │
│ ┌──────────────────┐ │
│ │ forBodyEnv │ │
│ │ i: [每次迭代覆盖] │ │
│ └──────────────────┘ │
│ ↑ 每次迭代创建新的 iterationEnv │
│ │
│ 迭代 0: iterationEnv₀ { i: 0 } ←── 闭包₀ 的 [[Env]] │
│ 迭代 1: iterationEnv₁ { i: 1 } ←── 闭包₁ 的 [[Env]] │
│ 迭代 2: iterationEnv₂ { i: 2 } ←── 闭包₂ 的 [[Env]] │
│ │
│ setTimeout 异步执行时: │
│ 闭包₀ 访问 iterationEnv₀.i = 0 → 输出 0 │
│ 闭包₁ 访问 iterationEnv₁.i = 1 → 输出 1 │
│ 闭包₂ 访问 iterationEnv₂.i = 2 → 输出 2 │
└─────────────────────────────────────────────────────────────┘
TDZ 的完整生命周期
TDZ(Temporal Dead Zone,暂时性死区)是 ES2015 引入 let/const 时一并引入的概念。它解决了一个规范设计问题:let/const 绑定需要在进入块时就"存在"(否则块内其他代码无法区分该变量是块内变量还是外层变量),但又不能像 var 那样在未赋值时返回 undefined(这会让"变量未初始化"这个错误状态变得静默)。TDZ 的解决方案是:绑定存在,但处于"未初始化"状态,任何读取或写入都会报 ReferenceError,直到执行到声明语句完成初始化。
TDZ(Temporal Dead Zone,暂时性死区)描述的是 let/const 绑定从"创建"到"初始化"之间的时间段:
块作用域的生命周期:
进入块 {}
↓
所有 let/const 声明的绑定被"提升"(创建 uninitialized 状态的绑定)
↓
← TDZ 开始(此时访问这些变量会抛 ReferenceError)
↓
... 执行块内代码 ...
↓
执行到 let/const 声明行
↓
执行初始化表达式(如果有)
↓
← TDZ 结束(绑定状态从 uninitialized → initialized,此后可正常访问)
↓
... 块内代码继续 ...
↓
退出块 {}
↓
ER 从作用域链移除(绑定变为不可访问)
{
// TDZ 区域开始
// console.log(x); // ReferenceError,TDZ 中
let x = 10; // TDZ 结束,x 初始化为 10
console.log(x); // 10,正常访问
}
typeof 对 TDZ 变量的行为
typeof 操作符有一个广为人知的特殊豁免行为:对完全未声明的变量使用 typeof 会返回字符串 'undefined',而不是抛出 ReferenceError。这个豁免让 typeof 成为"安全地检测全局变量是否存在"的工具,在加载多个脚本的场景(如检测 jQuery 是否加载)里被大量使用。然而,这个豁免对 TDZ 变量不适用——对 let/const 声明的变量,在其声明之前使用 typeof 同样会抛出 ReferenceError:
// 未声明变量:typeof 安全返回
console.log(typeof undeclaredVar); // 'undefined'(不报错)
// TDZ 变量:typeof 也报错
{
console.log(typeof x); // ReferenceError!(不是 'undefined')
let x = 1;
}
这是因为规范在执行 typeof 时,如果操作数是一个 Reference Record 且 [[Base]] 是 unresolvable(未声明变量),才走"返回 undefined"的特殊路径。但对 TDZ 变量,GetIdentifierReference 找到了 x 的绑定(状态 uninitialized),Reference Record 的 [[Base]] 是一个真实的 ER(不是 unresolvable),然后 typeof 对这个 Reference Record 调用 GetValue,GetValue 调用 GetBindingValue,后者发现绑定未初始化,抛出 ReferenceError。
const 与 Object.freeze 的关系
const 和 Object.freeze 是两个相关但完全独立的概念,解决的是不同层次的"不变性"问题。const 解决的是绑定层的不变性——这个变量名不能被重新赋值;Object.freeze 解决的是对象层的不变性——这个对象的属性不能被修改。两者可以组合使用,但都有局限性:const 对值的内部结构没有任何约束,Object.freeze 只冻结最外层属性,嵌套对象不受影响。
在函数式编程范式里,不可变数据(immutable data)是核心原则之一。JavaScript 原生支持的不可变机制很有限,这就是为什么 Immutable.js、Immer 等库在 React 生态里被广泛使用——它们提供了高效的持久化数据结构,能够在"修改"数据时高效地创建新版本,而不是深拷贝整个对象树。
const 只保证绑定不可重新赋值,不保证值不可变。深度冻结对象需要递归使用 Object.freeze:
const obj = Object.freeze({ a: 1, b: { c: 2 } });
obj.a = 10; // 静默失败(严格模式下 TypeError)
obj.b.c = 20; // 可以!b 是对象,b 本身没有被冻结
// 深度冻结
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = obj[name];
if (typeof value === 'object' && value !== null) {
deepFreeze(value); // 递归冻结子对象
}
});
return Object.freeze(obj);
}
const frozen = deepFreeze({ a: 1, b: { c: 2 } });
frozen.b.c = 20; // 严格模式下 TypeError,非严格模式静默失败
🔺 Level 3 · 规范怎么定义的
规范 14.3:Declarations and the Variable Statement
ECMAScript 规范第 14.3 节(Declarations and the Variable Statement)是理解变量声明处理流程的权威来源。这一节定义了两套平行的机制:var 的"VarScoped"处理流程,以及 let/const 的"LexicallyScoped"处理流程。两套机制在代码实例化(InstantiateFunctionObject、InstantiateModuleObject 等)时被分别执行。
规范 14.3 节(及其子节)定义了变量和函数声明的处理方式:
VarDeclaredNames 和 VarScopedDeclarations:这两个抽象操作在函数/脚本实例化时收集所有 var 声明的名称,然后在当前 VariableEnvironment 中创建绑定。var 绑定通过 CreateMutableBinding(name, false)(D=false,不可删除)创建,并立即通过 InitializeBinding(name, undefined) 初始化为 undefined。
LexicallyDeclaredNames 和 LexicallyScopedDeclarations:收集所有 let/const/class 声明。这些通过 CreateMutableBinding(let)或 CreateImmutableBinding(const)创建,但不立即调用 InitializeBinding——这就是 TDZ 的物理实现。
var 的函数级提升机制:
FunctionDeclarationInstantiation 算法(简化):
1. 收集形参并创建绑定(初始化为实参值)
2. 调用 InstantiateOrdinaryFunctionObject 处理内层函数声明
3. 对每个 VarDeclaredName:
a. 调用 varEnv.HasBinding(name)
b. 如果没有 → 创建绑定 + InitializeBinding(name, undefined)
c. 如果有(同名函数声明已创建)→ 跳过(不覆盖)
4. 执行函数体
ForStatement 的 per-iteration scope 创建算法
规范对 for 循环 let 绑定的处理是所有绑定规则里最复杂的之一,它需要在性能(不创建无谓的 ER)和正确性(每次迭代独立的绑定)之间取得平衡。规范定义了 CreatePerIterationEnvironment 操作来实现这个平衡:只为参与迭代的变量(在 for 的第一部分声明的 let/const 变量)创建新 ER,不为循环体里的其他变量创建额外的 ER。
规范对 for (let/const init; test; update) body 的执行定义了 CreatePerIterationEnvironment 操作:
CreatePerIterationEnvironment(iterationVarNames):
1. 取当前运行的 EC 的 LexicalEnvironment 作为 lastIterationEnv
2. 创建新的 DeclarativeEnvironmentRecord,其 [[OuterEnv]] 指向 lastIterationEnv.[[OuterEnv]]
3. 对每个 iterationVarName:
a. 创建可变绑定(在新 ER 里)
b. 读出 lastIterationEnv 里该名称的当前值
c. 用该值初始化新 ER 里的绑定
4. 把当前 EC 的 LexicalEnvironment 设置为新建的 ER
这个算法精确描述了"每次迭代复制当前值到新 ER"的行为:第 0 次迭代结束时 i=0,第 1 次迭代开始时新 ER 被创建,绑定 i 被初始化为 0,然后 i++ 把这个新 ER 里的 i 改为 1……
关键细节:新 ER 的 [[OuterEnv]] 指向 lastIterationEnv.[[OuterEnv]](跳过了上一个迭代 ER),不是直接指向 lastIterationEnv。这确保了迭代之间的 ER 不会形成链式引用(每次迭代的 ER 在下一次迭代开始后,如果没有闭包持有它,就可以被 GC)。
CreateImmutableBinding 与 const 的规范语义
const 在规范层面的实现方式揭示了为什么重新赋值会抛出 TypeError 而不是 SyntaxError:const 的不可变性是在运行时由环境记录的方法强制实现的,而不是在编译时静态拒绝的。虽然在实践中,JavaScript 引擎(包括 V8 和 SpiderMonkey)会在解析阶段(语法分析时)就检测到对 const 绑定的赋值并报告为 SyntaxError,但规范本身定义的是运行时的 TypeError。这个区别在极端情况下(如通过 eval 动态执行代码)可能会有所不同。
const 声明使用 CreateImmutableBinding(N, S) 创建绑定,S=true(严格):
规范对 DeclarativeEnvironmentRecord.SetMutableBinding 的要求:如果绑定是通过 CreateImmutableBinding 创建的(标记为 immutable),调用 SetMutableBinding 时(即赋值操作)会检查 S 参数——如果 S=true,抛出 TypeError。
const 声明的处理流程:
- 解析阶段:检测到
const声明,记录为 LexicallyScopedDeclaration - 进入块时:调用
CreateImmutableBinding(name, true)(创建不可变绑定,TDZ 状态) - 执行到声明行:计算初始化表达式,调用
InitializeBinding(name, value)(TDZ 结束) - 后续赋值:调用
SetMutableBinding(name, newValue, true)→ 检测到 immutable → TypeError
💎 Level 4 · 边界与陷阱
陷阱 1:var 同名重复声明不报错,最后赋值生效
var 允许在同一作用域内对同一个名字声明多次,这是 JavaScript 的历史设计缺陷之一。在 ES3 和 ES5 时代,这个特性被认为是"灵活",但实际工程中它几乎只会造成意外覆盖和难以追踪的 bug。ESLint 的 no-redeclare 规则和 TypeScript 的严格模式都会报告这个问题:
function example() {
var x = 1;
var x = 2; // 不报错!重复声明被忽略,只有赋值生效
var x; // 也不报错,也没有效果(绑定已存在,不重复初始化)
console.log(x); // 2
}
// 更隐蔽的版本
function confusing() {
console.log(x); // undefined(var 提升)
for (var i = 0; i < 3; i++) {
var x = i; // 这个 var x 只在第一次执行时"有效"(后续是赋值)
}
console.log(x); // 2(最后一次迭代的 i=2)
console.log(i); // 3(循环结束后 i 仍可访问)
}
规范行为:在函数实例化阶段,VarDeclaredNames 算法对重复的 var 声明只创建一个绑定。运行时遇到 var x = value 时,只执行赋值部分,不重新创建绑定,也不重置为 undefined。
陷阱 2:let 的 TDZ 遮蔽了外层同名变量
这是 Level 1 提到过的场景,这里深入展示其工程影响:
const DEBUG = true; // 外层全局变量
function configure(options) {
if (options.debug) {
// 假设你想在 if 块内重新声明 DEBUG 为本地变量
console.log(DEBUG); // ReferenceError! 内层 let 的 TDZ 遮蔽了外层 const DEBUG
let DEBUG = options.debug;
console.log(DEBUG); // 这里才能用
}
}
这个陷阱在重构代码时特别容易触发:把外层变量的名字和内层新增的 let 声明弄重了,TDZ 就会在运行时悄悄把原本"应该能访问外层"的代码变成 ReferenceError。
识别方法:在代码审查中,对任何 let/const 声明之前的同名引用要特别警惕——不是"外层作用域的值",而是 TDZ。
陷阱 3:typeof 对 TDZ 不是安全的
许多开发者把 typeof x !== 'undefined' 作为"安全的变量存在性检测"的惯用写法,把它用在了所有可能不存在的变量检测上。这个写法在检测全局变量(如第三方库)时是完全正确的,但如果用在已经有 let/const 声明但还没执行到声明行的变量上,就会触发 TDZ 错误,与你的预期完全相反:
// 安全检测未声明变量(有效)
if (typeof jQuery !== 'undefined') {
// jQuery 可用
}
// 但这样做会失败
function checkFeature() {
if (typeof myFeature !== 'undefined') { // ReferenceError! myFeature 在 TDZ
console.log(myFeature);
}
let myFeature = getFeature(); // TDZ 到这里结束
}
规范原因:typeof 的豁免只适用于 [[Base]] 为 unresolvable 的 Reference Record(即完全未声明的变量)。TDZ 变量的 Reference Record 的 [[Base]] 是一个真实的 ER,所以不享有豁免,GetValue 时照样抛出 ReferenceError。
实践:不要在 let/const 声明之前使用 typeof 检测同名变量。把 typeof 安全检测视为"仅用于检测来自外部环境(浏览器全局、第三方脚本)的变量"。
陷阱 4:for...in 配合 var 的经典输出值陷阱
for...in 循环枚举对象的可枚举属性,配合 var 声明迭代变量时,会产生与 for 循环 + var 完全相同的闭包问题:所有回调函数共享同一个迭代变量,最终只能拿到最后一次迭代后的值。这是 JavaScript 旧代码库里常见的 bug 模式之一:
const funcs = [];
// 错误版本:var j 被共享
for (var j in { a: 1, b: 2, c: 3 }) {
funcs.push(() => console.log(j));
}
funcs.forEach(f => f()); // 输出三次 'c'(最后一个键)
// 正确版本:let j 每次新建绑定
for (let k in { a: 1, b: 2, c: 3 }) {
funcs.push(() => console.log(k)); // 每次捕获不同的 k
}
// 但 for...in 的键顺序不是规范保证的!
// 对于整数键,V8 按升序排列;对于字符串键,按插入顺序
for...in 和 for...of 的 let 绑定与普通 for 循环相同:每次迭代新建 ER,但 for...in/for...of 没有 update 表达式的"复制"步骤——迭代变量在每次迭代开始时用新值初始化,不从上一次迭代的 ER 复制。
陷阱 5:const 与解构赋值、函数参数的交互
解构赋值是 ES2015 的另一个重要特性,它与 const/let 的交互产生了一些需要注意的细节。解构赋值创建的每个绑定都遵循声明关键字的规则:const { x, y } = point 里的 x 和 y 都是 const 绑定,不可重新赋值。函数参数的默认值机制有类似 TDZ 的行为,需要特别注意:
// const 与解构
const { x, y } = point; // x 和 y 都是 const 绑定
// x = 1; // TypeError
// 函数参数默认值里的 TDZ
// 参数从左到右初始化,后面的参数默认值可以引用前面的参数
function greet(name, greeting = `Hello, ${name}`) {
console.log(greeting);
}
greet('Alice'); // "Hello, Alice"
// 但参数名不能引用自身(TDZ)
function broken(x = x) { // ReferenceError:x 在 TDZ 中引用了 x 本身
return x;
}
broken(); // ReferenceError
函数参数有自己的 EnvironmentRecord,每个参数从左到右初始化:当处理到参数 x = x 时,绑定已被创建(TDZ 状态),但还没有被初始化,参数默认值的计算试图读取 TDZ 状态的 x,触发 ReferenceError。
附:class 声明与 TDZ 的交互
class 声明在 TDZ 方面的行为与 let/const 完全相同,但有一个额外的细节需要注意:类内部的计算属性名(computed property names)和继承(extends)表达式会在类定义执行时被求值,此时类名本身仍处于 TDZ:
// 类名在 TDZ 期间不可引用
const Foo = class {}; // 这是表达式,赋值给 const Foo
class Bar extends Foo { // Foo 已经初始化,可以引用
constructor() {
super();
}
}
// 类声明的 TDZ(与 let 行为完全一致)
{
console.log(typeof MyClass); // ReferenceError! MyClass 在 TDZ 中
class MyClass {}
console.log(typeof MyClass); // 'function'
}
// 循环引用场景:类方法里可以引用类本身
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
// 方法是在类初始化完成后才执行的,此时 Node 已经可以访问
clone() {
return new Node(this.value); // 这里的 Node 访问的是外层作用域里已初始化的绑定
}
}
附:顶层 await 与 var 的交互(ESM 特有)
在支持顶层 await(Top-level await,ES2022)的 ESM 模块里,模块级别的 var 声明和 await 之间有一个需要理解的时序关系:顶层 await 会让模块的执行暂停,但模块级别的所有 var 声明已经在模块开始执行时就被提升和初始化了,await 不影响这个提升过程:
// module.mjs(使用顶层 await)
console.log(x); // undefined(var 已提升初始化为 undefined)
var x = await fetchInitialValue(); // 暂停执行,等待 Promise 解析
console.log(x); // fetchInitialValue() 的结果
// 重要:即使有 await,var 的提升在模块开始时就发生了
// 在 await 之前访问 x,得到 undefined,不是 ReferenceError
// (如果是 let/const,则会是 TDZ,ReferenceError)
这个行为在调试模块初始化时序问题时很重要:如果你的模块有顶层 await,而且在 await 之后有 var 声明,该变量在 await 之前就已经是 undefined 了(不是未定义),这可能导致意外的"变量已存在但值为 undefined"的状态。
小结
-
const是现代 JS 的默认声明形式:绑定不可重新赋值,但值(若为对象)的内容可以修改;Object.freeze只冻结一层属性,深度冻结需要递归;let允许重新赋值但保持块级作用域;var仅用于维护历史代码。 -
let/const在块进入时立即创建绑定(状态为 uninitialized,即 TDZ),在执行到声明行时才完成初始化;TDZ 的范围是从块开始到声明语句的全部代码区间,typeof操作符在此区间内也会抛出ReferenceError。 -
for (let i ...)循环通过 CreatePerIterationEnvironment 算法为每次迭代创建新的 DeclarativeER,并将上一次的迭代变量值复制进来;这是let版 for 循环可以为闭包提供独立变量的规范机制。 -
内层
let/const的 TDZ 会遮蔽外层同名变量:GetIdentifierReference在当前 ER 找到 uninitialized 绑定后就停止向外查找,对其 GetValue 时抛出ReferenceError,外层的同名变量完全不可见。 -
全局
var声明通过 GlobalEnvironmentRecord 的 ObjectRecord 挂到全局对象(window.x === 1),而全局let/const存在于 GlobalEnvironmentRecord 的 DeclarativeRecord 中,不挂到全局对象(window.x === undefined)。