JavaScript 作用域与变量提升:从 ES5 到 ES6 的演进(含 TDZ 与查找规则详解)

60 阅读14分钟

📌 引言:为什么“变量提升”让人困惑?

你是否曾写出如下代码,却发现输出结果与预期不符?

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();

我们将分别讨论:

  1. if (true) + var name
  2. if (false) + var name
  3. if (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 或遮蔽。


📊 三种情况对比总表

情况条件声明方式第一次输出第二次输出是否遮蔽全局原因
1truevarundefined'大厂苗'✅ 是var 提升至函数顶部
2falsevarundefinedundefined✅ 是var 仍被提升(静态提升)
3truelet'刘老板''刘老板'❌ 否let 仅在块内有效,不提升

✅ 核心知识点总结

  1. var 是函数作用域无论条件真假都会提升
  2. let/const 是块级作用域,只在 {} 内有效,不会提升到外层
  3. 变量提升是编译时行为,与运行时 if 条件无关;
  4. 作用域查找规则:从当前作用域开始,逐层向外,直到全局;
  5. let 不会遮蔽外层变量,除非在同一作用域或嵌套块中访问。

💡 最佳实践建议

  • 永远不要依赖 var 的提升行为,它容易引发 bug;
  • 优先使用 const,其次 let
  • 避免在函数内声明与全局同名的变量,无论用 var 还是 let
  • 理解“声明” vs “赋值”var 声明即初始化为 undefinedlet 声明后处于 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. 先计算右侧表达式(如 1);
    2. 将值赋给 a
    3. a 的状态从 uninitialized 变为 initialized
    4. 此时 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 函数作用域(无块级),遵循变量提升;

  • 初始化:

    1. 扫描 foo 函数内所有var声明,先创建ac的绑定(提升),值为undefined
    2. 变量环境的结构:{ a: undefined, c: undefined }(注意:c在块内但var无块级,仍绑定到 foo 函数作用域)。
(2)词法环境(处理 let)
  • 作用域:foo 函数作用域(外层),支持块级,初始链式结构:foo函数词法环境 → 全局词法环境

  • 初始化:

    1. 扫描 foo 函数内所有let声明(非块内的b),创建b的绑定并标记为「未初始化(TDZ)」;
    2. 词法环境结构:{ b: <uninitialized> }(此时访问b会触发 TDZ 报错)。

步骤 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 函数的词法环境:

  • 新块级词法环境初始化:

    1. 扫描块内let声明(bd),创建b: <uninitialized>d: <uninitialized>(TDZ);
    2. 块内无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函数词法环境 → 全局词法环境
  • 块内的bd绑定随块级词法环境销毁,无法再访问。

步骤 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的书写顺序入栈

✅ 正确逻辑:

  1. 变量环境(var)无 “入栈” 概念ac在 foo 执行上下文初始化时就绑定到函数作用域,仅修改值,无栈操作;

  2. 词法环境(let)的 “栈” 是「作用域栈」,而非变量栈

    • 进入块级时,整个块级词法环境入栈(包含块内bd);
    • 退出块级时,整个块级词法环境出栈
    • 变量的创建是 “作用域初始化时扫描声明”,而非按执行顺序入栈。

三、可视化总结(执行上下文结构变化)

阶段变量环境(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(失败)

四、核心结论

  1. 变量并非按书写顺序入栈,而是:

    • var变量在函数执行上下文初始化时就绑定到变量环境(无栈操作);
    • let变量在所属作用域(函数 / 块级)的词法环境中初始化,块级词法环境会整体入栈 / 出栈;
  2. 块级作用域的核心是「词法环境栈」的切换,而非变量的入栈;

  3. var无块级特性,即使写在块内,仍绑定到函数 / 全局作用域;let的块级绑定随块级词法环境出栈而销毁。

变量存储位置作用域是否可跨块访问
a变量环境函数作用域
b(外层)词法环境函数作用域✅(块内被遮蔽)
b(内层)词法环境(块级)块作用域❌(块结束后销毁)
c变量环境函数作用域(因 var 提升)
d词法环境(块级)块作用域

引擎行为

  • 对于 {} 块,不创建新执行上下文
  • 但在当前上下文的词法环境链中临时压入一个块级词法环境
  • 块执行完后,该环境立即弹出(栈式管理);
  • 变量查找时,从栈顶(当前块)开始,逐层向上搜索

⚖️ 五、对比总结:var vs let/const

特性varlet / 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. 变量查找规则

  1. 从当前作用域(或当前块)开始查找;
  2. 若未找到,沿作用域链向上搜索;
  3. 直到全局作用域;
  4. 仍未找到 → ReferenceError

6. 最佳实践

  • 优先使用 const,其次 let
  • 避免使用 var(除非兼容旧代码);
  • 不要在同一作用域混用 var 和 let 声明同名变量。

💡 七、拓展思考

Q:为什么 var 在块中仍属于函数作用域?

因为 var 的设计早于块级作用域概念,其作用域模型只有全局函数两级。这是历史遗留,也是 ES5 的局限。

Q:TDZ 是否影响性能?

几乎不影响。TDZ 是语义层面的保护机制,现代引擎对此有高度优化。


⚠️ 八、注意事项

  1. var 的提升是“声明提升”,不是“赋值提升”
  2. 函数声明提升优先级高于 var(但低于 let/const 的 TDZ 保护)。
  3. let/const 不会挂载到 window,避免全局污染。
  4. TDZ 是静态分析结果,与运行时无关。