从“坑爹”的变量提升到优雅的词法环境:深度解密 JS 作用域的底层实现

2 阅读5分钟

摘要:  你是否曾被 var 的 undefined 和 let 的暂时性死区搞得头大?为什么 for 循环里的 i 有时能访问,有时却报错?这一切的背后,是 JavaScript 引擎(V8)为了兼容历史与追求完美而上演的“一国两制”大戏。本文将带你透过核心理论,结合真实代码案例,深入执行上下文的内部,看透变量是如何被查找、存储和销毁的。


引言:一场由“设计缺陷”引发的思考

JavaScript 诞生之初,仅仅是为了给网页加点动态效果的“KPI 项目”。为了在激烈的浏览器商业竞争中快速上线,Brendan Eich 仅用了 10 天就设计出了这门语言。

当时的简单设计(如变量提升、缺乏块级作用域)在后来复杂的工程中变成了“坑”。为了解决这些问题,ES6 引入了 let/const 和块级作用域。

但你知道吗?为了向下兼容,JavaScript 引擎并没有丢弃旧的机制,而是采用了一种 “一国两制” 的策略:var 和 let 在引擎内部的待遇截然不同。

一:编译与执行的“双面人生”

在深入了解作用域之前,我们需要先了解 JS 引擎(以 V8 为例)的工作流程。

JS 的执行分为两个阶段:编译(创建执行上下文)执行

当一个函数被调用时,引擎会创建一个执行上下文(Execution Context) 。你可以把它想象成一个官员的“办公桌”,桌上分成了两个区域:

  1. 变量环境(Variable Environment) :专门伺候 var
  2. 词法环境(Lexical Environment) :专门伺候 let 和 const

这就是解决“一国两制”问题的关键——把新旧规则分开管理

二:旧时代的“贵族”—— var 与 变量提升

var 是旧时代的“贵族”。在编译阶段,引擎会扫描整个作用域,把所有用 var 声明的变量统统提到作用域顶部,并赋予初始值 undefined。这就是变量提升(Hoisting)

我给一个例子:


showName(); 
console.log(myname); // undefined
var myname = "张三"; 
function showName(){ 
    console.log('函数showName执行了'); 
}

底层逻辑:
在编译阶段,引擎眼中的代码其实是这样的:

var myname = undefined; // 变量提升
function showName(){ ... } // 函数声明也会提升(优先级更高)

// 执行阶段
showName(); // 能执行
console.log(myname); // undefined (变量已声明但未赋值)
myname = "张三"; // 此时才赋值

为什么这是缺陷?
正如文档中所说,这种设计导致了变量容易被意外覆盖,且本该销毁的变量因为提升而一直存活,造成了内存浪费和逻辑混乱。

三:新时代的“公民”—— let/const 与 暂时性死区

ES6 为了纠正这个历史错误,引入了 let 和 const。它们不再享受“贵族特权”,而是被放进了执行上下文的另一个区域——词法环境

核心机制:

  1. 不提升(其实是提升但不可用):  let 变量虽然也在编译阶段被注册,但它不会被初始化为 undefined
  2. 暂时性死区(TDZ):  从代码块开始到 let 声明语句执行之前,这个变量一直处于“死区”。如果此时访问,引擎会直接报错 ReferenceError

举个例子:


let name = '张三'
{
  console.log(name); // ❌ 报错:Cannot access 'name' before initialization
  let name = '李四'
}

底层逻辑:
虽然外面有个 name,但因为块级作用域内声明了 let name,它遮蔽了外部的 name
在块级作用域内,引擎在编译阶段看到了 let name,但在执行到赋值语句前,它处于“死区”。所以 console.log(name) 拿到的不是外部的 张三,也不是 undefined,而是报错

四:词法环境的“栈”结构与 块级作用域

这是最精彩的部分。词法环境内部维护了一个小型栈结构

let 的出现让 JavaScript 终于支持了块级作用域(由 {} 包裹的区域)。在引擎眼中,词法环境像一个栈(Stack)

依旧举个例子:


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) // ❌ 报错
}

底层逻辑(栈视角):

  1. 进入函数:  词法环境栈压入第一层:b: 2

  2. 进入内部代码块 {}  栈顶压入第二层:b: 3d: 5

    • 此时查找变量,先看栈顶。所以 b 是 3
    • var c 无视块级作用域,直接提升到了函数作用域(第一层)。
  3. 离开代码块:  第二层出栈销毁。

    • b 恢复为第一层的 2
    • d 随着第二层出栈而彻底消失,外界无法访问。

这就是为什么 c 能打印 4,而 d 会报错的原因。

第五章:var 与 let 的对比

让我们再看几个经典的对比案例,来验证我们的理论。

场景一:if 块里的陷阱

var 版本:

var name = "张三";
function showName() {
  console.log(name); // ❌ undefined
  if (true) {
    var name = "李四" // 提升到了 showName 函数顶部
  }
}
  • 解析:  var 没有块级作用域。if 里的 var name 提升到了 showName 函数的顶部(变量环境),导致函数内的 name 覆盖了全局的 name。在赋值前打印,就是 undefined

let 版本:

let name = "张三 ";
function showName() {
  console.log(name); // ✅ "张三"
  if (false) {
    let name = "李四" // 从未执行,但不影响外部
  }
}
  • 解析:  let 不提升。函数内部没有声明 name,所以顺着作用域链找到了全局的 张三

场景二:循环中的秘密


function foo() {
  for (let i = 0; i < 7; i++ ) {
    // i 是块级作用域的
  }
  console.log(i); // ❌ 报错
}
  • 解析:  let 在 for 循环中声明的变量,只存在于循环体这个“栈帧”中。一旦循环结束,栈帧弹出,i 就被回收了。而 var i 会泄露到函数作用域中。

结语:拥抱词法环境

回顾 JavaScript 的作用域发展史,从 var 的“变量提升”到 let 的“暂时性死区”,从缺乏块级作用域到词法环境的“栈结构”管理,这不仅是语法的更新,更是语言设计的进化。

V8 引擎通过将执行上下文拆分为变量环境(兼容旧时代)和词法环境(拥抱新时代),完美地解决了“一国两制”的难题。

作为开发者,理解这套底层机制,能让我们写出更安全、更符合直觉的代码。下次再遇到 ReferenceError 或 undefined 时,你就知道,那是引擎在告诉你: “变量还在死区里,请先声明再使用!”