JavaScript 论代码执行上下文

0.285字数 4053阅读 2120

导读

本片文章,在前人的基础上,加上自己的理解,解释一下JavaScript的代码执行过程,顺道介绍一下执行环境和闭包的相关概念。

分为两部分。第一部分是了解执行环境的相关概念,第二部分是通过实际代码了解具体执行过程中执行环境的切换。

执行环境

执行环境的分类

  • 1.全局执行环境
    是JS代码开始运行时的默认环境(浏览器中为window对象)。全局执行环境的变量对象始终都是作用域链中的最后一个对象。
  • 2.函数执行环境
    当某个函数被调用时,会先创建一个执行环境及相应的作用域链。然后使用arguments和其他命名参数的值来初始化执行环境的变量对象。
  • 3.使用eval()执行代码

没有块级作用域(本文不涉及ES6中let等概念)

执行上下文(执行环境)的组成

执行环境(execution context,EC)或称之为执行上下文,是JS中一个极为重要的概念。当JavaScript代码执行时,会进入不同的执行上下文,而每个执行上下文的组成,基本如下:

  • 变量对象(Variable object,VO): 变量对象,即包含变量的对象,除了我们无法访问它外,和普通对象没什么区别
  • [[Scope]]属性:数组。作用域链是一个由变量对象组成的带头结点的单向链表,其主要作用就是用来进行变量查找。而[[Scope]]属性是一个指向这个链表头节点的指针。
  • this: 指向一个环境对象,注意是一个对象,而且是一个普通对象,而不是一个执行环境。

若干执行上下文会构成一个执行上下文栈(Execution context stack,ECS)。而所谓的执行上下文栈,举个例子,比如下面的代码

var a = "global var";

function foo(){
    console.log(a);
}

function outerFunc(){
    var b = "var in outerFunc";
    console.log(b);
    
    function innerFunc(){
        var c = "var in innerFunc";
        console.log(c);
        foo();
    }
    
    innerFunc();
}


outerFunc()

代码首先进入Global Execution Context,然后依次进入outerFunc,innerFunc和foo的执行上下文,执行上下文栈就可以表示为:

执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境。当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境。处于活动状态的执行上下文环境只有一个。

产生执行上下文的两个阶段

当一段JS代码执行的时候,JS解释器会通过两个阶段去产生一个EC

  • 创建阶段(当函数被调用,但是开始执行函数内部代码之前)
    • 创建变量对象VO
    • 设置[[Scope]]属性的值
    • 设置this的值
    • 激活/代码执行阶段
  • 初始化变量对象,即设置变量的值、函数的引用,然后解释/执行代码。

创建变量对象VO过程

  • 1.根据函数的参数,创建并初始化arguments object
  • 2.扫描函数内部代码,查找函数声明(function declaration)
    • 对于所有找到的函数声明,将函数名和函数引用存入VO中
    • 如果VO中已经有同名函数,那么就进行覆盖
  • 3.扫描函数内部代码,查找变量声明(Variable declaration)
    • 对于所有找到的变量声明(通过var声明),将变量名存入VO中,并初始化为undefined
    • 如果变量名跟已经声明的形参或函数相同,则什么也不做

注:步骤2和3也称为声明提升(declaration hoisting)

通过一段代码来了解JavaScript代码的执行

我们举例说明,假如我们有一个js文件,内容如下:

var  global_var1 = 10;
function  global_function1(parameter_a){
    var  local_var1 = 10 ;
    return  local_var1 + parameter_a + global_var1;
}
var global_sum = global_function1(10);
alert(global_sum);

下面我们来一步一步说明解释器是如何执行这段代码的:

1.创建全局上下文

首先,在解释器眼中,global_var1global_sum叫做全局变量,因为它们不属于任何函数。local_var1叫做局部变量,因为它定义在函数global_function1内部。global_function1叫做全局函数,因为它没有定义在任何函数内部。

然后,解释器开始扫描这段代码,为执行这段代码做了一些准备工作——创建了一个全局上下文

全局上下文,可以把它看成一个JavaScript对象,姑且称之为global_context。这个对象是解释器创建的,当然也是由解释器使用。(我们的JavaScript代码是接触不到这个对象的)

global_context对象大概是这个样子的:

global_context = {
       Variable_Object :{......},
       Scope           :[......],
       this            :{......}
}

可以看到,global_context有三个属性

  • Variable_Object(以下简称VO)
    {
    global_var1:undefined
    global_function1:函数 global_function1的地址
    global_sum:undefined
    }

    解释器在VO中记录了变量全局变量global_var1global_sum,但它们的值现在是undefined的,还记录了全局函数global_function1,但是没有记录局部变量local_var1。VO的原型是Object.prototype

  • Scope数组中的内容如下:

      [     global_context.Variable_Object     ]
    

    我们看到,Scope数组中只有一个对象,就是前面刚创建的对象VO。

  • this

    this的值现在是undefined

global_context对象被解释器压入一个栈中,不妨叫这个栈为context_stack。现在的context_stack是这样的:

