变量提升

150 阅读10分钟

变量提升是什么?

在js代码执行过程中,js引擎会把变量和函数声明部分提到代码前头,提到前头的变量和函数默认给予undefined值。

这个现象是js代码的设计缺陷,会导致和其他语言不太一样的逻辑和结果,因此后来在ES6中,出现了let const和块级作用域,来解决这一问题。但由于向下兼容,这情况一直存在。

es6之前,用var声明变量,其声明的变量不管写在代码哪个位置,该变量都会提升到变量作用域的顶端

var声明变量

全局作用域声明变量

console.log(num) // undefined
var num = 1
等价于
var num
console.log(num)
num = 1

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

函数作用域声明变量

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

结论:var函数内的num被提升到函数作用于顶端

var声明函数

函数声方式有两种,一种是变量形式,一种是命名函数形式

// 函数的变量声明形式
var fn = function(){}
// 函数的具名声明形式
function fn(){}

这两种都会变量提升,区别是:具名形式内容也会提升,所以具名形式不管哪里调用都不会出错

image.png

为什么有变量提升?

js语言也有编译和执行阶段。

编译阶段:js引擎会搜集所有变量和函数的声明,将其提前生效。

执行阶段:除了声明类的代码,其他等到代码执行到具体的代码行,才生效。

这就是变量提升后边的机制,那为什么有这个机制呢?-作用域

作用域

作用域是指变量和函数的可访问位置,作用域决定了变量和函数的生命周期。

在es6 之前,作用域分为全局和函数作用域。

全局作用域:声明的变量在代码任何地方都能访问到,其生命周期跟随页面的生命周期。

函数作用域:在函数内部声明的变量和函数,其只有在函数的内部能够访问,函数执行结束,函数内声明的变量和函数全部销毁。

在es6之后,出现了块级作用域,块级作用域就是一对{}包裹的代码,除了对象的声明,其他:函数体{}、循环体{}、判断体{}都属于块级作用域。甚至一个单独的{}都可以被看作是一个块级作用域。

块级作用域 在块级作用域内部声明的变量在代码块外部访问不到,并且当代码块内的代码执行完毕,代码块内的变量就会被销毁。

在es6之前没有块级作用域,只有全局和函数作用域,因此通过变量提升特性,在对应的作用域将变量进行提前提升,这样在作用域的任何地方都能访问到该变量。

变量提升好处?

容错性好

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

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

如果没有变量提升,这两行代码就会报错,但是因为有了变量提升,这段代码就可以正常执行。

虽然在可以开发过程中,可以完全避免这样写,但是有时代码很复杂,可能因为疏忽而先使用后定义了,而由于变量提升的存在,代码会正常运行。当然,在开发过程中,还是尽量要避免变量先使用后声明的写法。


性能好

在JS代码执行之前,会进行语法检查和预编译,并且这一操作只进行一次。这么做就是为了提高性能,如果没有这一步,那么每次执行代码前都必须重新解析一遍该变量(函数),这是没有必要的,因为变量(函数)的代码并不会改变,解析一遍就够了。

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

总结:

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

变量提升可能带来的问题?

变量被覆盖

var num = 1
function getNum () {
    console.log(num)  // undefined
    if(1){
        var num = 9
    }
}
getNum()

分析:执行getNum时,创建该函数的执行上下文,该执行上下文中,num被提升,但是先使用后声明,因此打印undefined。

注意: var声明的变量在函数/全局 作用域中,不管位置在哪里,(即循环体,方法体,条件体)都会提升

变量未被销毁

function fn(){
    for(var i = 0;i<9;i++){
        
    }
    console.log(i)   // 9
}
fn()

分析: 在fn执行上下文中,var声明的变量i会提升在函数作用域顶端,因此只要在fn内都能访问到,尽管在循环结束后,造成了变量循环外未销毁,还能访问

禁用变量提升

通过let/const 声明变量,不会进行变量提升。声明的变量的生效时机就是执行时机。二者区别是:const常量。

image.png

let/const对变量覆盖的解决案例

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

分析:

1、函数中,var声明的变量都会提升,不管位置。因此有两个同名变量num被一起提升在getNum函数作用域顶端。

2、num = 1,num=2

3、打印都是2

---官方解释------------------:

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

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

改造:

function getNum(){
    let num = 1
    if(1){
        let num = 2
        console.log(num)     // 1
    }
    console.log(num)    // 2
}
getNum()

分析:

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

js底层怎么支持块级作用域

js 怎么即能支持函数作用域,又能支持块级作用域?我们通过一个例子来看看,底层的执行上下文做了什么?

function fn(){
    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(d)  // 5
    }
    console.log(b) // 2
    console.log(c) // 4
}   
fn()

分析: 执行这段代码时,js引擎会首先编译并创建执行上下文,然后顺序执行代码。

(1)创建执行上下文

image.png

可见:

  • 通过var声明的变量在编译阶段会存放到变量环境;
  • 通过let声明的变量在编译阶段会存放到词法环境;
  • 函数内,单独代码块{}内声明的let变量没有放到词法环境中。

(2)执行到代码块的执行上下文

当执行到代码块时,函数内,代码块外,的变量ab已经赋值1,2.而代码块内的let变量会在词法环境进栈,一个代码块一个栈。

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

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

(3)执行到代码块末尾的执行上下文

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

(4)执行完代码块的执行上下文

当代码块执行完毕之后,词法环境中代码块对应的栈,就会从栈顶弹出,销毁对应栈的变量(let)

image.png

可见,块级作用域通过执行上下文的词法环境的栈结构实现,而变量提升由变量环境实现,二者结合,js引擎就实现了变量提升和块级作用域

暂时性死区

用let/const声明的变量,在声明前就使用,就会造成暂时性死区。

*1 var name = 'JavaScript';
*2 {
*3	name = 'CSS';
*4 	let name;
*5 }

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

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

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

也就是,程序进入代码块,搜索到声明的变量,再放到词法环境。就会造成暂时性死区。

重要说明

  1. 同名函数与变量的提升中,函数优先级高于变量。
  2. 函数(非表达式)在编译阶段解析后,执行时不二次解析并赋值。
  3. 函数表达式只有执行阶段才会赋值。
  4. 如果有两个同名函数(非表达式),那么提升阶段也会有覆盖问题,且二者都不会在执行阶段再赋值。

demo

证明1

image.png

证明2

image.png

证明3

image.png

证明4

image.png