JavaScript执行上下文等基础知识

287 阅读13分钟

一、变量提升

1、变量声明和赋值

var name = '变量的声明和赋值'

这一行代码应该看成两句话:

// 变量提升 start
var name = undifined
// 变量提升 end

// 可执行代码 start
name = '变量的声明和赋值'
// 可执行代码 start

2、函数的声明和赋值

function foo(){
  console.log('foo')
}

var bar = function(){
  console.log('bar')
}

这段代码应该看成像下面这样

// 变量提升 start
function foo(){ //声明和赋值是同时的
  console.log('foo')
}

var bar = undefined
// 变量提升 end

// 可执行代码 start
bar = function(){
  console.log('bar')
}
// 可执行代码 end

3、综合

showName()
showName2()
console.log(name)
var name = '我是name变量'
function showName() {
    console.log('我是showName函数')
}
var showName2 = function() {
    console.log('我是showName2函数')
}

这段代码应该看成下面这样

// 变量提升 start
var name = undifined
function showName() {
    console.log('我是showName函数')
}// 声明和赋值同时发生
var showName2 = undifined
// 变量提升 end

// 可执行代码 start
showName()
showName2() //报错了,showName2不是一个函数,下面的代码不会再执行了.
console.log(name)
name = '我是name变量'
showName2 = function() {
    console.log('我是showName2函数')
}
// 可执行代码end

二、执行上下文和它的变量环境

ES6之前还没有let和const,var声明的变量放到变量环境里面。

1、编译

编译和变量提升的关系:编译生成执行上下文可执行代码.执行上下文中保存了变量提升的内容,放在一个叫做变量环境的对象里头.可执行代码被放到代码空间里头。

执行上下文当然不止变量环境这一个东西.变量环境只是执行上下文的子集.这里讲ES5的编译,还没有涉及到变量环境之外的可执行上下文的内容.你暂时可以把ES5的执行上下文理解为变量环境.

2、执行

编译完之后就会按可执行代码的顺序一行行执行下去.

3、调用栈

当一段代码要被执行的时候,先要进行编译.但其实代码的编译不是一次性完成的,而是通过调用栈来一边编译一边执行的.当调用函数的时候,会再次编译产生新的执行上下文,调用栈即用来存放执行上下文.

JavaScript代码执行过程是这样的:

  1. 编译.编译全局代码并创建全局执行上下文和全局可执行代码.全局执行上下文会被放到栈底部.

  2. 执行可执行代码.此时,若遇到以下两种情况,会再编译一次产生新的执行上下文和可执行代码.这个执行上下文会入栈.

    • 函数调用
    • 调用eval()
  3. 执行新的可执行代码.

  4. 若在新的可执行代码中又出现函数调用、eval时,就会创建新的执行上下文.

  5. 当最栈中最顶层的函数返回的时候,执行上下文才会一个个出栈.

三、执行上下文的新朋友:词法环境

ES6出现了const和let,它们被保存在词法环境里面

而且ES6之前的JavaScript只支持全局作用域和函数作用域,而ES6的let和const的出现使得JavaScript有了块级作用域.

执行上下文添加了词法环境来实现块级作用域.

现在执行上下文由变量环境和词法环境构成.

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

根据上面这块代码,来看看有了let、const和词法环境之后,编译和执行有什么不同.

编译和执行

有了let和const,编译产生的执行上下文的内容就不一样了.

