从执行上下文到变量提升——JS学习记录

吐槽君 分类:javascript

以下内容均为个人在学习中的个人理解所得,若存在理解上的错误欢迎各位大佬指出,谢谢。本人也只是一个在学习中的小菜鸡

什么是执行上下文?

执行上下文的定义

  • ECMA-262标准
    • An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript. implementation. At any point in time, there is at most one execution context per agent that is actually executing code. This is known as the agent's running execution context. All references to the running execution context in this pecification denote the running execution context of the surrounding agent.
  • 译文
    • 执行上下文是一个规范的设备是用来跟踪运行时的评估由一个ECMAScript代码。实现。在任何时刻,最多每个代理一个执行上下文实际上是执行代码。这被称为代理的执行上下文。对正在运行的执行上下文的所有引用这个pecification表示周围的代理的运行执行上下文。

以上是官方对执行上下文的解释,翻译是有道翻译的,有一说一看完我变得更加迷糊了。但在查询很多网上其他大佬写的文章和资料后,得到一个关于执行上下文的比较口语点的解释

  • 执行上下文是指当前执行环境中的变量、函数声明,参数(arguments),作用域链,this等信息,可以抽象的理解为一个对象(Object)

因此,我们可以暂时简单的将执行上下文理解为当前js代码运行时所在的环境,保存了代码运行时的某些信息的对象。

执行上下文的分类

在JS中,执行上下文又分为以下几种类型

  • 全局执行上下文
    • 全局执行上下文只有一个
  • 函数执行上下文
    • 每次执行函数是都会新建一个函数执行上下文
  • eval执行上下文
    • 开发过程中基本不会使用eval函数

执行上下文的具体内容

定义中说到,执行上下文在JS中被抽象成一个对象,如果是对象,那么自然而然的就会拥有一些相关的属性。而对于执行上下文来说,它主要包含以下内容

  • 变量对象(OV)
    • 全局变量对象
    • 函数变量对象
      • 形参 arguments
      • 函数声明 (会替换已有的变量对象)
      • 变量声明 (不会替换形参和函数)
  • 作用域链(scopeChain)
  • this

执行上下文的生命周期

执行上下文的生命周期主要有以下两个阶段,每个阶段中又包含了不同的内容

  • 创建阶段
    • 创建变量对象
    • 创建作用域链
    • 确定this指向
  • 执行阶段
    • 变量赋值
    • 函数调用
    • 执行其他代码

执行上下文栈(ECStack)

刚刚提到,执行上下文分为三类:全局执行上下文、函数执行上下文、eval执行上下文(通常不使用)。那么在JS代码执行的时候,这些执行上下文是如何进行管理的呢。这里便要使用执行上下文栈!
当JS开始要解释执行代码时,最先遇到的便是全局代码,因此在初始化时,便会将全局执行上下文压入执行上下文栈。
刚刚在函数执行上下文中讲到,每次执行函数的时候都会创建一个新的函数执行上下文,而创建的新的执行上下文就会被压入执行上下文栈,当函数执行结束后,该函数执行上下文就会被弹出栈。当代码全部执行完毕,全局执行上下文才会被弹出,执行上下文栈被清空。
接下来使用一段代码来详细了解下该流程

function foo1(){
    function foo2(){
    }
    foo2();
}
foo1();
 

在代码开始解释执行时,全局执行上下文会被压入栈,这里使用globalConetext来代表全局执行上下文。此时ECStack内容如下:

ECStack = [
    globalContext
]
 

接着,代码会创建foo1函数声明并执行foo1函数,因此会生成一个foo1函数的执行上下文,并将其压入栈中。此时执行上下文栈的内容如下:

ECStack = [
    foo1Context,   //将foo1Context放到globalContext前只是为了更好的展示foo1Context现在是处于栈顶位置
    globalContext
]
 

之后,代码会开始进入foo1函数内部开始执行foo1函数的内容,在foo1函数中,我们先声明了foo2函数并将其执行,因此foo2函数又会创建一个foo2函数的执行上下文,然后被压入到栈中。此时执行上下文栈的内容如下:

ECStack = [
    foo2Context,
    foo1Context,
    globalContext
]
 

在foo2函数执行完毕之后,foo2函数创建的执行上下文将从执行上下文栈弹出,此时执行上下文栈内容如下:

ECStack = [
    foo1Context,
    globalContext
]
 

同理的,在foo1函数执行完之后,foo1Context一样也会被弹出,直到代码全部执行完毕,globalContext也被弹出栈,此时栈被清空。执行上下文栈的整个流程如下:

ECStack.push(globalContext)
ECStack.push(foo1Context)
ECStack.push(foo2Context)
ECStack.pop(foo2Context)
ECStack.pop(foo1Context)
ECStack.pop(globalContext)
 

