javascript弃坑之路-搞定执行环境对象、变量访问和作用域

740 阅读17分钟

可以说只要写代码,就离不开变量访问,先来看一下下面几道关于变量的值的题目:

  • 题一
var a=10;
function getA(){
   var a=20;
   console.log(a);
}
getA();//20
console.log(a)//10
  • 题二
var a=10;
function getA(){
   console.log(a);
}
getA();//10
  • 题三
var a=10;
function getA(){
    var a=20;
    inner();
}
function inner(){
    console.log(a);
}
getA();//10
 - 题四
var a=10;
function getA(){
   var a=20;
   return function(){
       console.log(a);
   }
}
var innerfunc=getA();
innerfunc();//20
  • 题五
var a=10;
function getA(){
   console.log(a);
   var a=20;
}
getA();//undefined
  • 题六
 var a=10;
 function getA(a){
   console.log(a);
   var a=20;
 }
 getA(10);//10

以上几个题目如果你都能轻松地答对答案,并且知其所以然的话,那么或许你已经掌握了作用域,至少能独立解决作用域的大部分问题了,如果有些不明白为什么会是这样的结果的话,可以接着阅读下面的内容,或许能找到答案~

执行环境和执行环境对象

为什么打印一个变量,在不同的情况下会出现那么多中结果?这就和执行环境对象有关了,JavaScript引擎把变量作为属性保存在了一个对象中,这个对象正是执行环境对象,那么执行环境对象又和执行环境有什么关系呢?
执行环境是一种概念,表示正在执行中的代码所在的环境(可以是全局的,普通函数,或者eval函数),这种概念包含了对变量或函数有权访问的其他数据的定义,它本身并不是一个对象,事实上,每个执行环境都有一个与之相关的执行环境对象,同时,执行环境对象正是对ECMAScript对执行环境的实现,在ECMAScript中,执行环境有三种类型:global,function和eval,三种环境对象的执行环境对象也有不同的表现,在执行环境对象的生命周期也会有所体现。

执行环境对象的生命周期

执行环境对象也是一个对象,那么这个对象的生命周期又是什么样的呢?它是天然存在,永生不死的吗?我们知道,对象是需要创建才会生成的,执行环境对象也不例外,只有当进入了某个执行环境中,才会产生这个执行环境所对象的执行环境对象。

全局执行环境

我们把不处于任何函数中的代码所在的环境称为全局执行环境,当一段程序开始执行时,首先进入的就是全局执行环境,此时就生成了全局执行函数对象,在此之后所有的变量定义,函数声明都离不开全局执行环境,因此全局执行环境对象会存活于程序的整个生命周期,知道程序结束。

function执行环境

除全局执行环境外,所有其他的执行环境都是需要执行函数来激活的,也就是说只有当开始执行某个函数了,才会进入这个函数对应的执行环境,与此同时,相关的执行环境变量对象会被创建,这里的执行环境变量对象也就是,我们通常所说的执行上下文,到这里也可以发现,原来有几个概念是基本等同的:开始执行某个函数==进入对应的执行环境==创建生成对象的执行环境对象==产生对应的执行上下文==激活上下文,也就是执行环境对象相当于执行上下文,本文中,除上下文堆栈外,均用执行环境对象来表述。

eval执行环境

eval函数比较特殊,它执行的是JavaScript代码字符串,当直接调用eval时,等同于在被调用者的作用域中调用,若不是直接调用,则等同于在全局作用域中调用。可以看如下示例

```
function evaldirect(){
    var a=10;
    eval("a=20");
    console.log(a);
}
function evalindirect(){
    var a=10;
    var evalfunc=eval;
    evalfunc("a=20");
    console.log(window.a);
}
function 
evaldirect();//打印结果为20,即eval内对a的赋值作用到了被调用者evaldirect的变量对象中
evalindirect();//打印结果为20,即eval内对a的赋值作用到了被全局对象window的变量对象中,因为这里不是直接调用的eval,而是将eval赋值给了evalfunc.
```

