本文深入解析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 变量提升内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!