JavaScript的作用域和作用域链

JS的作用域是一个老生常谈的话题,本文将深入探讨它内部的原理。在正文开始之前,我们先来了解一下和作用域相关的几个重要的知识点。

JS执行的三个阶段

JS引擎运行JS代码分为三个阶段:

语法分析阶段

该阶段对js代码块的语法进行分析:如果发现语法不正确,就向外抛出一个语法错误(SyntaxError),停止该js代码块的执行,然后继续查找并加载下一个代码块;如果语法正确,则进入预编译阶段。

预编译阶段

在预编译阶段,JS引擎会为代码创建相应的“执行上下文”。

执行环境

“执行上下文”即“执行环境”,为了简化概念,我们统称为“执行环境”,JS引擎在运行JS代码的时候,会给全局代码、每个函数、eval函数包裹的代码创建相应的“执行环境”,并在执行阶段将他们“压”入“执行栈”中执行。共有三种类型的执行环境:

  • 全局执行环境
  • 函数执行环境
  • eval执行环境

而创建执行环境时主要做了以下三件事情:

创建变量对象

创建变量对象主要是经过以下过程,如图所示:

JavaScript的作用域和作用域链_第1张图片

  • 创建arguments对象,检查当前上下文的参数,建立该对象的属性与属性值,仅在函数环境(非箭头函数)中进行的,全局环境没有此过程。
  • 检查当前上下文的函数声明,按照代码顺序查找,将找到的函数提前声明,如果当前上下文的变量对象没有该函数名属性,则在该变量对象以函数名建立一个属性,属性值则指向该函数所在堆内存地址引用,如果存在,则会被新的引用覆盖掉。
  • 检查当前上下文的变量声明,按代码顺序查找,将找到的变量提前声明,如果当前上下文的变量对象没有变量名属性,则在该变量对象以变量名建立一个属性,属性值为undefined;如果存在,则忽略该变量声明。

函数声明提前和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。

创建作用域链

作用域链由当前执行环境的活动对象和上层(外层)执行环境的活动对象组成,是一种有序列表或链表结构。

确定this的指向。