变量对象(VO)和活动对象(AO)

在执行上下文中讲到,执行上下文是包含了三个内容:变量对象(OV)、作用域链(scopeChain)、this。要深入了解变量提升的原因,就得了解执行上下文的变量对象。首先让我们一起来了解下什么是变量对象(VO)?什么是活动对象(AO)?

变量对象(VO:Variable Object)

每个执行上下文(执行环境)都有一个与之对应的变量对象(VO),该变量对象保存了当前代码环境(即执行上下文)中的函数和变量。在代码执行过程中,如果需要查询某个变量的值,将会到变量对象中进行查询。了解变量对象的时候应当注意一下几点:

  • 在JS开始运行JS脚本程序的时候,默认进入的是全局执行上下文,因此会创建一个全局变量对象,该对象即为window对象。
  • 全局变量对象代码可以直接访问,但函数创建的变量对象不能直接访问,需要通过活动对象(AO)进行访问。
  • 函数表达式不会被包含在VO中。

活动对象(AO:Activation Object)

因为函数执行上下文的变量对象无法被直接访问,因此在函数创建之后,紧接着会创建一个活动对象,并将其作为变量对象进行使用。活动对象可以理解为变量对象在函数执行上下文的一种表现形式。
活动对象创建后会使用函数的arguments属性进行初始化,然后和变量对象一样会扫描当前环境中的变量声明和函数声明,并创建相应内容。

作用域链和this(挖坑)

这两个东西又是两个大坑,我们现在主要讨论的就是执行上下文和变量提升。在变量提升中,不需对作用域链和this有太多深入的了解,我们只要知道执行上下文中除了变量对象以外还有这两样东西就行了。
接下来让我们回归本次话题——变量提升!深入的了解一下变量提升以及为啥会有变量提升。

变量提升(Hoisting)

什么是变量提升

首先让我们来了解下什么是变量提升
MDN对变量提升(Hoisting)的有如下说明:

从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。

可见,变量提升的主要内容就是在我们执行JS代码时,变量和函数的声明注意:提升的只有变量和函数的声明)会被移动到代码最前面。我们可以举个例子来说明这一问题:

s = "Hello ";
foo(s);
function foo(a){
    n = "JavaScript";
    console.log(a+n);
    var n;
}
var s;
 

其执行结果如下:

PS D:\Code\LESSON_SS\js> node .\hoisting.js
Hello JavaScript
 

通过运行上面的代码,我们发现,在代码书写过程中,我们将函数执行的代码放到了函数声明之前,变量的赋值操作也放在了变量声明之前,但是代码却是能够正常运行并且输出了我们想要的内容。好像代码给我们改成了如下形式:

var s;
s = "Hello ";
function foo(a){
    var n;
    n = "JavaScript";
    console.log(a+n);
}
foo(s);
 

那么在代码被写完,保存到.js的文本文件,再到我们的代码被编译执行期间,到底发生了什么,让我们可以先使用变量或函数再对函数进行声明呢?接下来就让我们再进一步的了解,到底什么时变量提升。

从执行上下文看变量提升

在提到变量提升之前,我们花了大幅篇章去了解什么时执行上下文,它有什么内容,它的生命周期是怎么样的,那么它到底和变量提升有什么关联呢,接下来我们就拿刚刚那段简单的代码进行深入的了解。

代码:

s = "Hello ";
foo(s);
function foo(a){
    n = "JavaScript";
    console.log(a+n);
    var n;
}
var s;
 

首先,JS一开始执行,默认创建一个全局执行上下文,并将其压入ECStack中。

ECStack.push(globalContext)
 

此时,ECStack(执行上下文栈)的内容如下:

ECStack = [
    globalContext
]
 

那这个globalContext里又有些什么呢?刚刚我们就讲到,执行上下文主要有三个内容:变量对象、作用域链、this。抛开作用域链和this这两个大坑,我们来看看刚刚谈到的变量对象,刚刚讲到,变量对象的创建是发生在执行上下文的创建阶段。在变量对象的创建过程中,首先会使用函数的arguments属性初始化变量对象,并扫描当前环境的所有变量声明和函数声明添加到变量对象当中。在这里,代码进行了变量s的声明:var s,函数foo的声明:function foo(s){...}。但变量函数只是被声明并未被赋值,因此会默认给一个undefined作为其初始值。此时全局变量对象内容可以表示成下面的样子:

globalOV = {
    //foo是函数foo(){}的引用
    foo: reference to function foo(){},   //函数声明的优先级比变量声明的优先级高
    s: undefined
}
 