明白了eval的执行环境,对以前比较迷惑的setTimeout,setInterval函数有了一些想法,或许这两个函数就是用了间接调用eval的方法来执行字符串代码的?所以才处于全局作用域中,还有某种对于元素的事件绑定方法,如onClock=" alert(this.a)",是不是也是通过间接调用eval的方式来执行呢?(之前被坑过~)

上下文堆栈

每当开始执行一个函数时,就会生成一个执行环境对象,也即执行上下文,那么,一个函数可能产生不止一个上下文,这是很常见的,比如在一个函数中调用另一个函数,就会产生另一个上下文,更甚者,出现循环调用或递归时,会产生很多个上下文,那么,在很多个上下文的情况下,js引擎是如何处理呢?这么多个上下文都是有效的吗?他们是如何发挥作用的?
事实上,函数中代码的执行只和当前的执行上下文有关,那么在它之前的那些上下文是不是就没用了?并不是的,与上下文的创建相对于,还有上下文的销毁操作,当当前函数执行结束后,与之相关的上下文对象就会被销毁,此时在激活它之前的上下文成为当前上下文,即当前上下文不是一成不变的,会随着函数的执行和结束而进行切换,整个上下文切换过程类似:
当前上下文是全局上下文->执行函数1->产生函数1上下文->当前上下文切换为函数1上下文->函数1中调用函数2->产生函数2上下文-》当前上下文切换为函数2上下文->函数2执行结束->函数2上下文被销毁->当前上下文恢复为函数1上下文.... 这个过程看上去似曾相识?对,就是堆栈!可以看到上下文的切换过程与堆栈的入栈出栈极其相似,我们称之为上下文堆栈。
了解了上下文堆栈的概念后再来看题一:

   var a=10;
   function getA(){
    var a=20;
    console.log(a);
   }
   getA();//20
   console.log(a)//10

答案就很明了了,在全局执行上下文中调用getA函数后,进入了getA函数的执行上下文 ,此时console.log(a),其中的a为当前的执行上下文中的变量a,即为20,之后getA结束执行,对应的函数上下文出栈,当前执行上下文回到全局执行上下文,此时执行console.log(a)中的变量a为全局执行上下文中的a 即为10。

执行环境对象的属性

我们已经知道,访问一个变量时,所取得的这个变量和当前的执行上下文,即执行环境对象(下文中,均称为执行环境对象)有关,并且要访问变量,首先要存储变量,那么这些变量在执行环境对象中是如何存储的呢?或许是直接作为执行环境对象的属性一一存储的?---其实并不是的,通常我们所访问的变量并不是直接作为属性存储在执行环境对象中的,而是存储在一个与执行环境对象有关的变量对象中。 既然这个变量对象是和执行环境对象有关的,那么到底是什么关系呢?~~答案是变量对象是执行环境对象的属性之一,执行环境对象有三大属性,分别是变量对象、this、作用域链,其中除this外的两个属性,我们无法直接访问,但它依然会影响我们对其他变量的访问。

this值

由介绍知道,this和作用域链,变量对象是并列的关系,并不需要通过作用域链的访问才能得到this值,反之this值是执行环境对象(执行上下文)的直接属性,也就是说一旦执行环境对象确定了,必然有一个它所对应的this值,无需涉及到作用域链的回溯,这一点常常弄混,搞清楚这一点,应该会解决很多问题~这里不详细介绍了,具体可以看这篇 javascript弃坑之路之原来是这样的this~~

变量对象

每个执行环境都有一个对应的执行环境对象,而变量对象又是执行环境对象的属性,因此可以说,每个执行环境都有一个对应的变量对象,全局变量对象会存在于整个程序执行期间,而执行函数时相关的变量对象即局部变量对象则只在函数执行过程中才存在,当函数执行结束后被销毁(闭包除外), 变量对象用于存储被定义在执行环境对象中的变量和函数声明(注:不包括函数表达式)。

function outer(){
  var a=10;
  function inner(){
      console.log(a);
  }
  (function inner2(){
     ... 
  })()
  inner();//10;
  inner2();//Referenced Error;
}
outer();