创建出global_context后,解释器又偷偷摸摸干了一件事,它给global_function1设置了一个内部属性,也叫scope,它的值就是global_context中的scope!也就是说,现在:

global_function1.scope === [  global_context.Variable_Object   ];

我们获取不到global_function1的scope属性的,只有解释器自己能获取到。

2.逐行执行代码

解释器在创建了全局上下文后,就开始执行这段代码了。

第一句:

var  global_var1 = 10;

解释器会把VO中的global_var1属性的值设为10。现在global_context对象变成了这样:

global_context = {
       Variable_Object :{ 
               global_var1:10,
               global_function1:函数 global_function1的地址,
               global_sum:undefined
        },
        Scope          :[ global_context.Variable_Object ],
        this           :undefined
}

第二句:

解释器继续执行我们的代码,它碰到了声明式函数global_function1,由于在创建global_context对象时,它就已经记录好了该函数,所以现在它什么也不用做。

第三句:

var global_sum = global_function1(10);

解释器看到,我们在这里调用了函数global_function1(解释器已经提前在global_context的VO中记录下了global_function1,所以它知道我们这里是一个函数调用),并且传入了一个参数10,函数的返回结果赋值给了全局变量global_sum

解释器并没有立即执行函数中的代码,因为它要为函数global_function1创建一个专门的context,我们叫它执行上下文(execute_context)吧,因为每当解释器要执行一个函数时,都会创建一个类似的context。

execute_context也是一个对象,并且与global_context还很像,下面是它里面的内容:

execute_context = {
       Variable_Object :{ 
               parameter_a:10,
               local_var1:undefined,
               arguments:[10]              
        },
        Scope          :[execute_context.Variable_Object, global_context.Variable_Object ],
        this           :undefined
}

我们看到,execute_context与global_context相比,有以下几点变化:

  • VO
    • 首先记录了函数的形式参数parameter_a,并且给它赋值10,这个10就是我们调用函数时传递进去的。
    • 然后记录了函数体内的局部变量local_var1,它的值还是undefined。
    • 然后是一个arguments属性,它的值是一个数组,里面只有一个10。

你可能疑惑,不是已经在parameter_a中记录了参数10了吗,为什么解释器还要搞一个arguments,再来记录一遍呢?原因是如果我们这样调用函数:

global_function1(10,20,30);

在JavaScript中是不违法的。此时VO中的arguments会变成这样:

arguments:[10,20,30]

parameter_a的值还是10。可见,arguments是专门记录我们传进去的所有参数的。

  • Scope

Scope属性仍然是一个数组,只不过里面的元素多了个execute_context.Variable_Object,并且排在了global_context.Variable_Object前面。

解释器是根据什么规则决定Scope中的内容的呢?答案非常简单:

execute_context.Scope = execute_context.Variable_Object + global_function1.scope。

也就是说,每当要执行一个函数时,解释器都会将执行上下文(execute_context)中Scope数组的第一个元素设为该执行上下文(execute_context)的VO对象,然后取出函数创建时保存在函数中的scope属性(本文中则是global_function1.scope),将其添加到执行上下文(execute_context)Scope数组的后面。

我们知道,global_function1是在global_context下创建的,创建的时候,它的scope属性被设置成了global_context的Scope,里面只有一个global_context.Variable_Object,于是这个对象被添加到execute_context.Scope数组中execute_context.Variable_Object对象后面。

任何一个函数在创建时,解释器都会把它所在的执行上下文或者全局上下文的Scope属性对应的数组设置给函数的scope属性,这个属性是函数“与生俱来”的。

  • this
    this的值此时仍然是undefined的(但不同的解释器可能有不同的赋值)

解释器为函数global_function1创建好了execute_context(执行上下文)后,会把这个上下文对象压入context_stack中,所以,现在的context_stack是这样的:

准备执行函数内的代码

做好了准备工作,解释器开始执行函数里面的代码了,此时我们称函数是在执行上下文中运行的。

第一句

var  local_var1 = 10 ;

它的处理办法很简单,将execute_context的VO中的local_var1赋值为10。这一点与在global_context下执行的变量赋值语句的处理一样。此时的execute_context变成这样:

execute_context = {
       Variable_Object :{ 
               parameter_a:10,
               local_var1:10,                      //为local_var1赋值10
               arguments:[10]              
        },
        Scope          :[execute_context.Variable_Object, global_context.Variable_Object ],
        this           :undefined
}

第二句

return local_var1 + parameter_a + global_var1;
  • 解释器进一步考察语句,发现这是一个返回语句,于是它开始计算return 后面的表达式的值。
  • 在表达式中它首先碰到了变量local_var1,它首先在execute_context的Scope中依次查找,在第一个元素execute_context的VO发现了local_var1,并且知道它的值是10
  • 然后解释器继续前进,碰到了变量parameter_a,它如法炮制,在execute_context的VO中发现了parameter_a,并且确定它的值是10。
  • 接着发现 global_var1,解释器从execute_context的Scope第一个元素execute_context.VO中查找,没有发现global_var1。继续查看Scope数组的第二个元素,即global_context.VO,发现并且确定了它的值为10。
  • 于是,解释器将三个变量值相加得到了30,然后就返回了。
  • 此时,解释器知道函数已经执行完了,那么它为这个函数创建的执行上下文也没有用了,于是,它将execute_context从context_stack中弹出,由于没有其他对象引用着execute_context,解释器就把它销毁了。现在context_stack中又只剩下了global_context。