现在执行上下文增加了一个词法环境的对象,词法环境通过栈结构来存放let和const相关的内容.

  1. 首先编译生成包含变量环境和词法环境的执行上下文和可执行代码

    • 词法环境里面专门用来保存let和const声明的变量.但第一步编译的现在不包括块级作用域中的let和const变量,仅包含一个b=undefined.

    • 变量环境用来保存当前作用域中所有var声明的变量以及函数,即:a = undefined,c = undefined.

    • 然后代码可以看成下面这样

    • function foo(){
          // 变量环境 start
          var a = undefined
          var c = undefined
          // 变量环境 end
          
          // 词法环境 start
          let b = undefined
          // 词法环境 end
          
          // 可执行代码 start
          a = 1
          b = 2
          {
            let b = 3
            c = 4
            let d = 5
            console.log(a)
            console.log(b)
          }
          console.log(b) 
          console.log(c)
          console.log(d)
          // 可执行代码
      }  
      
  2. 执行可执行代码

    • 执行a=1和b=2,现在执行上下文中的这两个a和b的内容有了值.

    • 执行块级作用域的内容,此时要先对块级作用域的内容做一下"编译".即把块级作用域里面的用let声明的b和d打包在一起压入词法环境栈.此时词法环境栈的栈顶内容是b=undefined,d=undefined.,此时代码长这样

    • function foo(){
          // 变量环境 start
          var a = 1
          var c = undefined
          // 变量环境 end
          
          // 词法环境 start
          let b = 2
          // 词法环境 end
          
          // 可执行代码 start
          {
            let b = undefined
            let d = undefined
            b = 3
            c = 4
            d = 5
            console.log(a)
            console.log(b)
          }
          console.log(b) 
          console.log(c)
          console.log(d)
          // 可执行代码
      }  
      
    • 执行块级作用域中一部分可执行代码,现在代码长这样

    • function foo(){
          // 变量环境 start
          var a = 1
          var c = 4
          // 变量环境 end
          
          // 词法环境 start
          let b = 2
          // 词法环境 end
          
          
          {
          // 词法环境的栈顶 start
            let b = 3
            let d = 5
          // 词法环境的栈顶 end
            
          // 可执行代码 start
            console.log(a)
            console.log(b)
          }
          console.log(b) 
          console.log(c)
          console.log(d)
          // 可执行代码
      }
      
    • 执行块级作用域的两行打印操作,打印出a、b分别为1和3.查找顺序为词法环境栈顶到栈底-->变量环境此时词法环境的栈顶元素出栈,此时代码长这样

    • function foo(){
          // 变量环境 start
          var a = 1
          var c = 4
          // 变量环境 end
          
          // 词法环境 start
          let b = 2
          // 词法环境 end
          
          // 可执行代码 start
          console.log(b) 
          console.log(c)
          console.log(d)
          // 可执行代码 end
      }
      
    • 执行函数作用域的三行打印操作,打印出b、c、d分别为2、4和ReferenceError: d is not defined

三、作用域链和闭包

1、作用域链

调用一个函数时,要是有一句可执行代码,出现了这个函数的执行上下文中没有出现的变量,该怎么办呢?

答案当然是去另外一个作用域的执行上下文找.那去个作用域的执行上下文找呢?

不按专业术语来讲的话,就是看函数在哪个作用域声明的,如果当前执行的函数是在全局作用域声明的,就在全局执行上下文去找;如果当前正在执行的函数是在一个另函数里面定义的,就在那个函数的执行上下文里面找.跟函数在哪个地方调用的没有关系.

按专业术语来讲的话,就是通过词法作用域来找的.

作用域链就是按上面这个规则一步步查找变量,形成的这个查找链条.

下面解释以下什么叫词法作用域

词法作用域:作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

顺便补充一句,在执行上下文查找的规则是从词法环境的栈顶往下查找,直到查找完词法环境,找到就返回.没有找到就查找变量环境.

2、闭包

上面讲了,调用一个函数时,要是有一句可执行代码,出现了这个函数的执行上下文中没有出现的变量,该怎么办.假如这个被调用的函数,是在B函数内部声明的,那么这个函数的作用域链的下一级就是B函数.那么查找变量时的下家就是B函数.

看这一种情况:

function func1() {
    let a = 1;
    return function() {
		console.log(a)
    }
}

let a = 100

let func2 = closure()

func()

那func函数的结果是100还是1呢?

结果是1,原因如下

首先func调用时,在自身的执行上下文中找不到a变量,所以就要根据词法作用域的规则去找a.根据词法作用域,它要到closure函数的执行上下文去找,这样就找到了a=1.

一般来讲,调用栈的栈顶函数执行完毕,ESP会下移,然后这个栈顶函数的执行上下文就被销毁了.

那这个a=1是怎么保存起来的呢?

  • 当JavaScript引擎执行到func1函数时,首先会编译,并创建一个空执行上下文.
  • 在编译过程中,遇到内部函数时,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了func1函数中的a变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建一个“closure(func1)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存a变量.

所以即使func1的执行上下文已经被销毁,它的内部函数也能够访问到它的被引用的变量.

产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中。

闭包的回收

  • 如果引用闭包的变量是全局的,那么闭包会一直存在直到页面关闭.

  • 如果引用闭包的变量是函数内部的,那么闭包会在这个函数执行上下文销毁之后,在下次垃圾回收器工作的时候就被回收.

所以,如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量.

四、this机制

this机制和作用域链的关系不大。this是和执行上下文绑在一起的

全局执行上下文中的this指向的是window(浏览器环境)或global(node环境)

函数执行上下文中的this指向的默认也是window或global

除此之外,this还有一些特殊情况

1、三种特殊情况

① 对象方法调用

