导读
本片文章,在前人的基础上,加上自己的理解,解释一下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_var1
、global_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_var1
、global_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_function1
的scope
属性中引用着为执行global_function1
创建的execute_context.Variable_Object
对象,导致global_function1
在执行完毕后,它的execute_context.Variable_Object
对象并不会被回收,此时我们称函数local_function1
是一个闭包,因为它除了是一个函数外,还保存着创建它的执行上下文的变量信息,使得我们在调用它时,仍然能够访问这些变量。
函数将创建它的上下文中的VO对象封闭包含在自己的scope属性中,函数就变成了一个闭包。从这个广泛的意义上来说,global_function1
也可以叫做闭包,因为它的scope内部属性也包含了创建它的全局上下文的变量信息,也就是global_context.VO
推荐文章
- 教你步步为营掌握JavaScript闭包(本篇文章脱胎于这篇文章)
- 理解JS执行环境
- 深入理解JavaScript闭包和原型链