变量提升

1,640 阅读20分钟

1. 什么是变量提升?

通俗来说,变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined。 正是由于 JavaScript 存在变量提升这种特性,导致了很多与直觉不太相符的代码,这也是 JavaScript 的一个设计缺陷。虽然 ECMAScript6 已经通过引入块级作用域并配合使用 let、const 关键字,避开了这种设计缺陷,但是由于 JavaScript 需要向下兼容,所以变量提升在很长时间内还会继续存在。

/* 你应该见过下面的类似代码,那你知道这是为什么*/
console.log(a)  // undefined
var a = 24

变量提升是当栈内存作用域形成时,JS代码执行前,浏览器会将带有var, function关键字的变量提前进行声明 declare(值默认就是 undefined),定义 defined(就是赋值操作),这种预先处理的机制就叫做变量提升机制也叫预定义。

在变量提升阶段:带 var 的只声明还没有被定义,带 function 的已经声明和定义。所以在代码执行前有带 var 的就提前声明,比如这里的 a 就赋值成 undefined在代码执行过程中遇到创建函数的代码浏览器会直接跳过。

如果当前变量没有用var来声明,那么它被认为是全局变量,执行到的时候才会有赋值等操作,不会进行变量提升

带 var 和不带 var 的区别

  • 全局作用域中不带var声明变量虽然也可以但是建议带上 var声明变量,不带 var 的相当于给window对象设置一个属性罢了。
  • 函数作用域,带 var 的是私有变量。不带 var 的是会向上级作用域查找,如果上级作用域也没有那么就一直找到 window 为止,这个查找过程叫作用域链(这里记住往上找就行,当前属性并没有提前声明)。
  • 全局作用域中使用 var 申明的变量会映射到 window 下成为属性,且没有变量提升。

在 ECMAScript6 之前,JS 引擎用 var 关键字声明变量。在 var 时代,不管变量声明是写在哪里,最后都会被提到作用域的顶端。 下面在全局作用域中声明一个num 变量,并在声明之前打印它:

var声明变量

全局作用域声明变量
console.log(num) 
var num = 24
等价于
var num
console.log(num)
num = 24

结论:var全局的num被提升到全局作用域顶端

函数作用域声明变量

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

function getNum() {
  console.log(num) // undefined
  var num = 24 
}
getNum()
等价于
function getNum() {
  var num 
  console.log(num) 
  num = 24
}
getNum()

结论:函数内部的变量声明会被提升至函数作用域的顶端

var声明函数(变量形式声明的提升)

函数实际上也是存在提升的。JavaScript中具名的函数的声明形式有两种:

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

当使用变量形式声明函数(只声明还没有被定义)时,和普通的变量一样会存在提升的现象(也就是说它和本身的变量是没有区别的,如果遇到同名变量会直接按照先后顺序去覆盖),而函数声明式会提升到作用域最前边,并且将声明内容一起提升到最上边(不仅声明而且被定义了)。如下:

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

函数声明式提升


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

可以看到,使用变量形式声明fn并在其前面执行时,会报错fn不是一个函数,因为此时fn只是一个变量,还没有赋值为一个函数,所以是不能执行fn方法的。

函数提升优于变量提升

函数提升优于变量提升;函数声明时,直接提升到作用域最前面,等到真正执行到当前代码时,不会二次声明,但是变量声明的形式虽然也提升,但是只声明,不定义,需要真正执行到的时候才会进行定义等实际的操作

例子如下:

/* 
* 函数声明优于变量声明
* 函数表达式会将变量提升,但是代码在执行的时候才会被定义(赋值)(非常重要)
*/
foo(); // 我是函数
console.log(foo); // foo() { console.log('我是函数');},此时函数提升,函数不仅声明了还定义了,但是变量只声明还没有定义,所以此时还是函数
function foo() {
  console.log('我是函数');
}
console.log(foo); // foo() { console.log('我是函数');},此时函数提升,函数不仅声明了还定义了,但是变量只声明还没有定义,所以此时还是函数
var foo = '我是变量';
console.log(foo); // 我是变量,此时同名变量进行了覆盖
var foo = function (params) {
  console.log('我是函数表达式');
}
console.log(foo); // function (params) { console.log('我是函数表达式');}
foo(); // 我是函数表达式,此时同名变量进行了覆盖,通过变量提升的函数和通过变量提升的变量没有区别

解释:函数声明优于变量声明,所以第一个console.log(foo),会输出函数foo(),foo()输出'我是函数'。而变量只有当代码执行到该语句的时候才会被赋值并且覆盖内存。所以第三个console.log(foo)输出的是变量'我是变量'。最后函数表达式又覆盖了前面的代码,所以最后一个console.log(foo)输出函数表达式foo(),foo()输出'我是函数表达式'。

