深入理解JavaScript变量提升:从机制到ES6块级作用域

作者:半夏

时间:2026-10-24

浏览:608

本文深入解析JavaScript中的变量提升机制,探讨var、let和const在声明行为上的差异。通过执行上下文、词法环境与变量环境的原理,阐明ES6引入块级作用域如何解决变量覆盖与未销毁问题,并详细解释暂时性死区(TDZ)的形成原因及规避方法。

文章目录

前言

在 ECMAScript 6 标准中,引入了 let 和 const 关键字以完善变量声明机制。前端面试中常涉及 let、const 与 var 的区别,这直接关联到变量提升和暂时性死区等核心概念。本文将详细解析这些机制。

1. 什么是变量提升?

首先参考 MDN 对变量提升的定义:

变量提升(Hoisting)是对 JavaScript 执行上下文(包括创建和执行阶段)工作方式的一种概念性描述。在 ECMAScript 2015 规范之前的文档中,并未直接使用“变量提升”这一术语。 从字面理解,“变量提升”似乎意味着变量和函数的声明在物理上被移动到了代码的最前面,但这并不准确。实际上,声明在源代码中的位置保持不变,而是在编译阶段被放入内存中处理。

通俗而言,变量提升是指 JavaScript 引擎在代码执行过程中,将变量和函数的声明部分“提升”到当前作用域顶部的行为。提升后,变量会被赋予默认值 undefined。这种特性常导致与直觉不符的代码行为,被视为 JavaScript 的设计缺陷。尽管 ECMAScript 6 通过引入块级作用域和 let、const 关键字缓解了这一问题,但为了向后兼容,变量提升机制在很长一段时间内仍会存在。

在 ECMAScript 6 之前,JavaScript 引擎使用 var 关键字声明变量。在 var 时代,无论变量声明位于何处,它最终都会被提升到当前作用域的顶端。以下示例在全局作用域中声明变量 num,并在声明前打印它:


console.log(num) 
var num = 1

此处输出为 undefined,因为变量声明被提升了,其等价于:


var num
console.log(num)
num = 1

可见,num 作为全局变量被提升到了全局作用域的顶端。

此外,函数作用域中也存在变量提升:


function getNum() {
  console.log(num) 
  var num = 1  
}
getNum()

此处同样输出 undefined,因为函数内部的变量声明被提升到了函数作用域的顶端。其等价于:


function getNum() {
  var num 
  console.log(num) 
  num = 1  
}
getNum()

除了变量提升,函数声明同样存在提升现象。JavaScript 中具名函数的声明主要有两种形式:


//函数声明式:
function foo () {}
//变量形式声明: 
var fn = function () {}

当使用变量形式声明函数时,其行为与普通变量类似,存在提升现象;而函数声明式则会提升到作用域的最前端,并将声明内容一并提升。示例如下:


fn()
var fn = function () {
	console.log(1)  
}
// 输出结果:Uncaught TypeError: fn is not a function

foo()
function foo () {
	console.log(2)
}
// 输出结果:2

可见,若使用变量形式声明 fn 并在赋值前执行,会报错“fn不是一个函数”,因为此时 fn 仅是一个未赋值的变量,无法作为方法执行。

2. 为什么会有变量提升?

变量提升与 JavaScript 的编译过程紧密相关:JavaScript 与其他语言一样,需经历编译和执行阶段。在短暂的编译阶段,JS 引擎会收集所有变量声明并提前使其生效;其余语句则需等到执行阶段,具体执行到某一行时才生效。这就是变量提升背后的机制。

那么,JavaScript 中为何存在变量提升这一特性?

首先需从作用域说起。作用域定义了变量的定义区域,决定了变量的生命周期。通俗理解,作用域即变量与函数的可访问范围,控制着变量和函数的可见性及生命周期。

在 ES6 之前,作用域主要分为两种:

  • 全局作用域中的对象可在代码的任何位置访问,其生命周期伴随页面生命周期。
  • 函数作用域指在函数内部定义的变量或函数,仅能在函数内部访问。函数执行结束后,内部定义的变量将被销毁。

相比之下,其他语言普遍支持块级作用域。块级作用域指由一对大括号包裹的代码段,如函数、判断语句、循环语句,甚至单独的 {} 均可视为一个块级作用域(注意,对象声明中的 {} 不是块级作用域)。简言之,若语言支持块级作用域,代码块内部定义的变量在外部不可访问,且代码块执行完毕后,内部变量将被销毁。