之后,代码会进入执行阶段,首先会遇到语句s = "Hello Js",对变量s进行赋值,JS引擎会去全局变量对象(VO)中进行查询变量s,并将"Hello Js"赋值给变量s,这也是为什么我们先写s的赋值语句后写s的声明,但是代码却能正常执行的原因,同理的foo函数也因为在执行代码之前,创建执行上下文的时候,JS已经把函数声明存放到变量对象当中了,因此在执行阶段时,代码foo(a)也能够正常的进行执行,因为在执行阶段,JS能够在执行上下文的变量对象中找到foo函数。
接着,代码继续执行,我们调用了foo函数(只是调用了函数,并未开始执行函数内代码),这时又会与之对应的会创建函数的执行上下文,同理的函数的执行上下文也包含了上述的三样内容。此时ECStack的操作如下:

ECStack.push(fooContext)
 

结果如下:

ECStack = [
    fooContext,
    globalContext
]
 

此时,会根据函数的参数,初始化创建arguments Object。接着回去扫描函数代码中的函数声明以及变量声明,这里只有变量n的声明,因此会将n加入到变量对象中。
此时函数的执行上下文的变量对象如下:

fooOV = {
    arguments:{
        0: "Hello ",
        length: 1
    },
    a: "Hello ",
    n: undefined
}
 

在函数的执行上下文的创建阶段结束后,开始执行代码,同理的,在执行赋值操作n = "JavaScript"时,函数执行上下文的活动对象(即被激活的变量对象OV)中的变量n将被赋值为"JavaScript。之后console.log(a+n)能够在被激活的变量对象(即活动对象)中找到我们所需的变量an,并将其打印出来。
之后函数结束,函数的执行上下文出栈。结果为:

//在执行ECStack.pop(fooContext)操作之后
ECStack = [
    globalContext
]
 

最后,脚本全部执行完,ECStack栈空。代码执行结束。其执行上下文栈的总体流程如下:

image.png

至此,我们终于对变量提升有了一个更深入的了解,产生变量提升的原因就是代码在执行前会创建执行上下文,而执行上下文在创建阶段又会将当前代码中的函数声明以及变量声明加入到执行上下文的变量对象中。代码执行时,如果遇到对某个变量的查找,将会在当前代码的执行上下文的变量对象中查找。所以我们在书写代码的时候,可以先使用,再声明(当然使用良好的代码书写习惯肯定是更好的啦)。

一道小题目

下方代码的执行结果应该是什么?

showName();
var showName = function(){
    console.log(2)
}
function showName(){
    console.log(1)
}
showName();
 

答案:

PS D:\Code\LESSON_SS\BATJTMD\tecent> node .\1.js
1
2
 

原因:
我们一步步的对代码执行进行分析:

  1. 代码开始执行,创建全局执行上下文,此时在全局执行上下文的创建阶段,全局执行上下文的变量对象内容应该如下:
globalVO = {
    showName: reference to function showName(){...}
}
 

可能有人会说,var showName的声明又跑去哪了呢?不是应该还有个showName: undefined吗?前面代码中提到过,函数的声明优先级是高于变量声明的,而var showName = function(){...}是一个函数表达式,这里进行的先是showName变量的声明操作,后续赋值操作是在执行阶段完成的工作。因此因为函数声明优先级高于变量声明,所以变量的声明将会被函数声明覆盖。
2. 接着开始执行代码,第一个执行的代码就是showName(),要对showName进行调用执行,而在变量对象中,showName已经被声明成函数,因此他会执行console.log(1)。然后执行完毕,接着回到全局执行上下文。
3. 紧接着代码执行var showName = function(){...}。如果没有弄清前面的关系,可能会觉得刚刚说var showName声明已经被覆盖了,就不会执行这行代码了。但是实际是并不是的,执行上下文的创建阶段,只会关心声明这个操作,虽然var showName被覆盖了,但是后面的赋值操作并没有受影响,赋值操作还会正常执行。其实这行代码就等价于

// showName已经声明了,是一个函数
showName = function(){
    console.log(2)
}
 
  1. 在第三步,showName的函数内容已经被修改了,所以在最后一行的showName()中,输出的结果应该是2。

总结

变量提升从表面上看就是在代码运行过程中,所有函数和变量的声明都会提升到最前面。而从本质来看,就是因为执行上下文在创建阶段会先将当前代码的函数变量声明添加到执行上下文的变量对象当中,而在执行阶段,一些变量的查找又是在变量对象中进行查找,所以在代码编译执行时便有了变量提升这一功能。

参考资料:

  • 理解JavaScript的执行上下文
  • 执行上下文和作用域
  • 彻底明白作用域、执行上下文
  • 关于javascript中的变量对象和活动对象
  • JavaScript的执行上下文与执行环境
  • JavaScript中的执行上下文和变量对象

回复

我来回复
  • 暂无回复内容