深入研究原生JS的执行环境和作用域问题

      发现许多新学习者对于原生js中执行环境和作用域链及作用域的理解不透彻甚至有点模糊,今天我就来详细帮助大家来分析一下这个问题,这个问题的理解对于我们理解闭包是事关重要的一个基础。因此我们务必彻底理解这个问题!

为此我特意去看了一下原版《JavaScript for Web Developers》也就是中文翻译版的《javascript高级程序设计》

为了照顾英文不好的同学,下面以翻译版为主来解析,英文好的同学我建议去读一下英文原版,更易理解这个问题!

下面进入正文,我们先把书上这段话拿出来进行一个简单的剖析:

      执行环境(execution context)是js中最为重要的一个概念,执行环境定义了变量或者函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。全局执行环境是最外围的一个执行环境,在web浏览器中,全局执行函数被认为是window对象,因此所有全局变量和函数都作为window对象的属性和方法创建的。某个执行环境中所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁。全局执行环境直到应用程序退出也随之销毁(关闭网页和浏览器的时候)。
书上说,当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象。活动对象在一开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样一直延续到全局执行环境;全局执行环境始终是作用域链中的最后一个对象
(引自《javascript高级程序设计》第四章73页)。

从这段话中我们可以提取一下几个重点需要理解的概念:

     一:执行环境:定义了变量或函数有权访问其他数据,决定了它们各自的行为

每个执行环境都有一个与之相关的变量对象,这个变量对象就是用来装载该环境中所有的变量和函数的。(我们的代码无法访问这个对象,但是解析器会)

      举个例子,比如全局执行环境就是 window 对象。

作用域链:当代码在执行时,会创建一个作用域链,作用是对每一级的执行环境进行有 序访问

或者以下面这个概念理解也可以:

     1. 执行环境: 所有 JavaScript 代码都是在一个执行环境中被执行的。执行环境是一个概念,一种机制,用来完成JavaScript运行时在作用域、生存期等方面的处理,它定义了变量或函数有权访问的其他数据(包含了外部数据),决定他们各自的行为。包括以下分类:

           全局执行环境: 全局环境是最外围的一个执行环境,根据ECMAScript实现所在的宿主环境不同,表示执行环境的对象也不一样,在web中,全局执行环境被认为是window对象。

     函数执行环境: 每个函数都有自己的执行环境。

  2. 变量对象: 每个执行环境都有一个变量对象与之关联,执行环境中定义的所有变量及函数(只包含在当前函数内定义的函数,局部变量)都保存在这个对象中,我们编写的代码无法直接访问这个对象,但解析器在处理数据时会在后台使用它。(参考下面的作用域,变量对象就是作用域为该执行环境的函数,变量的集合对象)

  3. 作用域: 变量或方法有访问权限的代码空间,即变量或函数起作用的区域。(作用域包括全局作用域与函数作用域,没有块级块作用域,即一个变量的作用域不可能是一个块级域,至少包括最临近的整个函数空间。)

  4. 作用域链: 由当前环境栈中对应的变量对象组成。作用域的用途,是保证对执行环境有权访问的所有变量和函数的有序访问,作用域前端,始终是当前执行的代码所在的环境对应的变量对象,下一变量对象来自包含(外部)环境,而再下一变量对象则来自下一包含环境,一直延续到全局执行环境。 

还是不明白的同学看{{{{{{{。。}}}}}}}}蓝色字体明白的同学可以直接跳过蓝色字体部分:

{{{{{{{{

明确几个概念:

EC:函数执行环境(或执行上下文),Execution Context
ECS:执行环境栈,Execution Context Stack
VO:变量对象,Variable Object
AO:活动对象,Active Object
scope chain:作用域链

EC(执行上下文)
每次当控制器转到ECMAScript可执行代码的时候,就会进入到一个执行上下文。
那什么是可执行代码呢?
可执行代码的类型:
全局代码(Global code):
这种类型的代码是在"程序"级处理的:例如加载外部的js文件或者本地标签内的代码。全局代码不包括任何function体内的代码。 这个是默认的代码运行环境,一旦代码被载入,引擎最先进入的就是这个环境。
函数代码(Function code):
任何一个函数体内的代码,但是需要注意的是,具体的函数体内的代码是不包括内部函数的代码。
Eval代码(Eval code)
eval内部的代码
这里仅仅引入EC这个概念,后面还有关于EC建立细节的介绍。
ECS(执行环境栈)
我们用MDN上的一个例子来引入函数执行栈的概念
function foo(i) {
if (i < 0) return;
console.log(‘begin:’ + i);
foo(i - 1);
console.log(‘end:’ + i);
}
foo(2);
// 输出:
// begin:2
// begin:1
// begin:0
// end:0
// end:1
// end:2
这里先不关心执行结果。先了解一下函数执行上下文堆栈的概念。相信弄明白了下面的概念,一切也就水落石出了
我们都知道,浏览器中的JS解释器被实现为单线程,这也就意味着同一时间只能发生一件事情,其他的行为或事件将会被放在叫做执行栈里面排队。
执行堆栈:
当浏览器首次载入你的脚本,它将默认进入全局执行上下文。如果,你在你的全局代码中调用一个函数,你程序的时序将进入被调用的函数,并创建一个新的执行上下文,并将新创建的上下文压入执行栈的顶部。
如果你调用当前函数内部的其他函数,相同的事情会在此上演。代码的执行流程进入内部函数,创建一个新的执行上下文并把它压入执行栈的顶部。浏览器总会执行位于栈顶的执行上下文,一旦当前上下文函数执行结束,它将被从栈顶弹出,并将上下文控制权交给当前的栈。这样,堆栈中的上下文就会被依次执行并且弹出堆栈,直到回到全局的上下文。
看到这里,想必大家都已经深谙上述例子输出结果的原因了
VO(变量对象)/AO(活动对象)
这里为什么要用一个/呢?按照字面理解,AO其实就是被激活的VO,两个其实是一个东西。下面引用知乎上的一段话,帮助理解一下。原文:
变量对象(Variable object)是说JS的执行上下文中都有个对象用来存放执行上下文中可被访问但是不能被delete的函数标示符、形参、变量声明等。它们会被挂在这个对象上,对象的属性对应它们的名字对象属性的值对应它们的值但这个对象是规范上或者说是引擎实现上的不可在JS环境中访问到活动对象
激活对象(Activation object)有了变量对象存每个上下文中的东西,但是它什么时候能被访问到呢?就是每进入一个执行上下文时,这个执行上下文儿中的变量对象就被激活,也就是该上下文中的函数标示符、形参、变量声明等就可以被访问到了
EC建立的细节
1、创建阶段【当函数被调用,但未执行任何其内部代码之前】
创建作用域链(Scope Chain)
创建变量,函数和参数。
求”this“的值
2、执行阶段
初始化变量的值和函数的引用,解释/执行代码。
我们可以将每个执行上下文抽象为一个对象,这个对象具有三个属性
ECObj: {
scopeChain: { /* 变量对象(variableObject)+ 所有父级执行上下文的变量对象*/ },
variableObject: { /*函数 arguments/参数,内部变量和函数声明 */ },
this: {}
}
解释器执行代码的伪逻辑
1、查找调用函数的代码。
2、执行代码之前,先进入创建上下文阶段:
初始化作用域链
创建变量对象:
创建arguments对象,检查上下文,初始化参数名称和值并创建引用的复制。
扫描上下文的函数声明(而非函数表达式):
为发现的每一个函数,在变量对象上创建一个属性——确切的说是函数的名字——其有一个指向函数在内存中的引用。
如果函数的名字已经存在,引用指针将被重写。
扫描上下文的变量声明:
为发现的每个变量声明,在变量对象上创建一个属性——就是变量的名字,并且将变量的值初始化为undefined
如果变量的名字已经在变量对象里存在,将不会进行任何操作并继续扫描。
求出上下文内部“this”的值。
3、激活/代码执行阶段:
在当前上下文上运行/解释函数代码,并随着代码一行行执行指派变量的值。
VO — 对应上述第二个阶段
    function foo(i){
        var a = 'hello'
        var b = function(){}
        function c(){}
    }
    foo(22)
123456
当我们调用foo(22)时,整个创建阶段是下面这样的
ECObj = {
    scopChain: {...},
     variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... }
}
1234567891011121314
正如我们看到的,在上下文创建阶段,VO的初始化过程如下(该过程是有先后顺序的:函数的形参==>>函数声明==>>变量声明):
函数的形参(当进入函数执行上下文时) —— 变量对象的一个属性,其属性名就是形参的名字,其值就是实参的值;对于没有传递的参数,其值为undefined
函数声明(FunctionDeclaration, FD) —— 变量对象的一个属性,其属性名和值都是函数对象创建出来的;如果变量对象已经包含了相同名字的属性,则替换它的值
变量声明(var,VariableDeclaration) —— 变量对象的一个属性,其属性名即为变量名,其值为undefined;如果变量名和已经声明的函数名或者函数的参数名相同,则不会影响已经存在的属性。
对于函数的形参没有什么可说的,主要看一下函数的声明以及变量的声明两个部分。
1、如何理解函数声明过程中如果变量对象已经包含了相同名字的属性,则替换它的值这句话?
看如下这段代码:
function foo1(a){
console.log(a)
function a(){}
}
foo1(20)//‘function a(){}’
根据上面的介绍,我们知道VO创建过程中,函数形参的优先级是高于函数的声明的,结果是函数体内部声明的function a(){}覆盖了函数形参a的声明,因此最后输出a是一个function
2、如何理解变量声明过程中如果变量名和已经声明的函数名或者函数的参数名相同,则不会影响已经存在的属性这句话?
//情景一:与参数名相同
function foo2(a){
console.log(a)
var a = 10
}
foo2(20) //‘20’
//情景二:与函数名相同
function foo2(){
console.log(a)
var a = 10
function a(){}
}
foo2() //‘function a(){}’
下面是几个比较有趣的例子,当做加餐小菜,大家细细品味。这里给出一句话当做参考:
函数的声明比变量优先级要高,并且定义过程不会被变量覆盖,除非是赋值
function foo3(a){
var a = 10
function a(){}
console.log(a)
}
foo3(20) //‘10’
function foo3(a){
var a
function a(){}
console.log(a)
}
foo3(20) //‘function a(){}’
AO — 对应第三个阶段
正如我们看到的,创建的过程仅负责处理定义属性的名字,而并不为他们指派具体的值,当然还有对形参/实参的处理。一旦创建阶段完成,执行流进入函数并且激活/代码执行阶段,看下函数执行完成后的样子:
ECObj = {
scopeChain: { … },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: ‘hello’,
b: pointer to function privateB()
},
this: { … }
}
提升(Hoisting)
对于下面的代码,相信很多人都能一眼看出输出结果,但是却很少有人能给出为什么会产生这种输出结果的解释。
(function() {
console.log(typeof foo); // 函数指针
console.log(typeof bar); // undefined
var foo = 'hello',
    bar = function() {
        return 'world';
    };
    
function foo() {
    return 'hello';
}
12345678
}());
1、为什么我们能在foo声明之前访问它?
回想在VO的创建阶段,我们知道函数在该阶段就已经被创建在变量对象中。所以在函数开始执行之前,foo已经被定义了。
2、Foo被声明了两次,为什么foo显示为函数而不是undefined或字符串?
我们知道,在创建阶段,函数声明是优先于变量被创建的。而且在变量的创建过程中,如果发现VO中已经存在相同名称的属性,则不会影响已经存在的属性。
因此,对foo()函数的引用首先被创建在活动对象里,并且当我们解释到var foo时,我们看见foo属性名已经存在,所以代码什么都不做并继续执行。
3、为什么bar的值是undefined?
bar采用的是函数表达式的方式来定义的,所以bar实际上是一个变量,但变量的值是函数,并且我们知道变量在创建阶段被创建但他们被初始化为undefined,这也是为什么函数表达式不会被提升的原因。
总结:
1、EC分为两个阶段,创建执行上下文和执行代码。
2、每个EC可以抽象为一个对象,这个对象具有三个属性,分别为:作用域链Scope,VO|AO(AO,VO只能有一个)以及this。
3、函数EC中的AO在进入函数EC时,确定了Arguments对象的属性;在执行函数EC时,其它变量属性具体化。
4、EC创建的过程是由先后顺序的:参数声明 > 函数声明 > 变量声明

}}}}}}}}}}}