在本例中调用outer函数,在outer内部依次调用了inner函数,和inner2函数,而inner函数能成功执行,inner2函数却无法执行,正是因为我们在outer函数中声明了inner函数,而inner2只是一个立即执行函数,参考函数声明会存储在变量对象中,而函数表达式不会被存储,所以在这里访问inner2会发生引用错误。
在这个例题中也会发现,调用inner函数时,执行console.log(a)能够打印出10,10明明是外部的outer函数所定义的变量a的值,inner函数的变量对象明明没有a的定义啊?使得,inner函数的变量对象确实没有a的定义,但是在这个函数中确实能访问到外部的a值,这个不是bug,正是接下来要说的执行环境的另一个属性,作用域链。

作用域链

以上例的inner函数为例,在创建inner()函数时,会创建一个预先包含外部所有变量对象的作用域链,从上到下依次是outer函数变量对象,全局变量对象,这个作用域链被保存在内部的[[scope]]属性中([[scope]]属性无法直接调用),当调用inner()函数时,会为函数创建一个执行环境对象,并构建该执行对象的属性之一作用域链,通过赋值[[scope]]属性中的对象开始构建该作用域链,之后又有一个活动对象(作为变量对象)被创建并推入作用域链的前端,活动对象最开始时只包含一个变量,即arguments对象(这个对象在全局环境是不存在的),之后会陆续创建其他变量(同一般变量对象中的变量)。

作用域链的特点

在作用域链中有几个需要注意的点:

  • arguments对象只存在于活动对象中
    像上面提到的,arguments是在函数调用时,新创建的活动对象所特有的一个变量,因此它永远存在且仅存在于作用域链的最前端变量对象中(全局执行环境对象不存在arguments)
  • 作用域链的构造
    因此作用域链中的变量对象从上到下的顺序总是:最上面当前活动对象,下一个变量来自包含环境,再下一个则来自下一个包含环境,依次类推,直到全局执行环境中的变量对象。
  • 变量名的解析 在运行期间,javascript会通过检索层级的作用域来解析变量名,它从作用域链的顶层,即活动对象开始搜寻,然后逐级的向后检索,直到找到对应的变量为止,当检索到作用域链的最底层,即undefined,依然没找到,则变量值为undefined. 现在再来看最开始的题二
题二:
var a=10;
function getA(){
   console.log(a);
}
getA();//10

这里调用getA()时,取到的a的值时10已经可以理解了~正是因为作用域链的原因,访问a变量时当前活动对象没有a变量,于是去了下一级变量对象寻找,即全局变量对象中,这时候找到了全局变量a. 那么再来看一下题三呢?
题三

var a=10;
function getA(){
    var a=20;
    inner();
}
function inner(){
    console.log(a);
}
getA();//10

这里或许依然有疑惑,在getA中调用inner方法时,当前活动对象属于inner函数,inner方法打印a,既然inner内并没有关于a的定义,那么应该去下一级对象去找a变量啊,作用域链的下一级不是应该来自inner函数的包含环境即调用它本身的getA函数吗,那也是20啊,可是结果为什么是10呢,明显是全局作用域中的变量值啊。是的,或许有些意外,但结果就是这样,这就和另一个常常弄混的概念有关了-静态作用域。

静态作用域

上节也有提到,作用域链的确是在函数开始执行后,作为环境执行对象的属性之一创建的,如题三中,是在getA方法的执行过程中调用了inner函数,inner函数开始执行后,创建了当前的作用域链,这没有问题,但问题是,作用域链的来源之一函数本身的[[scope]]属性是在函数定义的时候,就根据其层层的包含环境创建好了,当创建作用域链时,即是先复制了函数的原有的[[scope]]属性,再将新创建的活动对象推入作用域链的最顶层。
所以,函数的外部作用域是在函数被创建时(定义)就确定好的,只有活动对象是在函数执行时创建的,这种作用域链机制就是静态作用域(又有词法作用域的说法,即函数作用域是通过词法来划分的,在定义函数的时候作用域链就固定了)

