📌 引言:为什么“变量提升”让人困惑?
你是否曾写出如下代码,却发现输出结果与预期不符?
js
编辑
console.log(myname); // undefined
var myname = "路明非";
或者在函数中调用同名函数却意外覆盖?
js
编辑
function showName() {
console.log("函数showName执行了");
}
showName(); // 正常执行
这些“反直觉”的行为,根源在于 JavaScript 的变量提升(Hoisting)机制。而随着 ES6 引入 let/const 和块级作用域,这一问题得到了缓解,但并未消失——因为 JavaScript 必须向下兼容。
本文将从 执行上下文、作用域链、变量环境 vs 词法环境 的角度,系统解析 JS 作用域机制的演变,并重点剖析 暂时性死区(TDZ) 与 变量查找规则,通过两个典型实例强化理解。
🔍 一、核心概念定义:什么是作用域?
作用域(Scope) 是程序中定义变量的区域,该位置决定了变量的可见性与生命周期。
作用域链(Scope Chain) 是变量查找时的路径,由当前词法环境逐层向上链接至全局环境构成。
变量查找规则:引擎从当前作用域开始查找变量,若未找到,则沿作用域链向上搜索,直到全局作用域;若仍未找到,则抛出ReferenceError。
简言之:
- 作用域 = 变量能被访问的范围
- 作用域链 = 查找变量的“路线图”
🧱 二、变量提升(Hoisting):ES5 的设计妥协
var name = '刘老板';
function showName() {
console.log(name);
if (/* 条件 */) {
/* 声明方式 */ name = '大厂苗';
}
console.log(name);
}
showName();
我们将分别讨论:
if (true)+var nameif (false)+var nameif (true)+let name
✅ 每种情况都会从 编译阶段(变量提升/TDZ) 和 执行阶段(赋值/查找) 两个角度分析,并给出输出结果与原理总结。
🧪 情况一:if (true) + var name
代码
js
编辑
var name = '刘老板';
function showName() {
console.log(name); // ?
if (true) {
var name = '大厂苗'; // var 声明
}
console.log(name); // ?
}
showName();
输出
text
编辑
undefined
大厂苗
分析
-
编译阶段:
- 函数内存在
var name,被提升到函数顶部,初始化为undefined; - 此局部
name遮蔽全局name。
- 函数内存在
-
执行阶段:
- 第一次
console.log(name)→ 访问的是未赋值的局部name→undefined; if (true)执行,name = '大厂苗'赋值;- 第二次
console.log(name)→'大厂苗'。
- 第一次
✅ 结论:var 有函数作用域 + 变量提升,块不影响其作用域。
🧪 情况二:if (false) + var name
代码
js
编辑
var name = '刘老板';
function showName() {
console.log(name); // ?
if (false) {
var name = '大厂苗'; // var 声明(但不会执行)
}
console.log(name); // ?
}
showName();
输出
text
编辑
undefined
undefined
分析
-
编译阶段:
- 引擎仍然会扫描整个函数体,发现
var name; - 无论
if条件真假,var name都会被提升并初始化为undefined; - 全局
name被遮蔽。
- 引擎仍然会扫描整个函数体,发现
-
执行阶段:
if (false)不执行,name = '大厂苗'永远不会运行;- 两次
console.log(name)都访问未赋值的局部name→undefined。
✅ 关键点:
var的提升是静态的(编译时) ,与运行时条件无关!
🧪 情况三:if (true) + let name
代码
js
编辑
var name = '刘老板';
function showName() {
console.log(name); // ?
if (true) {
let name = '大厂苗'; // let 声明
}
console.log(name); // ?
}
showName();
输出
text
编辑
刘老板
刘老板
分析
-
编译阶段:
let name仅在if块内声明;- 引擎在函数作用域中没有发现
let name,因此不会遮蔽全局name; let name在块级词法环境中绑定,进入 TDZ(暂时性死区) ,但不影响函数外。
-
执行阶段:
- 第一次
console.log(name)→ 查找当前函数作用域无name,沿作用域链找到全局name = '刘老板'; if (true)执行,创建块级作用域,let name = '大厂苗'赋值(块内可用);- 第二次
console.log(name)→ 仍在函数作用域,块已结束,let name不可见,继续使用全局name。
- 第一次
✅ 结论:
let具有块级作用域,不会提升到函数顶部,也不会遮蔽外部同名变量(除非在同一作用域声明)。
🧪 补充对比:if (false) + let name
虽然你没问,但为了完整性:
js
编辑
var name = '刘老板';
function showName() {
console.log(name); // '刘老板'
if (false) {
let name = '大厂苗'; // 不会执行,也不影响作用域
}
console.log(name); // '刘老板'
}
showName();
let name根本不会被“看到”(因为块未执行?错!)- 实际上:即使
if (false),let name仍会在编译阶段被识别,但仅限于块内。 - 但由于块未进入,TDZ 不会影响函数作用域。
- 所以两次都输出
'刘老板'。
⚠️ 注意:
let的绑定是在词法分析时确定的,但 TDZ 仅在进入块后生效。只要不进入块,就不会触发 TDZ 或遮蔽。
📊 三种情况对比总表
| 情况 | 条件 | 声明方式 | 第一次输出 | 第二次输出 | 是否遮蔽全局 | 原因 |
|---|---|---|---|---|---|---|
| 1 | true | var | undefined | '大厂苗' | ✅ 是 | var 提升至函数顶部 |
| 2 | false | var | undefined | undefined | ✅ 是 | var 仍被提升(静态提升) |
| 3 | true | let | '刘老板' | '刘老板' | ❌ 否 | let 仅在块内有效,不提升 |
✅ 核心知识点总结
var是函数作用域,无论条件真假都会提升;let/const是块级作用域,只在{}内有效,不会提升到外层;- 变量提升是编译时行为,与运行时
if条件无关; - 作用域查找规则:从当前作用域开始,逐层向外,直到全局;
let不会遮蔽外层变量,除非在同一作用域或嵌套块中访问。
💡 最佳实践建议
- 永远不要依赖
var的提升行为,它容易引发 bug; - 优先使用
const,其次let; - 避免在函数内声明与全局同名的变量,无论用
var还是let; - 理解“声明” vs “赋值” :
var声明即初始化为undefined,let声明后处于 TDZ,直到赋值。
⚡ 三、暂时性死区(TDZ):let/const 的安全机制
3.1 TDZ 的本质:状态而非区域
⚡ 三、暂时性死区(TDZ):let/const 的安全机制
3.1 TDZ 的本质:不是“区域”,而是“状态”
重要澄清:
暂时性死区(TDZ)不是内存中的物理区域,而是对「变量已绑定但未赋值」这一状态阶段的描述。
具体来说:
- 变量的「绑定」:指变量名被注册到当前词法环境的
EnvironmentRecord中(编译阶段完成)。 - 变量的「可用」:指变量完成赋值(执行阶段到声明语句时完成)。
- TDZ 就是「绑定完成」到「赋值完成」之间的这段 “不可用期” 。
3.2 编译阶段:let 变量“绑定入册”,进入 TDZ
JS 引擎在执行代码前,会先扫描当前作用域(块 / 函数 / 全局),识别所有 let/const/class 声明,并在词法环境的 envRecord 中为它们注册变量名,但不赋值,状态标记为 uninitialized。
此时变量已“存在”于作用域中,但无法访问 —— TDZ 开始。
对比:var vs let 在编译阶段的行为
js
编辑
// 全局作用域编译阶段(伪逻辑)
LexicalEnvironment = {
envRecord: {
// let a 被绑定,状态 uninitialized → 进入 TDZ
a: 'uninitialized',
// var b 被绑定 + 赋值 undefined → 无 TDZ
b: undefined
},
outer: null
};
⚠️ 关键区别:
var:编译阶段绑定 + 赋值undefined→ 无 TDZ。let:仅绑定,不赋值 → 直接进入 TDZ。
3.3 执行阶段:走到声明语句时,“赋值脱绑”,退出 TDZ
进入执行阶段后,引擎按代码顺序逐行执行:
-
在
let a = 1之前:a仍处于 TDZ,访问则抛出ReferenceError。 -
执行到
let a = 1这一行:- 先计算右侧表达式(如
1); - 将值赋给
a; a的状态从uninitialized变为initialized;- 此时
a退出 TDZ,可正常访问。
- 先计算右侧表达式(如
3.4 完整示例:TDZ 的“进入→退出”全过程
js
编辑
// 阶段1:全局作用域编译完成 → let a 绑定,进入 TDZ
console.log(a); // ❌ ReferenceError(TDZ 内,不可访问)
console.log(b); // ✅ undefined(var 无 TDZ)
// 阶段2:执行到 var b = 2 → 赋值(覆盖 undefined)
var b = 2;
// 阶段3:执行到 let a = 1 → 赋值,a 退出 TDZ
let a = 1;
console.log(a); // ✅ 1(TDZ 结束,正常访问)
// 块级作用域的 TDZ 同理
{
// 阶段4:块级作用域编译完成 → let c 绑定,进入 TDZ
console.log(c); // ❌ ReferenceError(块级 TDZ)
let c = 3; // 阶段5:执行到这里,c 退出 TDZ
console.log(c); // ✅ 3
}
✅ 结论:TDZ 是一种时序保护机制,确保开发者不会在变量初始化前误用它。
🧩 四、块级作用域与变量查找规则实战
实例:var 与 let 在块中的行为差异
js
编辑
function foo() {
var a = 1;
let b = 2;
{
let b = 3; // 新的 b,遮蔽外层
var c = 4; // 提升到函数顶部(变量环境)
let d = 5;
console.log(a); // 1(从外层作用域找到)
console.log(b); // 3(当前块作用域)
}
console.log(a); // 1
console.log(b); // 2(回到外层 let b)
console.log(c); // 4(var c 提升到函数作用域)
console.log(d); // ❌ ReferenceError(d 随块环境出栈)
}
foo();
🔍 执行上下文与作用域链分析:
先明确几个核心概念(ES6 执行上下文结构):
- 执行上下文 = 变量环境(VariableEnvironment) + 词法环境(LexicalEnvironment) + this 绑定;
- 变量环境:仅处理
var/ 函数声明,绑定到函数 / 全局作用域(无块级),遵循变量提升; - 词法环境:处理
let/const/class,支持块级作用域,形成栈式链式结构(内层块级作用域指向外层),存在暂时性死区(TDZ); - 「入栈」的核心是词法环境的栈式创建,而非变量按顺序入栈。
一、foo 函数执行的完整流程(执行上下文视角)
步骤 1:foo 函数被调用 → 创建 foo 的执行上下文
执行上下文初始化时,会先构建两大环境:
(1)变量环境(处理 var)
-
作用域:foo 函数作用域(无块级),遵循变量提升;
-
初始化:
- 扫描 foo 函数内所有
var声明,先创建a和c的绑定(提升),值为undefined; - 变量环境的结构:
{ a: undefined, c: undefined }(注意:c在块内但var无块级,仍绑定到 foo 函数作用域)。
- 扫描 foo 函数内所有
(2)词法环境(处理 let)
-
作用域:foo 函数作用域(外层),支持块级,初始链式结构:
foo函数词法环境 → 全局词法环境; -
初始化:
- 扫描 foo 函数内所有
let声明(非块内的b),创建b的绑定并标记为「未初始化(TDZ)」; - 词法环境结构:
{ b: <uninitialized> }(此时访问b会触发 TDZ 报错)。
- 扫描 foo 函数内所有
步骤 2:执行 foo 函数内的同步代码(逐行处理)
(1)执行var a = 1
- 直接修改变量环境中
a的绑定值:a: 1(变量环境变为{ a: 1, c: undefined })。
(2)执行let b = 2
- 结束
b的 TDZ 状态,修改 foo 函数词法环境中b的绑定值:b: 2(词法环境变为{ b: 2 })。
(3)进入{}块级作用域 → 创建块级词法环境(入栈核心)
块级作用域会创建新的词法环境,压入词法环境栈,且新环境的「外层引用」指向 foo 函数的词法环境:
-
新块级词法环境初始化:
- 扫描块内
let声明(b、d),创建b: <uninitialized>、d: <uninitialized>(TDZ); - 块内无
var声明的新变量(c已在 foo 变量环境中),无需修改变量环境;
- 扫描块内
-
此时词法环境栈结构:
块级词法环境 → foo函数词法环境 → 全局词法环境。
(4)执行块内代码
let b = 3:结束块内b的 TDZ,块级词法环境中b: 3(遮蔽外层 foo 的b);var c = 4:修改 foo 变量环境中c的绑定值:c: 4(块内var仍作用于 foo 函数);let d = 5:结束块内d的 TDZ,块级词法环境中d: 5。
(5)退出块级作用域 → 块级词法环境出栈
- 栈结构回到:
foo函数词法环境 → 全局词法环境; - 块内的
b、d绑定随块级词法环境销毁,无法再访问。
步骤 3:执行 console.log 系列语句(变量查找规则)
变量查找时,先查词法环境栈顶,再查变量环境,最后向上找外层:
console.log(a):变量环境中找到a: 1→ 输出 1;console.log(b):foo 词法环境中找到b: 2→ 输出 2;console.log(c):变量环境中找到c: 4→ 输出 4;console.log(d):词法环境 + 变量环境均无d→ 报错ReferenceError: d is not defined。
二、关键问题:“按顺序入栈” 的准确理解
❌ 错误认知:变量按a→b→b→c→d的书写顺序入栈
✅ 正确逻辑:
-
变量环境(var)无 “入栈” 概念:
a、c在 foo 执行上下文初始化时就绑定到函数作用域,仅修改值,无栈操作; -
词法环境(let)的 “栈” 是「作用域栈」,而非变量栈:
- 进入块级时,整个块级词法环境入栈(包含块内
b、d); - 退出块级时,整个块级词法环境出栈;
- 变量的创建是 “作用域初始化时扫描声明”,而非按执行顺序入栈。
- 进入块级时,整个块级词法环境入栈(包含块内
三、可视化总结(执行上下文结构变化)
| 阶段 | 变量环境(foo 函数) | 词法环境栈(栈顶→栈底) | 关键操作 |
|---|---|---|---|
| foo 执行上下文初始化 | {a: undefined, c: undefined} | foo 词法环境(b: 未初始化)→ 全局 | 提升 var,标记 let 的 TDZ |
| 执行 var a=1/let b=2 | {a: 1, c: undefined} | foo 词法环境(b: 2)→ 全局 | 赋值 a,结束 b 的 TDZ |
| 进入块级作用域 | {a: 1, c: undefined} | 块级词法环境(b: 未初始化、d: 未初始化)→ foo 词法环境 → 全局 | 块级词法环境入栈 |
| 执行块内代码 | {a: 1, c: 4} | 块级词法环境(b: 3、d: 5)→ foo 词法环境 → 全局 | 赋值块内 b、d,赋值 c |
| 退出块级作用域 | {a: 1, c: 4} | foo 词法环境(b: 2)→ 全局 | 块级词法环境出栈 |
| 执行 console.log | {a: 1, c: 4} | foo 词法环境(b: 2)→ 全局 | 查找 a/b/c(成功),查找 d(失败) |
四、核心结论
-
变量并非按书写顺序入栈,而是:
var变量在函数执行上下文初始化时就绑定到变量环境(无栈操作);let变量在所属作用域(函数 / 块级)的词法环境中初始化,块级词法环境会整体入栈 / 出栈;
-
块级作用域的核心是「词法环境栈」的切换,而非变量的入栈;
-
var无块级特性,即使写在块内,仍绑定到函数 / 全局作用域;let的块级绑定随块级词法环境出栈而销毁。
| 变量 | 存储位置 | 作用域 | 是否可跨块访问 |
|---|---|---|---|
a | 变量环境 | 函数作用域 | ✅ |
b(外层) | 词法环境 | 函数作用域 | ✅(块内被遮蔽) |
b(内层) | 词法环境(块级) | 块作用域 | ❌(块结束后销毁) |
c | 变量环境 | 函数作用域(因 var 提升) | ✅ |
d | 词法环境(块级) | 块作用域 | ❌ |
✅ 引擎行为:
- 对于
{}块,不创建新执行上下文;- 但在当前上下文的词法环境链中临时压入一个块级词法环境;
- 块执行完后,该环境立即弹出(栈式管理);
- 变量查找时,从栈顶(当前块)开始,逐层向上搜索。
⚖️ 五、对比总结:var vs let/const
| 特性 | var | let / const |
|---|---|---|
| 作用域 | 函数/全局 | 块级 |
| 变量提升 | 是(赋值 undefined) | 否(存在 TDZ) |
| 重复声明 | 允许 | 不允许(同一作用域) |
| 全局绑定 | 是(挂载到 window) | 否 |
| 循环闭包 | 有经典问题(i=10) | 无(每次迭代新建绑定) |
| 存储位置 | 变量环境(Variable Environment) | 词法环境(Lexical Environment) |
| 查找规则 | 沿函数作用域链向上 | 沿词法环境栈 + 作用域链向上 |
✅ 六、核心知识点总结(便于复习)
1. 作用域的本质
- 作用域 = 变量定义的区域 → 决定可见性与生命周期。
- 作用域链 = 变量查找的路径(当前 → 外层 → 全局)。
2. 变量提升(Hoisting)
- 仅
var和函数声明被提升; var提升后初始化为undefined;- 导致函数内
var声明会遮蔽外部同名变量。
3. 暂时性死区(TDZ)
- 适用于
let/const/class; - 是「绑定完成 → 赋值完成」之间的状态阶段;
- 访问 TDZ 中的变量 →
ReferenceError。
4. 块级作用域实现机制
{}不创建新执行上下文;- 引擎在词法环境链中临时压入块级环境(栈式管理);
- 块结束,环境弹出,变量不可访问。
5. 变量查找规则
- 从当前作用域(或当前块)开始查找;
- 若未找到,沿作用域链向上搜索;
- 直到全局作用域;
- 仍未找到 →
ReferenceError。
6. 最佳实践
- 优先使用
const,其次let; - 避免使用
var(除非兼容旧代码); - 不要在同一作用域混用
var和let声明同名变量。
💡 七、拓展思考
Q:为什么 var 在块中仍属于函数作用域?
因为 var 的设计早于块级作用域概念,其作用域模型只有全局和函数两级。这是历史遗留,也是 ES5 的局限。
Q:TDZ 是否影响性能?
几乎不影响。TDZ 是语义层面的保护机制,现代引擎对此有高度优化。
⚠️ 八、注意事项
var的提升是“声明提升”,不是“赋值提升” 。- 函数声明提升优先级高于
var(但低于let/const的 TDZ 保护)。 let/const不会挂载到window,避免全局污染。- TDZ 是静态分析结果,与运行时无关。