最近在看《你不知道的JavaScript》,以下的内容都是关于此书的内容。也算是自己看完之后的一个总结(或者说笔记)
JavaScript编译原理
咦!不是将作用域吗?为啥讲到编译原理了?
别急,编译原理讲完之后有助于理解后面的作用域的概念,先卖个关子,开始理解一下JavaScript的编译过程:
编译就是指将源语言转换成机器认识的2进制语言的过程。而编译分为五个阶段:词法分析、语法分析、语义检查和中间代码生成、代码优化、目标代码生成。
JavaScript是一种具有函数优先,即时编译型的脚本语言, 也就是说我们编写JS代码运行的时候需要运行环境来编译成机器语言
JS的编译过程也是经历上面说的几个流程:
- 分词/词法分析
就是将我们写的代码分解成有意义的代码块(词法单元)
比如: 我们写的变量赋值:var a = 2,会被分解成 var, a, = , 2 这样的代码块,方便下面语法分析 - 解析/语法分析
这个阶段会把上一步分解成的词法单元,转换成抽象语法树(AST),每一种词法单元都会对应一个语法树上的节点,每个节点有个type来表示词法单元的类型
3. 代码生成
将抽象语法树转换成机器语言,可以被当前环境识别的语言。
在JS的编译过程中也会有对应的性能优化等步骤,感兴趣的话可以自己去研究一下。
我们上面简单的说完了编译的流程是为了更好的理解下面的作用域的概念,下面就来看一下作用域到底是什么。
作用域
作用域就是一套规则,用于存储(把变量存储在什么地方)和查找(当使用到变量的时候去什么地方查找)变量。
理解作用域
理解作用域?啥意思?
就是说理解一下作用域如何存储(收集)变量以及通过怎么样的规则来查找变量
我们还是来用 var a = 2 来说明:
首先变量的赋值操作 虽然看似是一行代码,但实际是分为两个步骤来执行:
-
第一步:声明变量a
首先编译器(负责语法分析以及代码生成)会在当前作用域中声明一个标识符为a的变量(会先在当前作用域中查找该变量,然后找到就不重新声明,没找到才重新声明)
-
第二步:给变量a赋值
这一步是在代码运行阶段执行,引擎(负责整个JS代码的编译和执行过程)会问作用域有没有这个标识符为a的声明,有的话给他赋值为2,没有的话就抛出异常(ReferenceError类型)
上面两步说明了收集和查找变量的过程,这也就是作用域的作用。
编译器问作用域是否已经声明过了a变量,没有的话,编译器就会在当前作用域声明一个变量a,有的话合并声明。
引擎看到a=2,就会在作用域中查找该变量,如果能找到就给他赋值。
LHS和RHS查找
LHS查找
引擎执行代码时遇到了a=2,引擎就会问作用域,作用域中有没有a声明呀?也就是说并不关心a的值是什么,引擎只是想把2赋值给a而已。
RHS查找
当把a赋值给其他变量时就会执行RHS查找,来判断a的值是什么,可以理解为:取到它的源值。
下面我们来根据一段代码来理解一下两个查找的过程:
function foo(a){
var b = a
return b+a
}
var c = foo(2)
复制代码
上面函数的执行过程应该是这样的:
引擎:作用域帮我找一下 c 是否在你里面定义了,(LHS)
作用域:是的,刚刚编译器定义过了,
引擎:在麻烦帮我看一下 foo 是不是定义过了? (RHS)
作用域:是的,也定义过了呢。
引擎:看一下 a有没有定义过呢?老哥,(LHS)
作用域:是的呢,刚刚编译器定义了一个函数参数 a
引擎:好的 我现在要把2 赋值给a了
引擎:我又发现一个变量b ,麻烦老哥再看一下呢?(LHS)
作用域:是呢 ,你在执行的函数环境里面有一个声明呢
引擎:A~ 我又看见这个a了帮我看一下是不是刚刚我赋值用的那个呢?(RHS)
作用域:好嘞 ,小老弟,放心用,就是那个玩意。
引擎:老哥 我又来了 这次我又在其他地方碰见a了,在帮我确认一下是不是他呢?(RHS)
作用域:小老弟,最后一次了呀,对 还是他,函数里面就是他。
引擎:好的,A~,我又碰见b了,老哥 这次你还帮忙不?(RHS)
作用域:哎~你可真烦哦。对 还是刚才那个b
引擎: 老哥,有你在我可真放心,好了,我都确认完了 要开始干活了… 我要把 这两个值(a,b)的和 赋值给c ,我也就可以休息了。
作用域:…
作用域嵌套
当在当前作用域找不到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或者抵达最外层的作用域(全局作用域)
词法作用域/动态作用域
通过上面的说明,应该对作用域已经有所理解了,下面来看一下两个新的名词:词法作用域和动态作用域
词法作用域
词法作用域就是定义在词法阶段的作用域,就是说跟我们写代码时将变量定义在哪个函数作用域或者块作用域中有关系,大多数情况是不变的(evel/with会修改)
下面使用一个书中的图片来更清晰的解释一下:
- 包含着全局作用域,包括标识符foo
- 包含着foo所创建的作用域,包括标识符:a,bar和b
- 包含着bar所创建的作用域, 包括标识符c
无论函数在哪里被调用,它的词法作用域都只有函数被声明时所处的位置决定。
动态作用域
动态作用域就是说当查询标识符的时候不是按照词法作用域嵌套来查找,而是按照调用顺序来查找的,
也就是说 动态作用域不关注你在什么地方声明,它只关心是在何处调用的。
说起来还是有点绕口还是举个例子来说明一下:
function foo(){
console.log(a)
}
function bar(){
var a = 3
foo()
}
var a = 2
bar() // 3 不是2
复制代码
看上面代码,实际foo输出的是3 不是2 ,foo是在全局作用域中创建的也就是说如果按照作用域嵌套的规则来查找变量a的时候应该是2,但实际是bar作用域中的3
也就是上面所说的动态作用域查找的时候不关心在何处定义,而是基于调用栈的。
总结
词法作用域是在写代码时确定的,动态作用域是在运行时确定的
词法作用域关注函数在何处声明,而动态作用域是关注函数在何处调用
闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
描述闭包的概念很多,下面还是通过代码来说明什么是闭包:
function foo(){
var a = 2
function bar(){
console.log(a)
}
bar()
}
foo() // 2
复制代码
上面的例子是闭包吗?我感觉是的,因为bar访问了foo作用域中的数据,当然也是通过我们上面说过的词法作用域嵌套查找规则来访问变量a
上面的例子并没有体现函数是在当前词法作用域之外执行这么一个特点,下面再来看一个例子
function foo(){
var a = 2
function bar(){
console.log(a)
}
return bar
}
let baz = foo()
baz() // 2
复制代码
上面foo()执行之后返回一个内部bar函数,然后赋值给了baz并调用baz(),实际这个过程实际上就是调用bar()函数,只不过把这个函数赋值给了几个不同标识符而已
这个例子明显的是bar函数在定义的词法作用域之外执行了
Tip: foo执行之后,通常会销毁该函数作用域,但是因为bar函数对其作用域的变量有引用,所以没有立即被回收
无论使用何种方式对函数类型的值传递,当函数在其他作用域被调用时都可以理解为闭包。
function foo(){
var a = 2
function bar(){
console.log(a)
}
return bar
}
function baz(fn){
fn() // 这就是闭包
}
baz(foo())
复制代码
下面使用最经典的for循环的例子来说明一下闭包的使用:
for(var i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
},0)
}
/*
我们期望的是 打印1,2,3,4,5, 实际上是6,6,6,6,6
虽然我的延时执行时间设置了0秒,但是setTimeout中的回调函数仍然会在循环结束之后执行(参考宏任务和微任务),而此时i=6,当几个回调函数timer执行的时候查找i的值都是6,所以输出5个6
那如果我生成一个封闭的作用域是不是就行了?
*/
/*
通过IIFE创建一个封闭的函数作用域
*/
for(var i = 1 ; i <= 5; i++){
(function IIFE(){
setTimeout(function timer(){
console.log(i)
},0)
})()
}
/*
上面的也是不行的,虽然创建了封闭的作用域但实际访问的i仍然是全局作用域中的i
那就是说我们必须要访问函数作用域中的变量,那就可以把变量传进来就好了
*/
for(var i = 1 ; i <= 5; i++){
(function IIFE(j){
setTimeout(function timer(){
console.log(j)
},0)
})(i)
}
/*
我们每次迭代都会创建一个封闭的作用域,当然也可以使用let声明来生成块级作用域,
*/
复制代码
总结与思考
上面的就是自己对作用域和闭包的理解,可能会有的地方写的不够严谨或者有错误,欢迎评论。