前言
《你不知道的Javascript》上卷中对于作用域和闭包的介绍写得真的太好了!形象生动,通俗易懂,读之前,我脑子中对这块知识只有一团模糊的身影,知道大概是什么东西,也明白它们的工作流程,但是却一直困顿于它们为什么会这样。在阅读《你不知道的Javascript》上卷以及参考各路文章后,终于触及到了它们的真面目。
本文参考资料
《你不知道的Javascript》上卷
《JavaScript高级程序设计》第四版
深入理解javascript原型和闭包
一、作用域
在直接给作用域下定义之前,我们先来看看“作用域”到底怎么来的,又是到底怎么用的。
1.1 编译原理
我们先明确一个概念,JavaScript是一门编译语言。但是它跟传统的编译语言不同,它不是提前编译的,编译的结构也不可以在分布式系统中进行移植。但是,JavaScript引擎进行编译的步骤和传统的编译语言非常相似。
传统编译语言的流程中,一段源代码在执行之前,一般会经历这仨步骤:词法分析 → 语法分析 → 代码生成;举例说 var a = 2 ,词法分析会把它分解成词法单元:var、a、=、2、;。语法分析是将词法单元流转换成语法树,代码生成就是将语法树转换成可执行代码(机器指令)
1.2 理解作用域
在JavaScript中,代码片段在执行前的编译离不开以下仨朋友:
引擎:从头到尾负责整个JavaScript程序的编译及执行过程
编译器:负责语法分析及代码生成
作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限
以var a = 2
为例,在var a = 2
这段代码,可别以为它只是一句声明:引擎认为,这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。
1.3 作用域嵌套
作用域是根据名称查找变量的一套规则。但实际情况中,通常需要同时顾及好几个作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
遍历嵌套作用域链的规则:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
二、词法作用域
作用域有两种工作模型:词法作用域(大多数编程语言采用)和动态作用域(Bash脚本、Perl中的一些模式)
为什么叫词法作用域?因为大部分标准语言编译器的第一个工作阶段叫作词法化,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
简单来说,词法作用域就是定义在词法阶段的作用域。是由这个变量和块在哪里写决定,而不是由这个变量和块在哪里调用被决定。词法作用域是一生下来就决定了,并且一般不会再改变。
让我们来看看下面代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
查找
当在嵌套的作用域查找变量时,作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”——内部的标识符“遮蔽”了外部的标识符。
全局变量会自动成为全局对象(比如浏览器中的window对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。例如window.a
。这样我们就可以访问到那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。
三、函数作用域和块作用域
3.1 函数作用域
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
3.2 隐藏内部实现
隐藏变量和函数是一个有用的技术,它们都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计。
除此之外,“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
3.3 函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。比如说:
var a = 2;
function foo() { // <—— 添加这一行
var a = 3;
console.log( a ); // 3
} // <—— 以及这一行
foo(); // <—— 以及这一行
console.log( a ); // 2
但是这段代码中并不理想,因为它导致了一些额外的问题 :
- 声明了一个具名函数foo(),foo这个名称本身“污染”了所在作用域,这里是全局作用域
- 必须显式地通过函数名(foo())调用这个函数才能运行其中的代码
针对这个问题,我们其实有解决办法:匿名函数——不需要函数ing,并且能够自动运行
var a = 2;
(function foo(){ // <—— 添加这一行
var a = 3;
console.log( a ); // 3
})(); // <—— 以及这一行
console.log( a ); // 2
!!!包装函数的声明以(function……而不仅是以function……开始。函数会被当作函数表达式而不是一个标准的函数声明来处理。
区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式
3.4 块作用域
ES6引入了新的let关键字,提供了除var以外的另一种变量声明方式。
let关键字可以将变量绑定到所在的任意作用域中(通常是{ ..}内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。
闭包的内容下一篇再写吧ORZ,先去学点别的缓缓呜呜呜