JS执行过程、作用域提升、作用域链以及闭包

406 阅读8分钟

前言

本篇文章将根据ECMA早期的规范,从js执行过程的角度,介绍作用域提升以及闭包的概念,内容都是我学到的加上一些个人的理解,如果有错误的话请在评论区指正。

JS的执行过程

让我们先来看下面三段代码,我们将由浅入深的介绍JS的执行过程

var num1 = 1
console.log(num2)
var num2 = 2
var num1 = 1
function foo(){
    console.log(num1)
    console.log(num2)
}
foo()
var num2 = 2

var num1 = 1
function foo(){
    var inner = 1
    console.log(num1)
    return function(){
        console.log(inner)
    }
}
let foo2 = foo()
foo2()
var num2 = 2

第一段代码(只有声明变量)的执行过程

var num1 = 1
console.log(num2)
var num2 = 2

首先,执行代码前,代码被解析,v8引擎内部会帮助我们创建一个对象(GlobalObject简称GO),扫描所有的代码,将一些内置的类和函数,挂载到GO对象上,同时也将代码中用var定义的变量(例如第一段代码中的num1和num2)挂载到GO上,但是这些被挂载的变量并没有被赋值(值为undefined)

image.png

v8引擎内部有一个执行上下文栈(Execution Context Stack),js代码都是在这个执行上下文栈中执行的。为了执行全局代码,我们会创建全局执行上下文对象并压入栈中。

创建全局执行上下文并且压入栈的过程分为两步骤:创建Variable Object指向Global Object;执行全局代码

image.png

在执行全局代码的过程中:

  • 执行到第一行,会对GO对象中的num1赋值(赋值为1)。 image.png

  • 执行到第二行,会在VO对象指向的GO对象中查num2的声明,发现此时num2是undefined(还没有赋值),所以输出undefined。

  • 执行到第三行,会给GO对象中的num2赋值(赋值为2)。

image.png

执行结束,全局执行上下文出栈,js代码执行结束。

image.png

第二段代码(有函数)的执行过程

var num1 = 1
function foo(){
    console.log(num1)
    console.log(num2)
}
foo()
var num2 = 2

在解析代码的时候,会扫描所有的变量的定义(普通变量的解析过程在前面描述过,这里不再重复,注意第六行的foo()是执行语句不是定义语句),由于函数在后面可能用不到,所以V8引擎只对函数声明作预解析

预解析指的是,只创建函数对象,但不解析函数内部的各种信息

检测到function foo()函数声明的时候 ,会在内存中开辟一个函数存储空间(函数对象) ,这个存储空间中保存了两个东西:父级作用域以及函数代码块。因为是在全局中定义的函数,所以父级作用域指向的是GO。GO中会挂载一个foo属性指向这个函数对象的内存空间。

image.png

注意,只有函数声明才有声明提前,函数表达式是不会声明提前的,例如var a = function (){}。区分函数声明和函数表达式的最佳方法就是看function是不是第一个单词

进入执行代码的过程,首先还是创建GO对象的全局执行上下文并且入栈,这个过程也还是包括两步骤:创建VO指向GO对象,执行全局代码块中的代码。

image.png

  • 执行到第一行的时候,给GO中的num1赋值为1
  • 执行到第六行foo()时,因为之前只对函数作了预解析,那么现在要真正地解析函数内部的信息。过程如下

在堆内存中创建一个Activation Object(下面简称AO)对象,并且扫描函数内部的变量声明(扫描过程跟全局代码的扫描过程一样),因为函数内部没有变量声明,所以Activation Object是一个空对象

扫描结束,进入执行函数阶段。前面提过,js代码都是在执行上下文栈中执行的,所以我们要创建foo函数的函数执行上下文并且入栈。这个过程跟全局执行上下文入栈大致一样,也分为两步骤:创建VO指向AO,执行代码块。

image.png 现在我们执行函数内部的代码:

  • 执行到console.log(num1)时,我们会首先在函数的AO对象中查找变量num1的定义,发现AO对象中没有,于是我们要根据parentScope往外层GO对象上寻找num1的定义。发现此时num1为1,于是我们输出1.

  • 执行到console.log(num2)时,我们的查找过程跟上面一样,只不过这时,num2的值为undefined,于是输出undefined。

执行完函数内部的代码,函数执行上下文出栈,AO对象被销毁

image.png

  • 执行到第七行,给num2赋值为2,全局代码执行结束,全局执行上下文出栈。js代码执行结束。

