JS中执行上下文与作用域【⭐️重点理解】
执行上下文是理解 JS 运行过程中一个很重要的概念。相当重要。本博文根据 JavaScript 高级程序设计第四版相关内容和网上的相关视频整理,详细总结了执行上下文当中内容,看完一定能明白 JS 的运行过程。
当你在网上查询执行上下文的内容时,你一定知道了JS 的预处理过程,也就是变量提升呀!函数提升等。这就是 JavaScript 在执行语句前,经过了一系列的“准备” ,为代码的执行创造一个“教室”----执行上下文
教室里会有很多上课需要的东西,如记录同学名字的点名册,黑板,粉笔等。执行上下文也会有很多代码执行需要的东西,如一些变量、函数等。如下图所示:
![执行上下文类比图](https://tva1.sinaimg.cn/large/008i3skNly1gujyemyg91j61iq0t4juw02.jpg)
执行上下文中最重要的两个内容是:文本环境 和 thisValue。本文中主要详细介绍文本环境。
文本环境:用来在 JS 代码执行前,把代码里面的所有变量名、函数名、类名登记的地方,JS 在执行过程中就可以去文本环境中查找变量、函数、类等信息。
JS 先找到执行上下文 ==> 文本环境 ==> 相关变量、函数、类等信息。执行上下文存放在执行栈内。
一、执行栈(Execution Context Stack)
执行栈是一个存放执行上下文的地方,满足先进后出规则。JS 每次创建一个执行上下文就会把它放入执行栈顶部。如下图所示:
![执行栈](https://tva1.sinaimg.cn/large/008i3skNly1gujzc1bcssj61b40e8div02.jpg)
- 执行栈栈顶的执行上下文称为当前执行上下文
- JS 代码总是在当前上下文中运行
- JS 代码中需要用到的资源,到当前执行上下文中找
二、执行上下文的创建条件
什么时候 JS 会创建执行上下文呢?在 ES6 中有下列四种情况会创建执行上下文,文中主要讲前两种。
- 进入全局代码,会创建全局执行上下文
- 进入 function 函数体代码,会创建相应函数的函数执执行上下文
- 进入 eval 函数参数指定的代码
- 进入 module 代码
代码段一示例:
console.log(foo); // undefiend
if(false){
var foo = "yes";
}
(Step1)全局执行上下文的创建:
当 JS 中只有上述代码时,一旦要开始执行代码,就会创建全局执行上下文并插入到上下文栈中,此时栈中只有全局执行上下文,所以全局执行上下文是栈顶,即当前执行上下文。
全局执行上下文比较特别的地方是它的文本环境有两部分组成,第一部分是 全局对象,在浏览器环境中,它就是 window 对象。另一部分是 全局scope 。全局执行上下文创建过程如下图右边步骤。
![第一步](https://tva1.sinaimg.cn/large/008i3skNly1guk1ayowhnj61h40r4n2t02.jpg)
- var 和 function 声明的变量和函数创建在全局对象中,会成为全局对象的属性和方法。
- let、const 和 class 声明的变量和类创建在全局 scope 中。
- 查找变量时先到全局scope 中找,找不到再到全局对象中找。
var let 关键词声明变量位置验证
let aLet = "aLet";
console.log(aLet); // aLet
console.log(window.aLet); // undefined
通过浏览器查看源码发现let 声明的变量插入到了 Script(即全局scope) 区域中。如下图
![let声明变量在全局scope区中](https://tva1.sinaimg.cn/large/008i3skNly1guk1u9gildj61kk0hin2f02.jpg)
var aVar = "aVar";
console.log(aVar); // aVar
console.log(window.aVar); // aVar
var 定义的变量放在全局对象中作为全局对象的属性。
console.log(Function); // ƒ Function() { [native code] }
console.log(window.Function); // ƒ Function() { [native code] }
Function 是 window 的构造函数。上述代码第一行会先查找全局scope ,结果没找到。再到全局对象中去找,找到了。
let Function = "let 定义覆盖了 Function";
console.log(Function); // let 定义覆盖了 Function
console.log(window.Function); // ƒ Function() { [native code] }
上述代码用let 声明并覆盖了一个与window 中同名的变量 Function ,进一步说明了let 声明的变量在全局scope 中,并且是先查找的。
(Step2) 对全局数据进行分析处理:
回过头来继续分析全局执行上下文的创建过程,在第二步中,只有 if 语句中有一个 var 声明,其他三项都没有直接略过。
console.log(foo); // undefiend
if(false){
var foo = "yes";
}
(Step3) 名字重复处理:
- let、const、class 声明的变量名或类名有重复则报错
- let、const、class 和 var、function 声明的变量名、类名或函数名之间有重复则报错
- var 和 function 名字重复时,var 声明的变量优先
(Step4) 创建绑定:
因为 var 声明的变量会放在全局对象中,并初始化为 undefined。如下图是步骤2到步骤4的处理过程。
![步骤2-4过程](https://tva1.sinaimg.cn/large/008i3skNly1guk2pp0xpuj61ho0rq45y02.jpg)
(Step5) 执行代码:
根据代码第一行要打印 foo 变量,需要去当前执行上下文中找。当前执行上下文即是全局执行上下文,在全局执行上下文的文本环境中,先查找全局scope,没有发现有 foo 变量,继续去全局对象中查找,找到了变量 foo ,值为登记时初始化的 undefined。所以第一行代码打印出 undefined。
![步骤2-4过程](https://tva1.sinaimg.cn/large/008i3skNly1guk3y4skg9j61js0t2dmd02.jpg)
之后进入到 if 判断语句,结果 if 条件为 false ,因此下面代码不会执行了。
代码段二示例:
let foo = "good";
console.log(foo); // good
(Step1)全局执行上下文的创建:
![第一步](https://tva1.sinaimg.cn/large/008i3skNly1guk49g6t7kj61ds0owgqt02.jpg)
(Step2) 对全局数据进行分析处理:
在第二步中,只有一个 let 声明,其他三项都没有直接略过。
(Step3) 无重复名字处理:
(Step4) 创建绑定:
因为 let 声明的变量会放在全局scope中,但不初试化。
![第2-4步](https://tva1.sinaimg.cn/large/008i3skNly1guk4h6m0trj61di0oq0z302.jpg)
(Step5) 执行代码:
![第2-4步](https://tva1.sinaimg.cn/large/008i3skNly1guk4pis6cdj61pa0u0jz302.jpg)
代码段三示例:
var a = 10;
function foo(){
console.log(a);
let a;
}
foo(); // 报错ReferenceError: Cannot access 'a' before initialization
(Step1)全局执行上下文的创建:
![第2-4步](https://tva1.sinaimg.cn/large/008i3skNly1guk50m48ttj61k30u0gs002.jpg)
(Step2) 对全局数据进行分析处理:
在第二步中,有一个 var 声明和一个顶级函数声明。
(Step3) 无重复名字处理:
(Step4) 创建绑定:
var 声明的变量放在全局对象中,并用 undefined 初试化。function 函数声明时会创建一个函数对象,里面存储函数的相关信息,声明的函数名也会放在全局对象中,并用创建的函数对象给它初试化,即函数名 foo 赋值为函数对象的地址obj。其中函数对象在创建时会在函数对象”体内“ 保存函数创建时的执行上下文的文本环境[[environment]]指向当前执行上下文的文本环境。如下图所示
![第2-4步](https://tva1.sinaimg.cn/large/008i3skNly1guk5ora832j61ka0u0jzv02.jpg)
(Step5) 执行代码:
执行第1句代码:给变量 a 赋值为 10。
![执行第1句代码](https://tva1.sinaimg.cn/large/008i3skNly1guk63r7gzuj61jx0u0wlw02.jpg)
执行第2句代码:调用foo()函数
函数的调用会创建相应的函数执行上下文。函数执行上下文的文本环境会以函数对象体内保存的文本环境即[[environment]]指向的内容为父。
![创建函数执行上下文](https://tva1.sinaimg.cn/large/008i3skNly1guk6mugtjvj61fe0r8wmo02.jpg)
接着同样在函数体内进行分析变量声明。在这里要注意函数执行上下文和全局执行上下文不同,函数执行上下文中的文本环境只有一个区域,函数scope ,无论哪一种声明变量都放在这个函数 scope 区域。
分析函数体内就只有 let 声明的变量 a ,并将其登记在自己的文本环境中。如下图:
![函数文本环境的登记](https://tva1.sinaimg.cn/large/008i3skNly1guk72e7apkj61fi0rg10q02.jpg)
最后执行函数体内的语句:打印变量a ==>去当前执行上下文中找 ==> 函数foo执行上下文 ==> 函数的文本环境。在函数的文本环境scope区找到没初始化的 a ,于是报错。
![执行函数体语句](https://tva1.sinaimg.cn/large/008i3skNly1guk7bb92eqj61fg0r4dng02.jpg)
从这里可以看出,let、const、class关键字声明的变量也有提升,只是没有初始化,所以在赋值前不能使用,这便是暂时性死区。
代码段4示例【作用域的理解】
至此可以对作用域有一个更好的理解:作用域是解析(查找)变量名的一个集合,就是当前执行上下文(也可以是当前执行上下文的文本环境)
全局作用域就是全局执行上下文
函数作用域就是函数执行上下文
函数调用时的执行上下文看”身世“------函数在哪里创建,就保存那里的执行上下文的文本环境。
函数的作用域是在函数创建的时候决定的而不是调用的时候决定。
不是很懂看看下面例子
function foo(){
console.log(a);
}
function bar(){
var a = 3;
foo();
}
var a = 2;
bar(); // 2
熟悉了前面几个例子的过程,这里不再详细演示每一步了。
第一大步: 创建全局执行上下文
第二大步: 代码段中有 foo 函数、bar 函数、var 声明的变量 a 需要登记。注意函数声明的三个操作如图:
![第一二大步过程图](https://tva1.sinaimg.cn/large/008i3skNly1guka1alrbnj61ha0tyn7j02.jpg)
第三大步: 执行 a = 2 以及 bar 函数的调用,函数的调用有如下图中描述的三个操作。
![第三大步过程图](https://tva1.sinaimg.cn/large/008i3skNly1gukagd49erj61hm0twgwm02.jpg)
第四大步: 执行bar函数体内的语句,给变量 a 赋值为3,并且调用 foo 函数。给变量 a 赋值时,沿着当前执行上下文 ==> bar函数执行上下文 ==> bar 函数执行上下文的文本环境 ==> 在自己的文本环境的函数scope区中找到 a ,于是赋值为3。调用foo 函数同第三步。
![第四大步过程图](https://tva1.sinaimg.cn/large/008i3skNly1gukau8ynmaj61gy0u0n6w02.jpg)
第五大步: 执行foo函数里的语句:打印变量 a。查找变量a:当前执行上下文 ==> foo函数执行上下文 ==> foo 函数执行上下文的文本环境(没有变量a) ==> 继续找父文本环境(foo函数对象中[[environment]]中有记录) ==> 全局执行上下文中的文本环境(有变量a)。于是打印它的值为2。
![第五大步过程图](https://tva1.sinaimg.cn/large/008i3skNly1gukb1tm0aij61h20u0wmg02.jpg)
这段代码如果按照函数的调用来形成作用域,那么当bar(函数调用 foo 函数时,bar 的文本环境将作为foo 的父文本环境,从而会打印出 3 。而事实并不是如此。
此例很好的说明了:作用域链并不是根据函数的调用嵌套关系形成的,而是根据函数创建嵌套即书写位置形成的。
代码段五【块级作用域-块内变量】
let a = "out";
if(true){
let a = "in";
console.log(a); // in
}
console.log(a); // out
按照前面的分析方法,先完成全局上下文的创建工作以及第一条语句的执行工作,到执行 if 语句块里前是下图所示结果
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukc056jk5j61fg0scjxd02.jpg)
进入块作用域分析中,按照块作用域分析过程(下图所示),首先为块作用域创建一个记录环境(相当于块作用域的文本环境)但不创建执行上下文。并将记录环境链接到原来记录之前。再去找到函数声明或 let 、const 声明的变量(没有var,在全局执行上下文创建时已经把块里的提升出去了)记录到创建的记录环境中,但不初始化。
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukc91qkdaj61f20rydm902.jpg)
执行 if 块中的语句:给变量 a 赋值为 “in” 。打印变量 a。根据作用域链来查找 a 就是找到作用域块中记录环境中的变量 a 。
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukcj5g779j61f00sg7ab02.jpg)
执行完代码块语句后,退出代码块后 JS 会将创建的记录环境给销毁掉,并把原来的链接接回执行上下文栈。再次打印变量a 时,按链接会找到全局执行上下文中的文本环境中的变量 a ,值为out。这就是块作用域内的变量不能在快外访问的原因,退出块作用域就会随着块作用域的文本环境给销毁掉。
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukcsu63gzj61ey0s4jwx02.jpg)
代码段六【块级作用域-块内函数】
console.log(foo); // undefined
if(true){
function foo(){
console.log("in");
}
}
foo(); // in
进入块级作用域前的工作结果如下:【注意】以下结果是在谷歌浏览器下的实验结果,其他浏览器可能不一致。
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukdje6lvqj61ge0ry0zj02.jpg)
进入块级作用域后会创建一个文本环境插入到当前的文本环境中。然后就是进行块作用域的分析过程,如下图,找到块中顶级的函数声明或 let const 变量声明,这段代码中只有函数声明,函数的声明会创建函数对象,(注意在全局作用域分析中虽然找到了块内的函数声明,但是初始化为 undefined ,没有用到函数对象的地址)并且函数对象中有记录创建函数时的执行环境 [[environment]]。并在块作用域中登记函数名,赋值为刚刚创建的函数对象。
![块级作用域前期工作](https://tva1.sinaimg.cn/large/008i3skNly1gukdw0tcfgj61go0s0n5802.jpg)
在退出块作用域时,如果块里面的文本环境记录的变量名与全局对象里记录的变量名有重复,则会先把块里面的文本环境记录的变量名的值覆盖掉全局对象中同名的变量值,再把创建的块记录环境给销毁掉,并把原来的链接接回执行上下文栈。注意这里块的文本环境被函数对象所引用了,所以不会销毁。
![退出块1](https://tva1.sinaimg.cn/large/008i3skNly1gupp7lctb2j31l20u045i.jpg)
![退出块2](https://tva1.sinaimg.cn/large/008i3skNly1gupqfm3vp4j61eg0qqn4802.jpg)
退出块作用域后下一个语句是 foo 函数调用,函数调用会创建函数执行上下文,文本环境会以函数对象创建时保存的文本环境为父,并需要修改当前执行上下文指向 foo 函数执行上下文。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gupql0qeuzj61fa0qgtga02.jpg)
最后执行 foo 函数内部的语句打印 “in” 。结束代码段。
代码段七【块级作用域】
var liList = [];
for (var i = 0;i < 2; i++){
liList[i] = function(){
console.log(i);
}
}
liList[0]();
liList[1]();
首先,构建全局执行上下文,提升 var 声明的变量 liList 和 变量 i 并初始化为 undefined。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gups413av3j61im0u0gq202.jpg)
执行语句:给 liList 变量赋值为 []。给 i 变量赋值为 0。判断条件 i 是小于2的,进入块里面。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gups7eo2mgj61i60u0whv02.jpg)
进入块后,会立即创建一个块内容的记录环境,并链接到当前的文本环境上。且块内有一个函数表达式式的声明(这种不会提升),但会创建函数对象,并赋值为 liList[0],同时函数对象体内保存创建时的文本环境。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gupsbrle0cj61j30u0q8m02.jpg)
块内只有一个函数的定义,无其他内容,执行完后退出块,连接回原来的文本环境链接。并执行 i++ 判断是否进入下一个循环。同上这里 i= 0的块的文本环境有被 liList[0] 函数对象引用,因此不销毁。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gupsjomm77j61hw0u0jx202.jpg)
i = 1,依然小于2 ,继续进入块,又创建 i = 1时的块的文本环境。同样创建相应的函数对象如下图所示:
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gupss8hnv5j31ho0u00ym.jpg)
退出块内容,连接回原来的文本环境链接,执行 i++ ,i 值变为2 ,判断不能进入下一个循环。之后执行下面的代码调用 liList[0] 函数,会创建函数执行上下文,该 liList[0] 函数的执行上下文会以 liList[0] 函数对象体内保存的创建函数对象时的执行环境为父,即 i = 0时的块文本环境。函数内的语句为打印变量 i ,一路找到 i 值为2 打印。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1gupt89y65gj61ho0u07cl02.jpg)
同样执行 liList[1] 函数的过程如下图所示。也打印 2 。
![调用函数](https://tva1.sinaimg.cn/large/008i3skNly1guptck1hubj31hu0u0wm6.jpg)
上图中有个小错误,就是 liList[0] 函数执行完后会退出执行上下文栈,再次调用 liList[1] 函数时创建它的执行上下文加入执行栈的栈顶,但不影响分析。
如果将本案例中for 中的 var 换成 let 声明,因为 let 是块级作用域的,会在每个循环块中赋值当前的 i 值,因此最后会输出 0 1。可以自己动手画一画分析的过程。