JS的作用域是一个老生常谈的话题,本文将深入探讨它内部的原理。在正文开始之前,我们先来了解一下和作用域相关的几个重要的知识点。
JS执行的三个阶段
JS引擎运行JS代码分为三个阶段:
语法分析阶段
该阶段对js代码块的语法进行分析:如果发现语法不正确,就向外抛出一个语法错误(SyntaxError),停止该js代码块的执行,然后继续查找并加载下一个代码块;如果语法正确,则进入预编译阶段。
预编译阶段
在预编译阶段,JS引擎会为代码创建相应的“执行上下文”。
执行环境
“执行上下文”即“执行环境”,为了简化概念,我们统称为“执行环境”,JS引擎在运行JS代码的时候,会给全局代码、每个函数、eval函数包裹的代码创建相应的“执行环境”,并在执行阶段将他们“压”入“执行栈”中执行。共有三种类型的执行环境:
- 全局执行环境
- 函数执行环境
- eval执行环境
而创建执行环境时主要做了以下三件事情:
创建变量对象
创建变量对象主要是经过以下过程,如图所示:
- 创建arguments对象,检查当前上下文的参数,建立该对象的属性与属性值,仅在函数环境(非箭头函数)中进行的,全局环境没有此过程。
- 检查当前上下文的函数声明,按照代码顺序查找,将找到的函数提前声明,如果当前上下文的变量对象没有该函数名属性,则在该变量对象以函数名建立一个属性,属性值则指向该函数所在堆内存地址引用,如果存在,则会被新的引用覆盖掉。
- 检查当前上下文的变量声明,按代码顺序查找,将找到的变量提前声明,如果当前上下文的变量对象没有变量名属性,则在该变量对象以变量名建立一个属性,属性值为undefined;如果存在,则忽略该变量声明。
函数声明提前和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。
创建作用域链
作用域链由当前执行环境的活动对象和上层(外层)执行环境的活动对象组成,是一种有序列表或链表结构。
确定this的指向。
这一阶段比较复杂,其实我们只要记住,对于一个函数来说,谁调用它,this就指向谁;如果没有指定调用方, 在浏览器环境下,this就指向window
(严格模式下指向undefined
)
执行阶段
预编译阶段结束后进入执行阶段。
在执行阶段,JS引擎会将上一阶段创建的执行环境推入“执行栈”中执行,此时执行环境中的变量对象上的属性按顺序得到赋值,变成“活动对象”。
在这一阶段,会把当前活动对象添加到作用域链的前端(起始位置),在访问一个变量时,会沿着作用域链一个个地查找,直到找到为止。若直到最后都找不到会抛出“ReferenceError”错误。
下面进入正文。
要理解作用域和作用域链,其实只需记住一句话即可:作用域是由函数声明的位置决定的。
每个函数的执行环境中维护着一个内部变量(只有JS内部可访问),这个变量指向外部执行环境,我们称之为outer。作用域链正是由outer指向的执行环境的活动对象组成。浏览器中,window对象作为作用域链的最后一个查找对象,而node.js中这个对象是global。
为了更好地示意,我们以一段代码为例:
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运行原理的角度,我们怎么理解这样输出的原因呢?下面我们一步步分析,揭开它的神秘面纱。
这段代码我们很容易得出,fnA和fnB的执行环境中的outer都是全局执行环境,全局执行环境是最外层的环境。
首先,JS引擎对全局代码进行语法分析,没有发现语法错误,进入预编译阶段。
在预编译阶段,为全局代码创建全局执行环境,根据上文讲的规则,其结构用我们最熟悉的JS代码表示如下:
// 注意,执行上下文和其中的变量对象、活动对象、作用域链都是JS引擎内部使用的,外部
//(也就是我们编写JS代码时)无法访问,只有this可以通过“this”关键字访问
globalContext = {
VO: { // 变量对象
fnA: function(){ }, // 对应函数体,略
fnB: function(){ }, // 对应函数体,略
a:undefined
},
scope:[window],//outer=window
this:window
}
ps:这里用JS是为了更好地说明,实际JS引擎是由更底层的语言编写的。下文的分析为了便于理解简化了一些细节,须知悉。
做完这些事情,进入执行阶段,此时JS引擎将全局执行环境“压入”执行栈中执行,变量对象上的属性根据顺序赋值,变成活动对象:
globalContext = {
AO: { // 活动对象
fnA: { }, // 对应函数体,略
fnB: { }, // 对应函数体,略
a:1
},
scope:[globalContext.AO,window],
this:window
}
执行全局代码时遇到fnA()
,将fnA的函数体取出,对其中的函数代码进行语法分析,然后进行预编译,创建fnA的执行环境:
fnAContext = {
VO: {}, // 变量对象,没有声明任何函数和变量
scope: [globalContext.AO,window], // fnAContext中的 outer=globalContext
this: window
}
进入执行阶段:
fnAContext = {
AO: {}, // 变量对象,没有声明任何函数和变量
scope: [fnAContext.AO,globalContext.AO,window], // 作用域链
this: window
}
执行时遇到赋值语句a = 2
,在作用域链上的globalContext.AO
找到a。将其值变为2。到此为止,全局变量a的值已被改变。
接着遇到fnB(3)
,以同样方式,最终在globalContext.AO上找到fnB,将其函数代码取出,进行语法分析、预编译。
fnB预编译结果:
fnBContext = {
VO: {
a:undefined, // arguments.a 重复的声明var a 被忽律
},
scope: [globoalContext.AO,window], // fnBContext中的outer = globalContext
this: window
}
接着进入执行阶段:
fnBContext = {
AO: {
a:3, // arguments.a传入值3
},
scope: [fnBContext.AO,globoalContext.AO,window], // 作用域链
this: window
}
fnB第一句代码输出a的值,我们在很幸运找到了fnBContext.AO.a,此时它已经被传入值赋值为3,因此这里console.log(a)
输出值为3。接着执行时遇到赋值语句a = 4
,fnBContext.AO.a的值改变为4,所以下一句的console.log(a)
输出值为4
接着,fnB(3)
执行完毕,其执行环境被弹出,栈指针下移动,回到fnA的执行环境,继续执行下一条语句console.log(a)
,根据前文分析我们得知其输出的是globalContext.AO.a的值,因此输出改变后的值2。
此时回到全局执行环境继续执行后面的代码console.log(a)
,输出改变后的globalContext.AO.a
的值2。
到这里,全局代码也执行完了,我们得出所有的输出是:
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高级程序设计第三版》
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。