还不理解的话,继续往下看:

开始了解一下环境栈的运作情况:

环境栈的运作情况:

当 JavaScript 代码执行时,第一个进入的总是默认的 全局执行环境(在 Web 浏览器中也就是 window 对象)。
对于每个执行环境都有三个重要的属性,变量对象(Variable object,VO)、作用域链 Scope chain和 this。这三个属性和代码运行的行为有很重要的关系。

VO 和 AO

变量对象(Variable object)

每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。

从上面的定义中,我们可以知道一般 VO 会包含下面的三类信息:

变量声明(var,Variable Declaration)
函数声明(FD,Function Declaration)
函数的形参

举个例子:

<script type="text/javascript">
var a = "global var";

function foo(x1) {
	var x2 = "local var";
}
</script>

在这个程序中,全局执行环境中的 VO 就有两个部分:

通过变量声明的变量 a
通过函数声明的函数 foo

活动对象(Activation object)
只有全局变量的变量对象允许通过 VO 的属性名称间接访问(浏览器中是 window对象);在函数执行环境中,VO 是不能直接访问的,则将其活动对象(activation object)作为变量对象。
AO 是在进入函数的执行环境时创建的,并为该对象初始化一个 arguments 对象(这个对象在全局环境是不存在的!)。


arguments 对象是函数环境里的活动对象 AO 中的内部类数组对象,它包括以下的属性:

callee:指向当前函数的引用
length:真正传递的参数的个数
properties-indexes:函数的参数值(按参数列表从左到右排列)
(对 arguments 对象的介绍可以移步 JavaScript arguments对象详解)


在上面 VO 例子中,当开始执行到 foo 的时候,就会有一个 foo 的 AO 被创建,这个活动对象由两个部分组成:

  1. 初始化生成的 arguments 对象
  2. 通过变量声明的变量 x2

再看执行环境

当一段 JavaScript 代码运行时,解释器(编译器)会创建执行环境,这里会有两个阶段:

创建阶段(编译阶段,当函数被调用,但是开始执行函数内部代码之前)

创建 Scope chain
创建 VO / AO
设置 this 的值


代码执行阶段

设置变量的值、函数的引用,然后解释、执行代码

 

对于创建 VO / AO,JavaScript 编译器主要做了下面的事:

根据函数的参数,创建并初始化 arguments对象
扫描函数内部代码,查找函数声明(Function declaration)

对于所有找到的函数声明,将函数名和函数引用存入 VO / AO 中
如果 VO / AO 中已经有同名的函数(变量),那么就进行覆盖


扫描函数内部代码,查找变量声明(Variable declaration)

对于所有找到的变量声明,将变量名存入VO/AO中,并初始化为"undefined"
如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的这类属性
看到这里和跳过上面的同学可以继续往下研究了:

讲到这里,又不得不提一下作用域的问题了

    作用域分为局部作用域和全局作用域。有如下几种情况可归纳为全局作用域:①最外层函数和在最外层函数外面定义的变量拥有全局作用域。②所有末定义直接赋值的变量自动声明为拥有全局作用域。③所有window对象的属性拥有全局作用域。而局部作用域:是函数内部的作用域,一般只在固定的代码片段内可访问到,有时候也成为函数作用域。这里引申一下变量的搜索机制:先搜索局部变量,如果没找到,往上一层查找,直到搜索全部变量,如果都没找到,返回undefined.


      在每个执行环境中,内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数。
延长作用域链:执行环境的类型总共有两种-全局和局部(函数),有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。有两种情况可以延长作用域链 try-catch的catch块和with语句。

关于延长作用域链的问题原书中讲解的已经很清楚了,这里就不再赘述了!还有js没有块级作用域,大家也要理解这个问题!

 


 


 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值