对象调用它自己的第一层级的方法时,this指向的是它本身

对象调用它自己的第二层级或以上的方法时,this指向的还是全局

// 第一种情况
// this在第一层级
var obj = {
    a : 1,
    b : function() {
        console.log(this.a)
    }
}

var a = 100

obj.b() //1

// 第二种情况
// this在第二层级
var obj2 = {
    a : 1,
    b : function() {
        var c = function() {
            console.log(this.a)
        }
        c()
    }
}

var a = 100
obj2.b() //100

// 第二种情况的解决办法
// 法一
var obj2 = {
    a : 1,
    b : function() {
        let that = this
        var c = function() {
            console.log(that.a)
        }
        c()
    }
}

var a = 100
obj2.b() //1

// 法二
// 箭头函数并不会创建其自身的执行上下文,箭头函数中的this取决于它所在的执行上下文。
var obj2 = {
    a : 1,
    b : function() {        
        var c = () => {
            console.log(this.a)
        }
        c()
    }
}

var a = 100
obj2.b() //1

② 箭头函数中的this

箭头函数不会创建执行上下文,它的this会继承它所在的执行上下文的this。要么来自全局执行上下文,要么来自函数执行上下文。

// 此处箭头函数的this继承的是b这个function的this
var obj2 = {
    a : 1,
    b : function() {        
        var c = () => {
            console.log(this.a)
        }
        c()
    }
}

var a = 100
obj2.b() //1

// 此处箭头函数的this继承的是全局执行上下文的this
var obj = {
  a : 1,
  b : () => {
      console.log('我是this',this.a)
  }
}

var a = 100
obj.b() 

③ 构造函数的this

function CreateObj(name){
  this.name = name
}
var myObj = new CreateObj('张三')

console.log(myObj.name)

2、改变this指向

call/apply/bind

bind只是绑定参数,没有调用,就像一个赋值语句那么简单,函数调用的时候会找到这个参数。

call/apply调用函数,不传参数时,它们是一样的,把要绑定的this作为call/apply调用时的第一个参数。若传参,apply就是把函数参数放到一个数组里面,把数组作为apply调用的第二个参数传入;而call就是把函数参数从call调用的第二个位置开始一个个传入。

var a = function() {
    console.log(this.c)
}

var b = {
    c : 1,
    d : 2,
}

var c = 100
a.call(b) //1

五、V8是如何执行一段JavaScript代码的

  • 首先:对源代码进行词法分析语法分析生成抽象语法树和执行上下文
  • 接下来,解释器解释抽象语法树生成字节码
  • 最后,解释器逐行解释执行代码,就是先根据字节码逐行生成机器码给cpu执行

即时编译(JIT)

在解释器逐行解释代码的过程中,若发现有热点代码(多次重复执行的代码),后台的编译器就会把这段热点代码直接生成机器码。

六、如何优化JavaScript执行效率

单次脚本的执行时间脚本的网络下载方式

  • 减少单次JavaScript执行时间
  • 同步的脚本尽可能小、尽量内联
  • 大一点的脚本尽可能异步,比如aysnc和defer

七、总结

  • 编译生成可执行代码和执行上下文,执行上下文的内容有变量环境、词法环境、this。函数作用域还有outer,用来实现作用域链。outer的指向是由函数声明的位置来决定的,跟函数在哪儿调用的无关。
  • 编译后,变量环境里面的内容包括函数和var声明的变量。
  • 词法环境包括let、const声明的变量。词法环境维护了一个小型的栈结构,用来实现块级作用域
  • 有关this。不管是函数执行上下文还是全局执行上下文,this的指向默认都是window/global;对象调用它自身第一层级的方法时,this指向这个对象;箭头函数中的this会继承它所在的执行上下文;对构造函数的this,要知道new的过程;call/apply/bind可以改变this指向。
  • 闭包是指,根据词法作用域的规则,内部函数的outer指向外部函数。在函数编译阶段,遇到内部函数时,JavaScript引擎要对内部函数做一次快速的词法扫描,发现内部函数引用了外部函数的变量,所以JavaScript会判断这是一个闭包,于是在堆空间创建一个闭包的对象,用来保存那个被内部函数所引用的变量,栈内存中有这个闭包对象的引用。在外部函数返回的时候,执行上下文被销毁,但是闭包会被保存下来。
  • JavaScript是解释型语言,利用解释器编译执行。采用JIT技术,对热点代码进行了优化。
  • 要优化JavaScript执行效率,减少JavaScript代码的单次执行时间,并且同步的脚本尽量小和内联,大脚本文件尽量defer或者async加载。