第三句

var global_sum = 30;

现在解释器又回到全局上下文中执行代码了,这时它要把30赋值给sum,方法就是更改global_context中的VO对象的global_sum属性的值。

第四句

alert(global_sum);

解释器继续前进,碰到了语句alert(global_sum);很简单,就是发出一个弹窗,弹窗的内容就是global_sum的值30,当我们点击弹窗上的确定按钮后,解释器知道,这段代码终于执行完了,它会打扫战场,把global_context,context_stack等资源全部销毁。

再遇闭包

现在,知道了上下文,函数的scope属性的知识后,我们就可以开始学习闭包了。让我们将上面的js代码改成这样:

var  global_var1 = 10;
function  global_function1(parameter_a){
    var  local_var1 = 10 ;
   function local_function1(parameter_b){
        return parameter_b  + local_var1 + parameter_a + global_var1;
   }
   return   local_function1 ;
}
var global_sum = global_function1(10);
alert(global_sum(10));

这段代码与原先的代码最大的不同是,在global_function1内部,我们创建了一个函数local_function1,并且将它作为返回值。

当解释器执行函数global_function1时,仍然会为它创建执行上下文,只不过此时execute_context.VO中多了一个函数属性local_function1。然后,解释器就会开始执行global_function1中的代码。

我们直接从创建local_function1语句开始分析,看解释器是怎么执行的,闭包的所有秘密就隐藏在其中。

当解释器在execute_context中执行创建local_function1时,它仍然会将execute_context的Scope设置给函数local_function1的scope属性,也就是这样:

local_function1.scope = [ execute_context.Variable_Object,   global_context.Variable_Object ]

然后,解释器碰到了返回语句,把local_function1返回并赋值给了全局变量global_sum。此时global_context的VO中global_sum的值就是函数local_function1

此时,函数global_function1已经执行完了,解释器会怎么处理它的execute_context呢?

首先,解释器会把execute_context从context_stack中弹出,但并不把它完全销毁,而是保留了execute_context.Variable_Object对象,把它转移到了另一块堆内存中。为什么不销毁呢?因为还有对象引用着它呢。引用链如下:

这意味着什么呢?这说明,当global_function1结束返回后,它的形式参数parameter_a,局部变量local_var1以及局部函数local_function1都没有销毁,还仍然存在。这一点,与面向对象的语言Java中的经验完全不同,这也是闭包难以理解的根本所在。

下面我们的解释器继续执行语句alert(global_sum(10));alert参数是对函数global_sum的调用,global_sum的参数为10,我们知道函数global_sum的代码是这样的:

function local_function1(parameter_b){
    return parameter_b  + local_var1 + parameter_a + global_var1;
}

要执行这个函数,解释器仍然会为它创建一个执行上下文,我们姑且称之为local_context2,这个对象的内容是这样的:

execute_context2 = {
   Variable_Object :{ 
           parameter_b:10,
           arguments:[10]              
    },
    Scope          :[execute_context2.Variable_Object, execute_context.Variable_Object, global_context.Variable_Object ],
    this           :undefined
}

这里我们重点看看Scope属性,它的第一个元素毫无疑问是execute_context2.Variable_Object,后面的元素是从local_function1.scope属性中获得的,它是在local_function1创建时所在的执行上下文的Scope属性决定的。

创建的execute_context2压入context_stack后,解释器开始执行语句

return parameter_b  + local_var1 + parameter_a + global_var1;

对于该句中四个变量,解释器确定它们的值的办法一如既往的简单,首先在当前执行上下文(也就是execute_context2)的Scope的第一个元素中查找,第一个找不到就在第二个元素中查找,然后就是第三个,直至global_context.Variable_Object。

然后,解释器就会将四个变量值相加后返回。弹出execute_context2,此时execute_context2已经没有对象引用着它,解释器就把它销毁了。

最后,alert函数会收到值40,然后发出一个弹窗,弹窗的内容就是40。程序结束

说到现在,啥是闭包啊?

简单讲,当我们从函数global_function1中返回另一个函数local_function1时,由于local_function1scope属性中引用着为执行global_function1创建的execute_context.Variable_Object对象,导致global_function1在执行完毕后,它的execute_context.Variable_Object对象并不会被回收,此时我们称函数local_function1是一个闭包,因为它除了是一个函数外,还保存着创建它的执行上下文的变量信息,使得我们在调用它时,仍然能够访问这些变量。

函数将创建它的上下文中的VO对象封闭包含在自己的scope属性中,函数就变成了一个闭包。从这个广泛的意义上来说,global_function1也可以叫做闭包,因为它的scope内部属性也包含了创建它的全局上下文的变量信息,也就是global_context.VO

推荐文章

推荐阅读更多精彩内容