深入理解 JavaScript 作用域:从执行上下文到词法环境的底层机制解析

179 阅读5分钟

JavaScript 的作用域是前端开发者必须扎实掌握的基础。
但很多人学到 ES6 后发现一个现象:

同为声明变量,var 和 let/const 的行为却完全不同。

这背后不是语法层面的差异,而是 JS 引擎内部机制 的差异。
尤其是 ES6 之后,JS 的变量模型正式进入 “一国两制”

  • var → 早期历史设计,基于 变量环境(Variable Environment)
  • let/const → ES6 新机制,基于 词法环境(Lexical Environment)

本文将从 JavaScript 引擎执行过程入手,深入解析作用域、作用域链、变量提升、TDZ、块级作用域栈等底层机制。阅读完成后,你会真正理解 JS 为什么“看上去怪怪的”,以及 ES6 如何弥补历史缺陷。

1. JS 的运行机制:编译阶段 + 执行阶段

JavaScript 并不是传统意义上的解释型语言,现代 JS 引擎(如 V8)执行代码分两步:

① 编译阶段

  • 生成 AST(语法树)
  • 创建执行上下文(Execution Context)
  • 构建变量环境 & 词法环境
  • 完成变量提升(Hoisting)
  • 建立作用域链

② 执行阶段

  • 自上而下运行代码
  • 根据作用域链查找变量
  • 创建/销毁块级作用域

理解这两个阶段,是理解 JS 作用域与提升现象的关键。

2. “变量提升”:JS 早期的设计妥协

看这段经典代码:

showName();
console.log(myname); // undefined

var myname = '张三';

function showName(){
    console.log("函数showName执行了");
}

输出:

函数showName执行了
undefined

很多初学者以为 JS 是“从上到下运行”,但这是执行阶段的行为;
在编译阶段,JS 会将代码变成如下形态:

function showName(){ ... }
var myname; // 提升并初始化 undefined

showName();
console.log(myname);

myname = '张三';

这带来了几个问题:

  • 变量可在声明前访问(不合理)
  • 变量可能被悄悄覆盖
  • 缺乏块级作用域

这些都是 ES5 的设计遗留问题。

3. ES6 引入词法环境:JS 进入“一国两制”时代

为了解决 ES5 的问题,ES6 引入:

  • let / const
  • 块级作用域 {}
  • 暂时性死区(TDZ)

底层的变化是多了一个词法环境(Lexical Environment)

声明方式存放位置是否提升是否初始化是否有 TDZ是否支持块级作用域
var变量环境undefined
let词法环境❌(不初始化)
const词法环境

所以 JS 变成了:

var 走老路(变量环境),let/const 走新路(词法环境) —— 典型的一国两制。

4. 执行上下文:作用域的根源结构

每当代码执行到“可运行节点”(如全局、函数)时,引擎会创建一个执行上下文,包括两个重要区域:

执行上下文(Execution Context)
│
├── 变量环境(Variable Environment) // var 在这里
│
└── 词法环境(Lexical Environment) // let/const、块级作用域在这里

这两者共同构成了作用域查找链,也决定了提升规则。

5. 作用域链:变量查找路径

当你访问一个变量时,JS 按以下顺序查找:

  1. 当前词法环境(可能是块级环境,也可能是函数环境)
  2. 外层词法环境
  3. 再外层……
  4. 最终到达全局环境

可视化为一个栈结构:

词法环境栈(从上到下)
│
├── 块级作用域({ })
├── for/if 等循环块
├── 当前函数作用域
└── 全局作用域

var 不在这个栈中,它在 “变量环境” 内,不受块级作用域影响。

6. 暂时性死区(TDZ):让 let/const 更安全

let name = "张三";
{
    console.log(name); // ReferenceError
    let name = "李四";
}

为什么不是 “张三”?
原因是:块内一旦声明 let name,就会屏蔽外层同名变量。

执行过程:

  1. 进入 {} → 创建新的块级词法环境(提升 name)
  2. name 被提升但未初始化
  3. 执行 console.log(name) → 访问未初始化变量 → 抛错

TDZ 的作用:

  • 禁止在声明前使用 let/const
  • 防止变量被意外覆盖
  • 增强语言的安全性

7. var 在 if 中声明会提升到函数顶部

var name = "张三";

function showName(){
    console.log(name); // undefined
    if(false){
        var name = "李四";
    }
    console.log(name); // undefined
}

showName();

因为在编译阶段,函数内部会被处理成这样:

function showName(){
    var name; // 提升
    console.log(name);
    if(false){
        name = "李四";
    }
    console.log(name);
}

无论 if 是否执行,声明一定提升到函数顶部。

8. let 支持块级作用域,不提升到函数顶部

let name = "张三";

function showName(){
    console.log(name); // 张三
    if(false){
        let name = "李四"; // 块级环境,不会污染外层
    }
}

showName();

因此 let 完全避免了 var 的提升陷阱。

9. for(let) 循环:每次迭代都是一个全新块级作用域

function foo(){
    for(let i = 0; i < 7; i++){}
    console.log(i); // ReferenceError
}
foo();

底层机制是:每一次循环都会创建独立的词法环境。

与之对比:

for(var i = 0; i < 7; i++){}
console.log(i); // 7

因为 var 完全没有块级概念。

10. 块级作用域的栈结构示例(非常关键)

例如:

function foo() {
    var a = 1;
    let b = 2;
    {
        let b = 3;
        var c = 4;
        let d = 5;
        console.log(a); // 1
        console.log(b); // 3
    }
    console.log(b); // 2
    console.log(c); // 4
    console.log(d); // 报错
}
foo();

原因分析:

块级作用域执行时会创建词法环境栈:

栈顶 →   { b=3, d=5 }   // block 作用域
          { b=2 }        // 函数作用域
栈底 →   全局作用域

变量查找规则:从栈顶往下

  • 块内 b → 找到的是 3
  • 块外 b → 块级环境已出栈,所以是 2
  • c → var,不在栈结构中,而在变量环境,所以可访问
  • d → 已随块级作用域出栈,访问报错

这正是 ES6 块级作用域的底层实现原理。

11. 总结:作用域的一张全图

                执行上下文(Execution Context)
                /                         \
  Variable Environment                Lexical Environment
     (存 var)                    (存 let/const/函数/块级环境)
                                          |
                                块级作用域(栈结构)
                                          |
                                 暂时性死区(TDZ)

核心结论:

  • JS 引擎先编译再执行
  • var 基于“变量环境”,存在提升,不支持块级作用域
  • let/const 基于“词法环境”,有 TDZ,支持块级作用域
  • 块级作用域由词法环境的“栈结构”实现
  • 作用域链按词法嵌套,而不是函数调用位置