上个月写过一篇V8是如何运行JavaScript(let a = 1)代码的?,写完之后我就发现,我对平常使用的工具V8引擎,偏底层的知识了解的竟然是如此甚少。同时我真正从事前端的时间还算是比较短的,那么基础也算是非常的薄弱。结合以上,我打算有时间就去从底层的角度去学习了解,便于在使用过程中的理解和解决遇到的问题,理解JavaScript的本质,能够更好的学习JavaScript。如果你跟我有同样的困惑,那我们可以结伴同行,共同学习。
本系列我会从我的视角不断的去总结:
前言
- 1、作用域的概念和分类
- 2、作用域的小demo
- 3、执行上下文和作用域的区别
- 4、作用域链的理解
- 5、作用域链的小demo
- 6、总结
1、作用域的概念和分类
通俗的说,作用域就是变量和函数的可访问范围,即作用域控制着变量和函数的可见性与生命周期。
作用域其实就是存放变量和函数的地方,全局有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量和函数。在ES6之前,ES的作用域只有两种:全局作用域和函数作用域。
ES6时支持了块级作用域。
- 全局作用域
其中的变量和函数在任何地方都能被访问,其生命周期伴随着页面生命周期。
- 函数作用域
就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数的内部被访问。函数执行结束之后,函数内部定义的变量就会被销毁。
- 块级作用域
就是使用一对大括号{}
包裹的一段代码,比如函数、判断语句、循环语句等,甚至单独的一个{}
都可以看做是一个块级作用域。
2、作用域的小demo
<script> function foo() { var a = 1 let b = 2 { let b = 3 var c = 4 let d = 5 console.log(a) console.log(b); } console.log(b); console.log(c); console.log(d); } foo() </script>
- 第一个断点位置
{}
中声明的 var c
已经在本地作用域中了,只不过初始值为undefined,从右侧的作用域可以发现,断点的位置let b
此时为undefined,因为此行代码还没执行。
- 第二个断点位置
从右侧的作用域可以发现,已经生成了{}
代码块的作用域,两个let声明的变量已经赋值为undefined了,不过此时打印的话会报错,这个我之前的文章是说明原因,可以去找一下。
- 第三个断点位置
从右侧的作用域可以发现,右侧的作用域分为了三块
- 第一块全局作用域,其中包含了foo函数的声明
- 第二块本地作用域,其中包含了foo函数中变量的声明
- 第三块块级作用域(代码块),其中只包含了let声明的两个
变量b和d
,因为var声明的变量c
存在变量提升
,包含到foo函数的本地作用域中了。
块级作用域的实现是通过let和const声明的变量来支持,var声明的变量是不行的,因为var声明的变量存在变量提升
。
- 第四个位置
此时可以发现控制台打印区域打印顺序为1,3,2,4
1
,块级作用域中没有声明a变量,于是会继续向上(foo函数)查找a变量
3
,块级作用域中存在变量b的声明和赋值,直接打印b的即可
2
,直接在函数作用域中查找b的声明,也已经赋值,直接打印即可
4
, c变量虽然在块级作用域中声明,但是var声明的变量存在变量提升
,提升到函数级作用域,所以在此会正常打印
5
, d变量只在块级作用域中有声明,并且是let关键字声明的,在函数级并没有声明,在此查找d变量是找不到的,而且块级作用域在执行完毕后,在调用栈中就被移除了。所以控制台会进行报错处理d is not defined
3、执行上下文和作用域的区别
如果对执行上下文的理解还不够透彻,或许可以看看我的系列文章里的,有可能助于你的理解。
- 作用域是在执行该函数时形成的;而执行上下文是在编译阶段,也就是代码执行前形成的。
- 作用域相当于是静态的,函数创建后,作用域就不会变了;而执行上下文相当于是动态的,函数编译时将生成的执行上下文压入调用栈,函数执行完后移除调用栈,函数作用域随之被销毁。
- 执行上下文就是代码的运行环境,而作用域相当于依附于执行上下文而存在。
4、作用域链的理解
如果你理解了执行上下文、调用栈、词法环境、变量环境、作用域等概念的话,再来理解作用域链可能就比较轻松了。如果你有兴趣可以看看我文章开头的第二篇和第三篇
两篇文章。
在每个执行上下文的词法环境
中,都包含了一个外部引用,用来指向外部的执行上下文,我们就把这个外部引用成为outer,通过这个outer就可以继续查找并使用外部变量,这个外部的outer指向具体是如何判断的?
词法作用域: 词法作用域是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它能够预测代码在执行过程中如何查找标识符了。
通过词法作用域,也就是通过函数声明的位置,就可以确定这个outer具体的指向了,找到这个具体的指向,便可以继续查找并使用外部变量,这个链条就可以成为作用域链。
5、作用域链的小demo
<script> function bar(){ let test = 4 { console.log(myName) console.log(test) } } function foo(){ var myName ="fooName" let test = 2 { let test = 3 bar() } } var myName ='全局Name' let test = 1 foo() </script>
先不说最终打印的结果,如果代码只运行到断点的位置(foo中执行bar函数)如下图所示
可以发现上一行代码let test = 3
,在右侧单独分配了代码块
的作用域
代码继续执行,执行到(bar函数中的console.log位置)断点位置,如下图所示
右侧可以发现只有本地``作用域
,因为代码块中不存在变量的声明,所以在右侧也就没有相应的作用域了(不像foo函数中的代码块
)。
假设代码就运行到bar函数console.log的位置,现在我们就从执行上下文调用栈以及作用域链的角度去分析一下
- 第一步全局执行上下文
代码开始运行时,V8会先初始化运行所需,准备好调用栈
,然后创建全局执行上下文
,并将全局执行上下文
压入调用栈
,变量环境
中存储var声明的变量myName,词法环境
中存储let和const声明的变量test,词法环境
中同时存在一个类似外部指向的outer,通过这个指向来查找并使用变量,全局执行上下文
中的outer指向是null,因为全局执行上下文
是最大的上下文了,没有外部了。
- 第二步foo函数执行上下文
准备好全局执行上下文之后,就开始执行可执行代码了,于是找到了foo()。此时开始创建foo函数的执行上下文
,同时会将函数执行上下文
压入调用栈,同样的:变量环境
中存储var声明的变量myName,词法环境
中存储let声明的变量test,词法环境
中同时存在一个类似外部指向的outer,根据词法作用域
也就是函数声明的位置可以发现,这个outer指向了全局执行上下文
。
然后还有一块{}
包裹的代码块,在词法环境
中,这里会形成一个小型栈结构,栈底就是foo函数中声明的test=2,然后进入块级作用域
后,会将块级作用域
中的let=3压入栈顶,因为都是let声明的变量,所以这个小型的栈结构,发生在词法环境
中,同理如果有var声明的则会在变量环境中。
注意:块级作用域的实现是通过let或者const声明的变量来支持的。这里的块级作用域
是无需压入调用栈
的。
- 第三步bar函数执行上下文
然后继续执行foo函数中的bar函数调用,此时便开始创建bar函数的执行上下文
。同样的:词法环境
中存储let声明的变量test,此函数中没有var声明的变量,所以变量环境中为空。
然后开始执行{}
代码块中的console.log打印函数。
先说打印test变量: test变量在块级作用域
中没有声明,于是会到函数作用域
中进行查找,发现bar函数执行上下的词法环境
中存在test变量,便进行打印4。
再来打印myName变量:myName变量在块级作用域
中没有声明,于是继续会到函数作用域
中进行查找,发现bar函数作用域
中也不存在,于是通过作用域链
,根据outer外部引用,找到全局执行上下文中的myName="全局Name"
注意: 作用域链
的查找过程,一定要根据词法作用域
。
总结
- 作用域的分类:全局作用域、函数作用域、块级作用域
- 块级作用域是通过let或者const声明的变量来支持块级作用域的,var声明的变量存在变量提升,是不支持块级作用域的
- 执行上下文和作用域的区别
- 注意一下词法作用域的定义:根据代码在函数中声明的位置来决定的
- 作用域链就是根据词法作用域来查找变量的