ES6 之前不支持块级作用域,因此将作用域内部的变量统一提升是最快速、简单的设计。这直接导致函数中的变量无论在哪里声明,在编译阶段都会被提取到执行上下文的变量环境中,使得这些变量在整个函数体内部任何地方均可访问,即 JavaScript 中的变量提升。

使用变量提升主要有以下两个好处:

(1)提高性能

在 JS 代码执行前,会进行语法检查和预编译,且该操作仅执行一次。此举旨在提高性能,若无此步骤,每次执行代码前都需重新解析变量(函数),这显然是不必要的,因为变量(函数)的代码不会改变,解析一次即可。

在解析过程中,还会为函数生成预编译代码。预编译时,会统计声明了哪些变量、创建了哪些函数,并对函数代码进行压缩,去除注释、不必要的空白等。这样做的好处是,每次执行函数时可直接为该函数分配栈空间(无需再解析以获取声明了哪些变量、创建了哪些函数),且因代码压缩,执行速度更快。

(2)容错性更好

变量提升可在一定程度上提高 JS 的容错性,参考以下代码:


a = 1;
var a;
console.log(a); // 1

若无变量提升,这两行代码将报错;但因存在变量提升,该代码可正常执行。

虽然开发中可完全避免此类写法,但有时代码复杂,可能因疏忽而先使用后定义。由于变量提升的存在,代码仍能正常运行。当然,开发中仍应尽量避免变量先使用后声明的写法。

总结:

  • 解析和预编译过程中的声明提升可提高性能,使函数在执行时可预先为变量分配栈空间;
  • 声明提升还可提高 JS 代码的容错性,使一些不规范的代码也能正常执行。

3. 变量提升导致的问题

由于变量提升的存在,使用 JavaScript 编写与其他语言相同逻辑的代码,可能导致不同的执行结果。主要有以下两种情况。

(1)变量被覆盖

参考以下代码:


var name = "JavaScript"
function showName(){
  console.log(name);
  if(0){
   var name = "CSS"
  }
}
showName()

此处输出 undefined,而非“JavaScript”,为何?

首先,当执行 showName 函数调用时,会创建 showName 函数的执行上下文。随后,JavaScript 引擎开始执行 showName 函数内部的代码。首先执行的是:


console.log(name);

执行此代码需使用变量 name,代码中有两个 name 变量:一个在全局执行上下文中,值为 JavaScript;另一个在 showName 函数的执行上下文中,由于 if(0) 永远不成立,name 值为 CSS。应使用哪一个?应先使用函数执行上下文中的变量。因为在函数执行过程中,JavaScript 会优先从当前执行上下文中查找变量。由于变量提升的存在,当前执行上下文中包含了 if(0) 中的变量 name,其值为 undefined,因此获取到的 name 值为 undefined。 此处输出结果与其他支持块级作用域的语言不同,例如 C 语言输出的是全局变量,因此极易造成误解。

(2)变量没有被销毁


function foo(){
  for (var i = 0; i < 5; i++) {
  }
  console.log(i); 
}
foo()

使用其他大部分语言实现类似代码时,for 循环结束后,i 即被销毁;但在 JavaScript 代码中,i 的值并未被销毁,因此最后打印出来的是 5。这也是由变量提升导致的,在创建执行上下文阶段,变量 i 已被提升,因此当 for 循环结束后,变量 i 并未被销毁。

4. 禁用变量提升

为解决上述问题,ES6 引入了 let 和 const 关键字,使 JavaScript 也能像其他语言一样拥有块级作用域。let 和 const 不存在变量提升。以下用 let 声明变量:


console.log(num) 
let num = 1

// 输出结果:Uncaught ReferenceError: num is not defined

若改为 const 声明,结果相同——使用 let 和 const 声明的变量,其声明生效时机与具体代码的执行时机保持一致。

变量提升机制会导致诸多误操作:那些忘记被声明的变量无法在开发阶段被明显察觉,而是以 undefined 的形式隐藏在代码中。为减少运行时错误,防止 undefined 带来不可预知的问题,ES6 特意将声明前不可用做了强约束。不过,let 和 const 仍有区别,使用 let 关键字声明的变量是可以改变的,而使用 const 声明的变量其值是不可以改变的。

以下来看看 ES6 如何通过块级作用域解决上述问题:


function fn() {
  var num = 1;
  if (true) {
    var num = 2;  
    console.log(num);  // 2
  }
  console.log(num);  // 2
}
fn()