这一阶段比较复杂,其实我们只要记住,对于一个函数来说,谁调用它,this就指向谁;如果没有指定调用方,    在浏览器环境下,this就指向window(严格模式下指向undefined

执行阶段

预编译阶段结束后进入执行阶段。

在执行阶段,JS引擎会将上一阶段创建的执行环境推入“执行栈”中执行,此时执行环境中的变量对象上的属性按顺序得到赋值,变成“活动对象”。

在这一阶段,会把当前活动对象添加到作用域链的前端(起始位置),在访问一个变量时,会沿着作用域链一个个地查找,直到找到为止。若直到最后都找不到会抛出“ReferenceError”错误。

执行栈

执行栈,也称“执行上下文栈”、“执行环境栈”。在此我们统一称之为“执行栈”,执行栈是一种先进后出的结构,在单线程的JS中,只有一个执行栈,JS引擎会将为JS代码创建的所有执行环境都放入这个执行栈中执行。执行栈的指针永远指向栈顶的执行环境,即当前执行环境。当前执行环境执行结束,将从执行栈中弹出,弹出的执行环境被销毁,栈指针下移,指向前一个执行环境。

下面进入正文

为了更好地示意,我们以一段代码为例:

   var a = 1 function 
   fnA(){  
       a = 2 
       fnB(3) 
       console.log(a) 
   } 
   function fnB(a){  
       console.log(a) 
       var a = 4 
       console.log(a) 
   }
   fnA() 
   console.log(a)

相信很多同学根据经验都能很容易给出上面代码的输出值。但从JS运行原理的角度,我们怎么理解这样输出的原因呢?下面我们一步步分析,揭开它的神秘面纱。

首先,JS引擎对全局代码进行语法分析,没有发现语法错误,进入预编译阶段。

在预编译阶段,为全局代码创建全局执行环境,根据上文讲的规则,其结构用我们最熟悉的JS代码表示如下:

// 注意,执行上下文和其中的变量对象、活动对象、作用域链都是JS引擎内部使用的,外部
//(也就是我们编写JS代码时)无法访问,只有this可以通过“this”关键字访问
globalContext = {
  VO: { // 变量对象
    fnA: function(){ }, // 对应函数体,略
    fnB: function(){ }, // 对应函数体,略
    a:undefined //注意,在浏览器全局执行环境下用var声明的变量会被添加到window对象,成为它的属性
  },
  scope:[window],
  this:window
}

ps:这里用JS是为了更好地说明,实际JS引擎是由更底层的语言编写的。下文的分析为了便于理解简化了一些细节,须知悉。

做完这些事情,进入执行阶段,此时JS引擎将全局执行环境“压入”执行栈中执行,变量对象上的属性根据顺序赋值,变成活动对象:

globalContext = {
  AO: { // 活动对象
    fnA: { }, // 对应函数体,略
    fnB: { }, // 对应函数体,略
    a:1
  },
  scope:[globalContext.AO,window],
  this:window
}

此时执行栈状态如下图所示:

JavaScript的作用域和作用域链_第2张图片

执行全局代码时遇到fnA(),将fnA的函数体取出,对其中的函数代码进行语法分析,然后进行预编译,创建fnA的执行环境:

fnAContext = {
  VO: {}, // 变量对象,没有声明任何函数和变量
  scope: [globalContext.AO,window], // 作用域链
  this: window
}

进入执行阶段:

fnAContext = {
  AO: {}, // 变量对象,没有声明任何函数和变量
  scope: [fnAContext.AO,globalContext.AO,window], // 作用域链
  this: window
}

此时执行栈如下图:

JavaScript的作用域和作用域链_第3张图片

执行时遇到赋值语句a = 2,在作用域链上的globalContext.AO找到a。将其值变为2。到此为止,全局变量a的值已被改变。

接着遇到fnB(3),以同样方式,最终在globalContext.AO上找到fnB,将其函数代码取出,进行语法分析、预编译。

fnB预编译结果:

fnBContext = {
  VO: {
    a:undefined, // arguments.a 重复的声明var a 被忽律
  },
  scope: [fnAContext.AO,globoalContext.AO,window], // 作用域链
  this: window
}

接着进入执行阶段:

fnBContext = {
  AO: {
    a:3, // arguments.a传入值3
  },
  scope: [fnBContext.AO,fnAContext.AO,globoalContext.AO,window], // 作用域链
  this: window
}

此时执行栈如下图:

JavaScript的作用域和作用域链_第4张图片

fnB第一句代码输出a的值,我们在很幸运找到了fnBContext.AO.a,此时它已经被传入值赋值为3,因此这里console.log(a)输出值为3。接着执行时遇到赋值语句a = 4,fnBContext.AO.a的值改变为4,所以下一句的console.log(a)输出值为4

接着,fnB(3)执行完毕,其执行环境被弹出,栈指针下移动,此时执行栈状态如下图:

JavaScript的作用域和作用域链_第5张图片

回到fnA的执行环境,继续执行下一条语句console.log(a),根据前文分析我们得知其输出的是globalContext.AO.a的值,因此输出改变后的值2。

fnA执行完,其执行环境从执行栈弹出,此时执行栈如下图:

JavaScript的作用域和作用域链_第6张图片

此时回到全局执行环境继续执行后面的代码console.log(a),输出改变后的globalContext.AO.a的值2。

到这里,全局代码也执行完了,全局执行环境被弹出,栈变为空:

JavaScript的作用域和作用域链_第7张图片

到此为止,我们得出所有的输出是:

3

4

2

2

你猜对了吗?

最后简单一提,JS中的with和try-catch语句中的catch所包含的代码会临时创建局部的作用域,将作用域链延长,在这些代码执行完后局部的作用域会被销毁,在写代码时需要额外注意,如下面的代码:

var message = 'hello'
with({message:'hello width'}){
    console.log(message)
}
console.log(message)
try{
    doSomething('hello')
}catch(err){
    console.log(err.message)
}

with语句很好理解,即with紧跟的括号里的对象被添加到了作用域链的前端(第一个位置);而try-catch语句,我们可以这么理解:当try包裹的发生错误或者我们主动抛出错误时,我们在catch语句怎么获取这个错误呢?答案就是,JS引擎帮我们将这个错误放到了作用域链的前端。如上面的代码,若原本的作用域链为[AO3,AO2,AO1],那么执行到catch语句时,作用域链就变成了[{err:{...}},AO3,AO2,AO1],这样我们自然就能读取到它了。

总结

  • JS获取一个变量时是沿着当前所处执行环境的作用域链上依次查找的,直到找到为止或找不到抛出错误。
  • JS运行代码时会创建相应的执行环境并压入执行栈中执行,执行栈中执行环境的顺序决定了变量的查找顺序。
  • JS代码的书写结构决定了JS代码的执行时执行环境的顺序。
  • var声明的变量会和函数声明会有声明提升的现象,顺序为函数声明在前,var声明在后
  • width表达式和try-catch语句会延长作用域链

参考:

1.《JS引擎线程的执行过程的三个阶段(一)》:https://www.cnblogs.com/BoatGina/p/10433518.html

2.《JavaScript高级程序设计第三版》

你可能感兴趣的:(作用域作用域链)