有了以上的说明再看以下的三,答案应该很明了了~
那么题四呢?
题四

var a=10;
function getA(){
   var a=20;
   return function(){
       console.log(a);
   }
}
var innerfunc=getA();
innerfunc();//20

这里将getA()函数的返回值赋给innerfunc变量,getA()方法返回值后,方法已经结束,一般来说函数执行结束后,getA()函数的变量对象会被销毁,可是这里调用innerfunc()的时候为什么还能访问到getA()中的变量对象呢?这就是传说中的闭包机制在发挥作用了。

闭包

闭包是指有权访问另一个函数作用域中的变量的函数-这是JavaScript高级程序设计中对闭包的一句话定义,虽然很简洁,但却实在的闭包这个概念进行了概括,创建闭包的常见方式就是在一个函数中创建另一个函数,就行刚才的题四,在getA()函数内部创建了一个匿名函数,这个匿名函数可以访问到getA()中定义的变量,所以这里匿名函数就是一个闭包,这里getA()方法在结束时,将匿名函数返回,那么到底是什么力量让闭包能够在getA()方法结束后依然能够访问它的变量呢?
一般来说,当函数执行结束之后,当前活动对象会被销毁,但闭包的情况就会不止这么简单。像之前所提到的,在函数创建的时候,函数的[[scope]]属性也会创建,也就是函数被包裹的层层外部环境的变量对象都会处于[[sope]]中,而这里匿名函数是在getA()函数执行过程中创建的,此时活动对象是getA()的变量对象,匿名函数创建后,它的外部函数getA()的函数的活动对象会处于它的作用域链的上层,这样,匿名函数就可以访问getA()函数的变量了,而正因为执行 var innerfunc=getA()时,这个匿名函数被返回且赋值了,所以该函数的作用域链中的变量对象是不会被销毁的(因为还有利用价值吧~),也就是说getA()方法虽然结束了,但是因为它的活动对象被其他有意义的函数的作用域链之中,所以不会被销毁,(这里的有意义的,不防理解为,可以被访问到的,假如单纯的执行了getA()方法而没有赋值,那么即使匿名函数被返回了,也永远不可能被访问到,此时getA()的变量对象依然会被销毁。)
这里有一点要注意的是,尽管getA()结束后,它的活动对象没有被销毁,但getA()的执行环境对象是被销毁了的,包括执行环境相关的this值和作用域链也被销毁了(还记得执行环境对象的三大属性吗?变量对象,this值,作用域链).

变量提升

到这里只剩最后一个问题了,再看看下题五
题五

var a=10;
function getA(){
   console.log(a);
   var a=20;
}
getA();//undefined

这里答案是undefined,还是比较奇怪,按理说至少可以得到全局变量a,也是10啊?这里其实原因在与变量提升了~

什么是变量提升呢?这和JavaScript引擎机制有关,JavaScript引擎在进入作用域时,会分两轮处理代码,第一轮:初始化变量,第二轮:执行代码。 在初始化变量的过程中会声明并初始化参数和函数,但对于局部变量,只会声明,并不初始化。 有了对上面两轮处理过程的了解,再来看上面的题四,在第一轮中将a进行了声明但未初始化,相当于var a,接下来再第二轮执行代码的过程中,console.log(a)时,已经能够取得当前作用域中的a变量,这是这时的a变量尚未初始化,相当于undefined,所以无需去它的上级作用域即全局作用域中去寻找a了,直接去了了值为undefined的a值,这整个过程就达到了变量提升的效果~

还剩最后一道题六

  • 题六
 var a=10;
 function getA(a){
   console.log(a);
   var a=20;
 }
 getA(10);//10

这里也是变量提升的一道题目,当这道题中,因为调用getA(a)方法时,传入了参数a=10,即a已经由参数赋值,所以即使var a再次声明a时,也不会被undefined覆盖了,这里的声明是多余的(看来还剩比较智能的~)