从 var 到 let:JavaScript 作用域模型的进化之路

73 阅读7分钟

从变量提升到块级作用域:JavaScript 作用域机制的演进之路

在学习 JavaScript 的过程中,很多开发者都会被“变量提升”这一特性搞得晕头转向。明明代码还没执行到变量声明的地方,却已经可以访问;明明写了一个 let 声明,却报错说“不能在初始化前访问”。这些看似“反直觉”的行为背后,其实隐藏着 JavaScript 引擎对作用域机制的历史妥协与现代优化。

本文将从变量提升的由来出发,结合实际代码,解释为何 ES5 的设计存在缺陷,并进一步说明 ES6 是如何通过块级作用域词法环境的引入,优雅地解决了这些问题。


一、变量提升:历史的产物

1.1 什么是变量提升?

在 ES5 及更早版本中,使用 var 声明的变量和函数声明会被“提升”到其所在作用域的顶部。例如:


showName();           //  正常输出:“函数showName 执行了”
console.log(myname);  
var myname = "瞿翔";  
function showName() {
  console.log('函数showName 执行了');
}

虽然 showName() 调用出现在函数定义之前,但 JavaScript 引擎在编译阶段就将函数声明提升到了作用域顶部,因此可以正常调用。

var 声明的变量也会被提升,但只有声明被提升,赋值操作则留在原地,所以 console.log(myName) 输出 undefined(如果变量名正确的话)。

1.2 为什么会有变量提升?

变量提升是早期为了简化引擎实现而做出的妥协。它让解析器无需关心变量声明的具体位置,只需在进入作用域时一次性处理所有 var 和函数声明。

但这带来了严重的问题:

  • 变量可能在未预期的地方被覆盖;
  • 无法限制变量的作用范围(比如 iffor 内部);
  • 容易引发难以调试的逻辑错误。

二、块级作用域缺失带来的问题

2.1 var 不支持块级作用域

var name = "刘锦苗";
function showName() {
  console.log(name);        // 输出刘锦苗?
  if (false){
    var name = "大厂的苗子";
  }
  console.log(name);       
}
showName();

你可能会以为 if 块内的代码永远不会执行,所以第一个打印的结果应该是“刘锦苗”,但实际上的结果是undefined!!!,这也是为什么我们说js是反直觉的代码

  • 真实的原因是什么呢
    那就是由于var是js早期的设计,它并不支持块级作用域,在if中的name会变量提升到整个函数作用域中,覆盖掉了全局中的“刘锦苗”,但赋值操作留在原地不会被执行到,所以会输出undefined。

这显然违背了开发者的直觉——我们希望 if 块内的变量只在块内有效。

2.2 循环中的经典陷阱


function foo() {
  for (var i = 0; i < 7; i++) { }
  console.log(i); // 输出:7
}
foo();

i 在循环结束后仍然可访问!如果后续代码不小心用了 i,就可能引发 bug。这也是为什么在 ES6 之前,很多人用 IIFE 来模拟块级作用域。


三、ES6 的救赎:let / const 与块级作用域

为了解决上述问题,ES6 引入了 letconst,并正式支持块级作用域

3.1 块级作用域是什么?

块级作用域指由 {} 包裹的代码区域(如 ifforwhile、甚至独立的 {} 块)。在 ES6 中,let/const 声明的变量仅在当前块内有效


let name = "刘锦苗";
{
  console.log(name); //  ReferenceError: Cannot access 'name' before initialization
  let name = "大厂的苗子";
}

这里报错不是因为变量不存在,而是因为进入了 “暂时性死区”(Temporal Dead Zone, TDZ) —— 在 let name 声明之前,该变量不可访问。

这正是 ES6 的设计哲学:宁可报错,也不让你写出有歧义的代码

3.2 执行上下文的双环境模型

根据你的笔记,V8 引擎在执行上下文创建时,会维护两个环境:

  • 变量环境(Variable Environment) :存放 var 声明,支持变量提升。
  • 词法环境(Lexical Environment) :存放 let/const 声明,支持块级作用域和 TDZ。

考虑下面这段代码:


function foo() {
  var a = 1;
  let b = 2;
  {
    let b = 3;   // 新的块级作用域,b 被压入词法环境栈
    var c = 4;   // c 属于函数作用域(变量环境)
    let d = 5;
    console.log(a); // 1
    console.log(b); // 3(查找当前块的词法环境)
  }
  console.log(b); // 2(块结束,b=3 出栈)
  console.log(c); // 4(var 提升到函数顶部)
  console.log(d); //  ReferenceError: d is not defined
}
foo();

我对代码的理解:

  • 作用域链
    对于代码中console.log(a),它被定义中块级作用域中,它是如何寻找a的呢,首先他会查找自身(块级作用域)是否声明了该元素,如果有那就停止查找,没有则会向他外层的作用域进行查找,这就是作用域的嵌套,直到找到该元素或者搜索到全局作用域也没找到。
    下面用一张图加深你对它的理解:

lQLPJwx8uLpKiuvNAi3NBHaw5eQSn_iEqfQJAjuChNC5AA_1142_557.png

  • 块级作用域
    块级作用域虽然不能创建一个新的词法环境,但却能够在当前词法环境中维护一个栈区,这里我们解释代码中 console.log(b) console.log(d)为什么是上述的输出结果。

下面用一张图解释:他们在函数执行上下文中编译阶段的关系

5da8edca7c830fce449e5691ee46646c.png

当我们在块级作用域中定义了一个新的b时,它并不会引起报错,因为它其实并不会影响到函数全局的b变量,他们实际上的关系就和图中一样,当我们在块级作用域中使用let/const声明一个变量时,块级作用域实际上会在当前函数的词法环境中维护一个栈结构一个块级作用域就是一个栈区,他们并不会互相干扰,当这个块级作用域执行完毕后,就会出栈,这就符合了js的垃圾回收机制,不会污染整个的函数的环境,所以当我们在块级作用域之外的地方想要去访问块级作用域中由let/const声明的变量,这是不符合规则的,他只会沿着作用域链去查询该变量,这也是为什么代码中访问d就会报错的原因--因为函数作用域中根本没有d这个变量!!

四、为什么 ES6 要“一国两制”?

你可能会问:既然 let/const 更好,为什么不直接废弃 var

答案是:向下兼容

JavaScript 已经广泛应用于全球数十亿网页,任何破坏性变更都可能导致旧网站崩溃。因此,ES6 采取了“新旧并存”的策略:

  • 保留 var 和变量提升,维持旧代码行为;
  • 新增 let/const,提供更安全、更符合直觉的作用域规则。

这也解释了为何现代最佳实践强烈推荐:永远使用 let/const,避免 var


五、总结

特性var(ES5)let/const(ES6)
作用域函数作用域块级作用域
变量提升提升存在暂时性死区
重复声明允许不允许
全局对象属性

变量提升是历史的妥协,块级作用域是未来的方向。理解这两者的差异,不仅能帮你写出更健壮的代码,也能深入掌握 JavaScript 引擎的工作原理。

“站在执行上下文的角度,var 放在变量环境,let/const 放在词法环境。”

这不仅是语法的改变,更是 JavaScript 语言成熟化的标志。


建议:在日常开发中,彻底告别 var,拥抱 const 优先、let 备用的原则,让代码更清晰、更安全!