下面我们把代码顺序调换一下

foo(); // 我是函数
console.log(foo); // foo() { console.log('我是函数');}
var foo = '我是变量';
console.log(foo); // 我是变量
var foo = function (params) {
  console.log('我是函数表达式');
}
console.log(foo); // function (params) {console.log('我是函数表达式');}
function foo() {
  console.log('我是函数');
}
console.log(foo); // function (params) {console.log('我是函数表达式');}
foo(); // 我是函数表达式

这次我将函数声明放到了最下面,但是却没有最后一次执行foo()输出的却是我是函数表达式。 解释:函数在提升的时候解析器就已经解析过一遍了,代码执行到函数这里不会再次解析也就不会再次赋值。因此就不会出现覆盖前面代码。

注意:如果两个函数名一样,后面的会覆盖前面的(认为是在函数解析的时候覆盖,即提升的时候就覆盖了)
console.log(foo); // foo() {console.log('我是函数提升同名覆盖');}
function foo() {
  console.log('我是函数');
}
console.log(foo); // foo() {console.log('我是函数提升同名覆盖');}
function foo() {
  console.log('我是函数提升同名覆盖');
}
console.log(foo); // foo() {console.log('我是函数提升同名覆盖');}
foo(); // 我是函数提升同名覆盖
console.log(a, b) 
var a =8, b ='kobe'
function foo(){
// 2
    console.log(a, b)
// 3
    var a = b = 24
    console.log(a, b)
}
foo()
console.log(a, b)

/* 输出:
    undefined undefined
    undefined "kobe"
    24 24
    8 24
*/

解释: 第一段console,当前a,b变量提升了,但是没有声明,还是undefined 第二段里面var a = b = 24 => var a = 24, window.b = 24,当前函数内有a,但是只提升了,还没有声明,所以为undefined,函数内没有b,b=24还没有执行到,所以沿着作用域链往上找b='kobe' 第三段里面执行了a = 24,window.b = 24,所以a在函数内找到24,b沿着作用域链找到了window,b为24 第四段是在全局执行的,此时a,b均为全局的a,b,所以是8,24

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 = "kobe"
function showName(){
  console.log(name);
  if(0){
   var name = "bryant"
  }
}
showName()

这里会输出 undefined,而并没有输出“kobe”,为什么呢?

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

console.log(name);

执行这段代码需要使用变量 name,代码中有两个 name 变量:一个在全局执行上下文中,其值是JavaScript;另外一个在 showName 函数的执行上下文中,由于if(0)永远不成立,所以 name 值是 bryant。那该使用哪个呢?应该先使用函数执行上下文中的变量。因为在函数执行过程中,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 = 8;
  if (true) {
    var num = 24;  
    console.log(num);  // 24
  }
  console.log(num);  // 24
}
fn()

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

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

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

function fn() {
  let num = 8;
  if (true) {
    let num = 24;  
    console.log(num);  // 24
  }
  console.log(num);  // 8
}
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 = 'kobe';
{
	name = 'bryant';
	let name;
}

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

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

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

var name = 'kobe';
{
    name = 'bryant';
}

这时程序就能正常运行了。其实,这并不意味着引擎感知不到 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关键字报错了,这就是暂时性死区导致的。

总结:

  1. 变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined。

  2. 在变量提升阶段:带 var 的只声明还没有被定义,带 function 的已经声明和定义 如果当前变量没有用var来声明,那么它被认为是全局变量,执行到的时候才会有赋值等操作,不会进行变量提升

  3. 当使用变量形式声明函数(只声明还没有被定义)时,和普通的变量一样会存在提升的现象(也就是说它和本身的变量是没有区别的,如果遇到同名变量会直接按照先后顺序去覆盖),而函数声明式会提升到作用域最前边,并且将声明内容一起提升到最上边(不仅声明而且被定义了

  4. 函数提升优于变量提升;函数声明时,直接提升到作用域最前面,等到真正执行到当前代码时,不会二次声明,但是变量声明的形式虽然也提升,但是只定义,不声明,需要真正执行到的时候才会进行声明赋值等实际的操作

  5. 可以通过块级作用域解决变量提升的问题,是通过词法环境解决的,跟变量环境(es5)是有区别的,是单独存储的

  6. 作用域链是在函数定义的时候确定的. 在函数内定义的变量不能在函数之外的任何地方访问,因为变量仅仅在该函数的域的内部有定义。相对应的,一个函数可以访问定义在其范围内的任何变量和函数。换言之,定义在全局域中的函数可以访问所有定义在全局域中的变量。在另一个函数中定义的函数也可以访问在其父函数中定义的所有变量和父函数有权访问的任何其他变量。

技术交流沟通➕V:yinzhixiaxue

参考文档: segmentfault.com/a/119000003… juejin.cn/post/684490…