此代码中,有两个地方都定义了变量 num,函数块的顶部和 if 的内部。由于 var 的作用范围是整个函数,因此在编译阶段,会生成如下执行上下文:

前言

从执行上下文的变量环境可以看出,最终只生成了一个变量 num,函数体内所有对 num 的赋值操作都会直接改变变量环境中的 num 的值。因此上述代码最后输出的是 2,而对于相同逻辑的代码,其他语言最后一步输出的值应该是 1,因为在 if 里面的声明不应影响块外面的变量。

下面将 var 关键字替换为 let 关键字,看看效果:


function fn() {
  let num = 1;
  if (true) {
    let num = 2;  
    console.log(num);  // 2
  }
  console.log(num);  // 1
}
fn()

执行此代码,其输出结果与预期一致。这是因为 let 关键字支持块级作用域,因此在编译阶段,JavaScript 引擎并不会把 if 中通过 let 声明的变量存放到变量环境中,这意味着在 if 中通过 let 声明的关键字,并不会提升到全函数可见。因此在 if 之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这符合我们的习惯:作用块内声明的变量不影响块外面的变量。

5. JS 如何支持块级作用域

那么问题来了,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?下面从执行上下文的角度来看看原因。

JavaScript 引擎是通过变量环境实现函数级作用域的,那么 ES6 又是如何在函数级作用域的基础之上,实现对块级作用域的支持呢?先看以下代码:


function fn(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
      console.log(d)
    }
    console.log(b) 
    console.log(c)
}   
fn()

当此代码执行时,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按顺序执行代码。let 关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的?

(1)创建执行上下文

创建的执行上下文如图所示:

前言

通过上图可知:

  • 通过 var 声明的变量,在编译阶段会被存放到变量环境中。
  • 通过 let 声明的变量,在编译阶段会被存放到词法环境中。
  • 在函数作用域内部,通过 let 声明的变量并没有被存放到词法环境中。

(2)执行代码

当执行到代码块中时,变量环境中 a 的值已被设置为 1,词法环境中 b 的值已被设置为 2,此时函数的执行上下文如图所示:

前言

可见,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,该区域中的变量并不影响作用域块外面的变量。例如,在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实,在词法环境内部,维护了一个栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。这里的变量是指通过 let 或者 const 声明的变量。

接下来,当执行到作用域块中的 console.log(a) 时,就需要在词法环境和变量环境中查找变量 a 的值了。查找方式:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎;如果没有查找到,那么继续在变量环境中查找。这样变量查找就完成了:

前言

当作用域块执行结束后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如图所示:

前言

块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现。通过这两者的结合,JavaScript 引擎就同时支持了变量提升和块级作用域。

6. 暂时性死区

最后再来看看暂时性死区的概念:


var name = 'JavaScript';
{
	name = 'CSS';
	let name;
}

// 输出结果:Uncaught ReferenceError: Cannot access 'name' before initialization

ES6 规定:如果区块中存在 let 和 const,这个区块对这两个关键字声明的变量,从一开始就形成了封闭作用域。假如尝试在声明前去使用这类变量,就会报错。这一段会报错的区域就是暂时性死区。上述代码的第 4 行上方的区域就是暂时性死区。

若想成功引用全局的 name 变量,需把 let 声明去掉:


var name = 'JavaScript';
{
	name = 'CSS';
}

此时程序即可正常运行。其实,这并不意味着引擎感知不到 name 变量的存在,恰恰相反,它感知到了,而且它清楚地知道 name 是用 let 声明在当前块里的。正因如此,它才会给这个变量加上暂时性死区的限制。一旦去掉 let 关键字,它也就不起作用了。

其实,这也就是暂时性死区的本质:当程序的控制流程在新的作用域进行实例化时,在此作用域中用 let 或者 const 声明的变量会先在作用域中被创建出来,但此时还未进行词法绑定,所以是不能被访问的,如果访问就会抛出错误。因此,在这运行流程进入作用域创建变量,到变量可以被访问之间的这段时间,就称之为暂时死区。

在 let 和 const 关键字出现之前,typeof 运算符是百分之百安全的,现在也会引发暂时性死区的发生,像 import 关键字引入公共模块、使用 new class 创建类的方式,也会引发暂时性死区,究其原因还是变量的声明先于使用。


typeof a    // Uncaught ReferenceError: a is not defined
let a = 1

可见,在 a 声明之前使用 typeof 关键字报错了,这就是暂时性死区导致的。

总结

到此这篇 JavaScript 变量提升的文章就介绍到这了,更多相关 JavaScript 变量提升内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章