第一部分:作用域和闭包
第一章:作用域是什么
第二章:词法作用域
第三章:函数作用域和块作用域
第四章:提升
第五章:作用域闭包
1、简单的概括图
2、3个简单的demo
下面这几个 demo 是考察你是否了解 JS引擎 在编译和执行时的工作机制
3、JS是编译语言
第一章原文节选:
尽管通常将 JavaScript 归类为‘动态’或‘解释执行语言’,但事实上,它是一门编译语言。
但与传统编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)
编译时 JS引擎 会做哪些事:
简单概括:词法分析 -> 语法分析(抽象语法树 AST)-> 代码生成(将 AST 转换为可执行代码,供 JS引擎 执行阶段使用)
编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找,其中具体的细节是这样的:
3.1 如果遇到一个变量的声明例如 var a,JS引擎会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中?
如果是,JS引擎会忽略该声明,继续进行编译,否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a
3.2 如果变量(含函数表达式)是以 var 关键字声明的,该变量的声明会被提升到当前作用域的最顶部,变量的赋值语句则留在原先位置
3.3 如果是函数声明,整个函数定义都会被提升到当前作用域的最顶部
3.4 如果当前作用域出现了同名的函数声明和变量声明,函数声明优先被提升到作用域的最顶部,同名的变量声明则会被忽略
3.5 示例分析
上面的代码段会被引擎理解为如下形式:
注意,var foo 尽管出现在 function foo() ...... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前
尽管重复的 var 声明会被忽略掉,但出现在后面的对 foo 的赋值操作还是能覆盖前面的函数定义
扩展阅读:
这篇文章来自知乎,里面一些优秀的回答能帮助你加深对 JavaScript 是编译语言的理解
《V8引擎本用了什么编译技术,使得 JavaScript 与用 C 写出来的代码性能相差无几?》
4、JS是词法作用域、JS引擎在编译和执行阶段都要和作用域打交道
作用域是一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找
词法作用域最重要的特征是它的定义过程发生在代码的书写阶段( 假设你没有使用 eval() 或 with )
词法作用域的形成阶段主要发生在编译阶段,JS引擎在执行阶段主要是进行作用域查询:
以 var a = 2 为例
JS引擎会在解释 JavaScript 代码之前首先对其进行编译,JS是先编译后执行
所以在 JS引擎 看来,var a = 2 不是一个声明,而是两个单独的声明 var a 和 a = 2
第一个声明是编译阶段的任务,如果当前作用域下没有 a 变量就创建,如果有,就忽略该声明
第二个声明是执行阶段的任务,如果当前作用域下有 a 这个变量就将2赋值给a,如果没有就去当前作用域的上一级作用域查找,以此类推,如果到了全局作用域还是没有找到a变量,引擎会抛出 ReferenceError
5、函数作用域、块作用域
ES6之前,每创建一个函数就会产生一个新的基于函数的作用域,ES6出现后,JS开始拥有基于代码块( 通常指 { .. } 内部)的作用域
块作用域:
特点:编译时,块作用域里的变量的声明不会被提升
优点:
5.1、垃圾收集 ( 原书3.4节 )
5.2、let 循环
5.2.1 var 循环
我们期望的结果可能是这样的:
先执行 for 循环,因为循环2次,所以第一次循环打印 i 其值为0,第二次循环打印 i 其值为1,循环结束后打印 i 其值为 2
分析为什么期望和运行的结果不一致:
当既有同步代码也有异步代码时,引擎会先执行同步代码,所有的同步代码都执行完毕后,再回过头去执行异步代码
第一次 for 循环时,循环体是一个定时器它是异步的,所以引擎暂时不会执行它,会将它放到事件队列中。
此时循环计数 i 由 0 变为 1,满足循环条件,可以进行第二次循环
因为此处的 for循环 的循环体无论循环多少次,循环体都不变还是定时器,所以第二次循环时,引擎又将一个定时器放到事件队列中
此时循环计数 i 由 1 变为 2,不满足循环条件,循环结束
for循环后是一条console语句,它是同步的,所以引擎会直接执行它,引擎会在当前作用域即全局作用域查找变量 i,找到后打印其值为2
这段程序的所有同步代码都执行完毕了,引擎开始去处理哪些存在事件队列中并且满足当下执行条件的异步代码,找到符合要求的两个定时器
这两个定时器都要打印变量 i 的值,从词法作用域可以看出,它们要打印的就是全局变量 i 的值,而全局变量 i 的值现在是2
所以运行的结果是,先执行了 for循环 之后的console语句,打印2,最后执行两个定时器且打印的结果都是2
5.2.2 let 循环
为什么 let 循环运行后的结果就能符合我们的预期呢?
分析:
和 var 循环一样,每次循环体里的定时器都会被放到事件队列中,等程序所有的同步代码都执行完毕后,才会去执行事件队列中的异步代码
循环结束后没有同步代码可执行,而且定时器的延迟时间我们设置的是 0ms,所以循环一结束,引擎就会立马执行那两个定时器的回调函数
这两个定时器的回调函数都要打印一个变量 i 的值,说明这个变量 i 的值一定不是全局变量,如果是全局变量两次打印的值肯定是一样的
但 let循环 实际运行两次打印的值都不一样,所以这个变量 i 到底是什么作用域呢,既不是全局作用域,也不是函数作用域(for循环代码外面没有函数包裹)
是块作用域
for 循环头部的 let 声明还会有一个特殊的行为:
每次迭代循环计数 i 都会被重新声明一次,所以 let 循环每次迭代都会生成一个全新的封闭的块作用域,实际效果类似下方代码
以第一个定时器回调函数为例,它需要在作用域里找到变量 j 才能完成 console 语句。
JS是词法作用域,作用域在代码书写的时候就定义好了
以 let 关键字声明的变量会生成块作用域,在 { ... } 块内的确有一个变量 j,其值被赋值为每次迭代开始的循环计数
每循环一次就会执行一次 let j,就会生成一个新的块作用域,所以两个回调函数都打印了 j 变量,但这两个 j 变量不是同一个变量
循环结束后这些声明的 j 变量对应的内存空间其实就会被销毁,但因为在同一个块作用域里,定时器的回调函数使用了这个应该随后会被销毁的 j 变量,
所以每次循环产生的块作用域里的变量都还保留着,所以当引擎开始执行定时器的回调时,每个回调都能找到自己的 j 变量
此处的 let循环 除了涉及块作用域还是涉及到闭包,下方会有闭包的介绍
5.3 改造 var 循环
明白了 let循环 为什么能达到预期结果后,我们不妨试着改造之前的 var 循环,不是没有块作用域就完成不了需求
分析:
5.3.1 因为期望每循环一次打印的都是当前的循环计数,即每次循环打印的值都不一样
5.3.2 所以变量 i 一定不是全局变量,不是全局变量只能是局部变量,即变量 i 属于函数作用域
5.3.3 所以循环体外层要包裹一个函数
5.3.4 也许是考虑到函数声明会污染全局变量且手动调用很麻烦,所以下面这种形式更常见
6、闭包
闭包的2个示例
示例1:
示例2:
比较这两个示例的共同点:
示例1中,foo() 函数执行完毕后,按理说 foo() 整个内部作用域都应该被销毁
示例2中,wait() 函数执行完毕后,其产生的函数作用域也应该被销毁
但2个示例中,原本在执行完毕后应该被释放的内存空间都没有被释放
因为有特殊情况出现,阻止了JS引擎的垃圾回收工作
什么特殊情况?
以示例1为例:
foo() 函数内部有一个函数 bar(),bar() 函数显然能访问它的父作用域 foo() 函数
而 foo() 函数的返回值恰好就是其内部函数 bar()
我们在全局作用域中调用 foo() 函数 并将其返回值赋值给了一个全局变量 baz
原本foo的内部函数bar只有一个引用计数,经过 var baz = foo() 后,现在bar函数的引用计数为2
所以在 foo函数 执行完毕后,引擎没有回收 bar函数 的内存空间,而 bar函数 里又访问了其父级作用域 foo函数 里的变量,所以 foo函数 的内存空间暂时也不能释放
全局变量baz是个函数表达式,baz() 后,我们能打印 foo函数 的局部变量 a
闭包的定义
原书定义:
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包
闭包是基于词法作用域书写代码时所产生的自然结果
我自己的话:
这种在某个具有封闭性质的词法作用域之外因为一些原因在外部还能访问该作用域的能力就叫闭包
这些原因有:
一个函数的返回值是其内部的一个子函数
在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中使用了回调函数
IIFE模式是闭包吗?
按照上述对闭包的定义,它不是闭包。
因为函数( 示例代码中的IIFE )并不是在它本身的词法作用域以外执行的。
它在定义时所在的作用域而非外部作用域中执行
a 也是通过普通的词法作用域查找而非闭包被发现的