image.png

第三段代码(嵌套函数)的执行过程

var num1 = 1
function foo(){
    var inner = 1
    console.log(num1)
    function q(){
        console.log(inner)
    }
    return q
}
var foo2 = foo()
foo2()
var num2 = 2
  • 创建GO对象,扫描全局代码,扫描结束的时候会在GO上挂载num1,foo2,num2属性,值都是undefined,在堆内存中创建foo函数对象并且在GO对象上挂载。

image.png

  • 扫描完毕,进入执行阶段,创建全局执行上下文并且入栈,创建执行上下文包括两步骤:创建VO指向GO对象,执行全局代码。

  • 执行到第1行的时候,给GO的num1赋值为1

  • 执行到第10行的时候,开始解析foo函数内部的具体信息:创建foo函数的AO对象并且扫描foo函数内部的变量声明。扫描到var inner时在foo的AO对象上挂载了inner属性,扫描到function q(){}函数声明时,在内存中创建了函数q的函数对象(包括了parentSope和代码块),并且将内存地址挂载到foo函数的AO对象的q属性上。

image.png

  • foo函数扫描完毕,开始执行foo函数内部的代码,创建函数执行上下文并且入栈,创建函数执行上下文包括两步骤:创建VO指向AO,执行代码块。

image.png

  • 执行var inner = 1时会将AO中的inner赋值为1,执行到console.log(num1)时,会在foo函数的AO上去找该变量的定义,发现没找到,就会根据parentScope往父级查找。最终在GO对象中寻找到了num1的值为1,输出1

  • 执行return语句,返回p函数的内存地址,执行上下文出栈,但是foo的AO对象并不会被销毁。因为返回的q函数的地址在GO中通过foo2变量保存,也就是说,从GO开始,可以通过foo2找到q函数的内存空间,又通过q函数的parentScope可以找到foo的AO对象的存储空间,所以GC不会将foo的AO对象回收。

image.png

  • 执行foo2,因为foo2保存的是q函数对象的内存地址,于是创建q函数的AO对象并开始解析q函数内部的详细信息。因为q函数内部没有定义变量,所以q函数的AO对象是空对象。解析完之后创建q函数的执行上下文并且入栈

image.png

  • 开始执行q函数内部的代码console.log(inner)时,会在q函数的AO对象中查找inner变量,发现没有,就会根据q函数存储空间中保存的parentScope,也就是foo的AO中去找inner变量。如果foo的AO中也没有的话,就会根据foo函数存储空间保存的parentScope继续寻找,直到找到GO对象。此时已经在foo的AO对象中找到了inner,输出1

  • q函数执行结束,执行上下文出栈,q函数的AO对象销毁。

image.png

  • 全局代码执行var num2 = 2,将GO中的num2赋值为2,全局代码结束,全局执行上下文出栈,js代码运行结束。

总结:什么是作用域提升?

还记得前面解析代码的过程嘛,在解析代码的时候,会将var声明的变量挂载到GO或者是AO对象上,值为undefined,所以如果我们在声明语句前面打印这个变量,值是undefined。

函数作用域提升其实跟解析代码的过程也有关系,因为在解析代码的过程中我们是不会去解析执行语句的,所以函数声明被解析了,函数执行语句却没有被解析,所以我们可以在函数声明前面调用函数

总结:什么是作用域链?

还记得我们创建函数存储空间内部保存的parentScope吗?创建函数执行上下文,入栈之后执行代码块,需要某个变量的时候,会先在对应函数的AO对象中去寻找,如果找不到的话,就会根据函数存储空间里保存的parentScope指向的对象中去寻找对应变量的定义,一直找到GO对象为止。根据parentScope父级作用域向上寻找变量的过程像一个链条一样,这就是作用域链。

总结:什么是闭包?

还记得我们第三段嵌套函数代码的执行流程吗?函数foo内部返回了一个q函数,因为这个返回的q函数的内存地址在GO中保存了,那么我们就可以通过GO访问到返回的q函数对象,而q函数对象的parentScope又保存了foo函数的AO对象的内存地址,所以我们也可以通过作用域链找到foo函数的AO对象,那么在调用q函数的时候,我们就可以通过作用域链访问到foo函数AO对象上的变量,这就是闭包。

闭包简言之就是,内部函数可以访问外部变量。当函数可以记住并访问所在的词法作用域时,就产生了闭包。