本文是对《你不知道的JavaScript(上卷)》的浓缩总结,已经看过的童鞋可以简单再过一遍,没看过的童鞋建议仔细阅读
前言
每当我们看完各种面试宝典,知识点总结,以为掌握了某些概念之后,有没有去想过,这种“掌握”是否真能学以致用。当你将通过面试作为学习的目的以后,你会频频体会到知其然不知其所以然的滋味。看完《你不知道的JavaScript(上卷)》之后,让我对很多知识点有了通透的理解,遂将自己的理解结合书本整理分享给各位可能有我上述所描述问题的童鞋,也欢迎大佬们补充指正。
读完本文,希望你可以对如下几个知识点,从编译原理的基础上,有一种通透的理解:
- 作用域
- 词法作用域
- 变量提升
- 作用域闭包
何为作用域
在js中(ES6版本后)一般会存在3种作用域
- 全局作用域
作用于所有代码执行的环境(整个 script 标签内部),或者一个独立的 js 文件
- 函数作用域
作用于函数内的代码环境,仅在该函数内部可以访问
- 块作用域
let和const创建的作用域
- eval作用域
仅在严格模式下存在,下一章会讨论
作用域的概念想必大家一定都能倒背如流,即:
作用域就是一套规则,用于确定
在何处以及如何查找变量(标识符)的规则
这里我们注意到有两个关键词在何处以及如何查找,下面我们就把js的执行拆开成编译时和运行时两个维度,来看看这两个维度分别做了些什么,作用域在其中又是如何将在何处以及如何查找实现的
编译时
首先JavaScript到底是解释型语言还是编译型语言,这里不做过多讨论,因为它同时具有两种类型的特性,本文按照《你不知道的JavaScript(上卷)》中的定义,将其解释为一种特殊的编译形语言来理解即可,编译时也可以理解为预编译时
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”:
- 分词/词法分析(Tokenizing/Lexing)
例如,考虑程序
var a = 1;。这段程序通常会被分解成为下面这些词法单元:var、a、=、1、;。
- 解析/语法分析(Parsing)
这个过程是将步骤1得到的词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,就是大家所熟知的
AST抽象语法树。
- 代码生成
将AST转换为可执行代码的过程,例如
var a = 1;的AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将一个值储存在a中。
引用原文里的总结就是:
任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript编译器首先会对
var a = 1;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。
这里我们着重看一下var a这个声明,当编译器发现这类声明时,会询问作用域是否已经有一个该名称的变量存在于同一个作用域集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域集合中声明一个新的变量,并命名为a。
看到这里是否你就可以理解,为什么js在运行时,可以提前调用之后声明的变量
(变量提升),因为变量声明在编译时就已经完成了,所以运行时可以通过作用域找到该变量
看到这里其实我们可以知道,对变量的创建(包括分配内存等)其实是在编译过程中就完成了,而这个过程中,我们的作用域其实就已经参与了进来,他会记得这些变量在何处,那么记得以后要如何查找呢,我们接着往下看
运行时
同样以var a = 1;为例,引擎运行时会首先询问作用域,在当前的作用域集合中(一段程序中可能会有多个作用域,例如代码在函数内执行就是当前函数作用域)是否存在一个叫作a的变量。如果有则直接使用这个变量;如果没有则会继续查找该变量(聪明的你应该已经猜到了这就需要用到作用域链了,这块我们之后再分析)。如果引擎最终找到了a变量,就会将1赋值给它。否则引擎就会抛出一个ReferenceError异常(关于异常后面会专门讲)。
那么作用域是如何找到变量a的呢,这就聊到了我面要讲的第二个关键词如何查找
作用域会协助引擎通过LHS或RHS进行变量查找
LHS查询是找到变量的容器本身,RHS查询是取得变量的源值
例如var a = b;,首先通过LHS找到变量a,再通过RHS找到变量b的值,最后将b的值赋值给变量a。
对何时使用
LHS或RHS,一种比较好的理解方式就是:“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”
这里对于LHS或RHS只做一个概述,有兴趣的可以看看原文里的解读
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。
上一节提到了如果作用域在当前作用域集合中没有找到要查找的变量会继续查找,其实就是在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
例如:
function fn() {
console.log(a)
}
var a = 1
变量a嵌套在了函数fn的作用域中,当js引擎通过
RHS(回忆一下为什么是RHS)查找变量a进行输出时,当前作用域发现没有这个变量,就会去外层作用域查找,最后在全局作用域中找到了变量a
这种作用域一层层嵌套形成的链路,我们就称之为作用域链
关于异常
我们都知道,在非严格模式下,当我们为一个未定义的变量赋值时,会隐式的创建一个全局变量
a = 1
console.log(a) //1
而在严格模式时,会抛出ReferenceError异常
"use strict"
a = 1 //ReferenceError
回忆一下变量的查询方式,不难知道这里使用的是LHS查询,在严格与非严格模式下表现是不同的
那如果我们是直接使用一个未声明的变量呢?
"use strict"
console.log(a) //ReferenceError
console.log(a) //ReferenceError
回忆一下变量的查询方式,不难知道这里使用的是RHS查询,在严格与非严格模式下表现是相同的
如果RHS查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或者调用该变量不存在的方法等,就会抛出TypeError异常
var a = 1
a() //TypeError
a.sort() //TypeError
引用原文里的总结就是:
ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
总结
-
编译器在
编译时对变量进行声明,作用域此时会记得这些变量在何处 -
js引擎在
运行时会在作用域的协助下,通过LHS或RHS查询取得变量或其源值(所谓的如何查找) -
查找时会通过
作用域链在嵌套作用域中一层层查找,直到找到全局作用域为止 -
不成功的
RHS引用会抛出ReferenceError异常。不成功的LHS引用会隐式地创建一个全局变量(非严格模式下),或者抛出ReferenceError异常(严格模式下)。
词法作用域
引用原文中的概念:
简单地说,
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和函数写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
如果说作用域就是一套规则,词法作用域就是作用域的一种工作模型,一种在词法阶段的就定义的作用域。
词法阶段
A、B、C。由于词法作用域在写代码时就定义好了,因此在执行C内部console时,会首先在当前作用域内寻找a、b、c三个变量,如果找不到再通过作用域链逐级向上查找。因此首先在C内找到了变量c,再向上在B内找到了变量a和b。
这里我们思考一个问题,如果我在C内重新定义了a会怎么样
function fn(a) {
var b = a * 2
function inner(c) {
var a = 2
console.log(a,b,c)
}
inner(b*2)
}
fn(1)
答案是会直接使用C内的变量a,原因是作用域查找始终从运行时所处的最内部作用域开始,直到遇见第一个匹配的标识符为止。这里就产生了遮蔽效应(内部的标识符“遮蔽”了外部的标识符)。也就意味着在C内永远无法访问到B内的变量a。除非被遮蔽的变量在全局作用域内,则可以通过window.xxx来访问。
欺骗词法
前面讲到词法作用域是在词法阶段就确定了的,那么有没有可能在运行时来“修改”(也可以说欺骗)词法作用域呢?答案是可以,但是完全不推荐!
eval
以下讨论仅在非严格模式内
引用原文里的描述:
eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。
我们通过一段代码来理解一下:
function fn(str,a) {
console.log(a,b)
}
var b = 2
fn(eval("var b = 3"), 1) //1,3
由于eval内重新声明了变量b,通过遮蔽效应导致输出的b变成了3
with
with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。他有一个副作用,会将变量泄漏到全局作用域。这里具体不做展开,因为with其实非常冷门且不推荐使用,而且在严格模式已经被完全禁止了,有兴趣了解的童鞋可以看看原文中的解释。
性能影响
eval和with为什么不建议使用,很大原因就是对性能有影响,这里原文解释的比较清楚:
JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。但如果引擎在代码中发现了
eval(..)或with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval(..)会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么。最悲观的情况是如果出现了eval(..)或with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。如果代码中大量使用eval(..)或with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。
总结
-
词法作用域是由函数及变量声明的位置来决定的,在执行过程中也会以此为作用域基准进行LHS和RHS -
可以通过
eval(..)和with对词法作用域进行“欺骗”(非严格模式) -
词法欺骗的副作用是导致js引擎性能优化失效,使程序运行变慢,因此不建议使用
函数作用域和块作用域
函数作用域
借用上一节的图片
词法作用域的概念之后,就知道函数fn在声明时就确认了自己的作用域B,该作用域就是函数作用域
函数作用域由函数在声明时所处的位置决定,与其在哪里被调用以及如何被调用无关
属于这个函数的全部变量都可以在整个函数的范围内使用及复用,而在函数之外是无法被访问的,这个特性有一个非常好的作用。这里我们再引申出一个概念:最小特权原则
最小特权原则,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计
设想一个这样的场景,我们需要向小明借钱
var money = 100
function borrowMoney(money) {
return money
}
function xiaoming() {
return borrowMoney(money)
}
xiaoming() //100
money = 1000000
xiaoming() //1000000
borrowMoney(5000000) //5000000
不难发现我们只要修改money的值便可以像小明借任意的钱,甚至我们可以不经过小明允许直接borrowMoney(money)拿钱,显然这是不合理的。我们希望的是外部没有对money和borrowMoney的访问权限,他们应该是属于小明的私有变量,这时就需要函数作用域出马了,按照最小特权原则,将money和borrowMoney“隐藏”起来
function xiaoming() {
var money = 100
function borrowMoney(money) {
return money
}
return borrowMoney(money)
}
xiaoming() //100
money = 1000000 //严格模式会ReferenceError
xiaoming() //100
borrowMoney(5000000) //ReferenceError
立即执行函数表达式
假设我们现在的场景是,我们的程序运行过程中只需要借一次钱,且不care到底是问谁借,那么原来的写法其实有两点没有必要,一个是创建了一个具名函数xiaoming,该名称本身会“污染”所在作用域(即在全局作用域中声明了一个不需要日后再被调用的函数名xiaoming),另外还需要显示的调用函数xiaoming
如果函数不需要函数名(至少不“污染”所在作用域),并且能够自动运行,将会更加理想,好在js提供了能够同时解决这两个问题的方案:
(function xiaoming() {
var money = 100
function borrowMoney(money) {
return money
}
return borrowMoney(money)
})() //100
上述代码通过括号包裹住函数xiaoming创建了函数表达式,再通过()执行该表达式,且虽然我们依旧声明了函数名xiaoming(为了体现不会污染所在作用域,实际可以直接写匿名函数),但该名称并未“污染”全局作用域,而是绑定在函数表达式自身函数中,仅在function内部可以被访问,也就是说:
(function xiaoming() {
···
xiaoming() //可以访问
})() //100
xiaoming() //ReferenceError
区分
函数声明和函数表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
从概念我们就可以知道,为什么如下写法,都可以创建函数表达式并立即执行(某些非主流写法不建议使用):
~function xiaoming() {···}()
!function xiaoming() {···}()
+function xiaoming() {···}()
块作用域
实际在ES6出现之前,JavaScript里并没有严格的块作用域,但是有些语法也会创建块作用域,后面会讲到
下面这段代码大家一定不陌生
for(var i=0;i < 10;i++) {
console.log(i) //0,1,2,3,4,5,6,7,8,9
}
console.log(i) //10
此时变量i实际上是声明在了全局作用域中,但其实这是完全没有必要的,反而会对全局造成“污染”
于是在ES6推出了let和const关键字,彻底解决这了一问题
let关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。
for(let i=0;i < 10;i++) {
console.log(i) //0,1,2,3,4,5,6,7,8,9
}
console.log(i) //ReferenceError
还记得一道常见的面试题吗?
for(var i=0;i < 10;i++) {
setTimeout(function() {
console.log(i) //输出10个10
})
}
现在是否就很容易理解,为什么是10个10了吧。当setTimeout的宏任务执行的时候,for循环在主线程已经执行完毕(不理解的需要去补习下事件循环机制),因此在执行console.log时i的值已经是10了,然后在全局作用域中找到i之后进行输出
如果使用let形成块级作用域,就能得到理想中的输出结果
for(let i=0;i < 10;i++) {
setTimeout(function() {
console.log(i) //输出0,1,2,3,4,5,6,7,8,9
})
}
这段代码可能不方便理解,那么换一种写法大家就能看懂块级作用域是如何运作的:
{
let j
for(j=0;j < 10;j++) {
let i = j
setTimeout(function() {
console.log(i)
})
}
}
从代码可以看出,在每一次for循环内,其实都隐式的绑定了一次变量i,且该变量仅在块作用域内部可访问,在执行console.log时访问的是块作用域内部该次循环所绑定的i
for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
除了let以外,ES6还引入了const,同样可以用来创建块作用域变量,唯一的区别是const的值不允许被修改
总结
-
函数作用域是JavaScript中最常见的作用域单元,可以通过最小特权原则将内部变量“隐藏”起来 -
可以通过括号包裹等方式创建
函数表达式并立即执行,在特定场合下减少全局“污染”和代码量 -
ES6引入和let和const关键字,可以在所在代码块{···}内部创建块级作用域
提升
关于变量提升,也是一个老生常谈的问题,想必大家都知道有这么一种机制,但是至于为什么,不一定十分理解,没关系,看完本章你会明白透彻
var提升
考虑以下代码:
a = 1
var a
console.log(a) //1
输出的结果并非语义上理解的undefined,而是1
console.log(a) //undefined
var a = 2
输出的结果并非语义上理解的报错,而是undefined
为什么会出现上面的结果呢,我们就要从编译阶段来理解其中的本质原因了
通过前面的学习我们已经知道,任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。引用原文的描述就是:
包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
因此第一段代码按照编译和执行的正确处理顺序如下:
var a //编译阶段先处理变量a的声明
a = 1
console.log(a) //1
代码按照编译和执行的正确处理顺序如下:
var a //编译阶段先处理变量a的声明
console.log(a) //undefined
a = 2 //赋值操作不会被提升
函数提升
对于函数来说也同样如此,且函数作用域内部的声明的变量同样会在该作用域内部被首先处理:
fn() //正常执行
function fn() {
console.log(a) //undefined
var a = 2
}
代码按照编译和执行的正确处理顺序如下:
function fn() { //编译阶段先处理fn的声明
var a //编译阶段先处理变量a的声明
console.log(a) //undefined
a = 2 //赋值操作不会被提升
}
fn() //正常执行
变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作
提升。
同名时的提升
这里我们引申一下,如果同时声明了同名的变量和函数会怎么样呢?比如如下代码:
console.log(a) //输出函数a
var a = 2
function a() {···}
在编译阶段,函数的优先级比普通变量要高,因此会被优先声明,代码按照编译和执行的正确处理顺序如下:
function a(){···} //编译阶段先处理a的声明
var a //回忆我们第一章讲到的,遇到已声明过的变量a,忽略该声明,继续进行编译
console.log(a) //输出函数a
a = 2 //赋值操作不会被提升
如果是重复声明同名函数呢:
a() //输出2,后声明的函数a会覆盖之前的,尽管a已经被声明过
function a() {
console.log(1)
}
function a() {
console.log(2)
}
函数表达式的提升
我们再做另一个引申,有时候我们会通过函数表达式的方式来定义一个function,那如果提前调用会怎样呢:
fn() //TypeError
a() //ReferenceError
var fn = function a() {···}
造成这种结果是由于函数在函数表达式中并不会被提升,只是类似一个赋值语句,可以类比成函数a就是一个值,这个值不需要提前声明,因此a也无法提前执行,而变量fn声明了但是还没有赋值,所以也无法通过()来执行,代码按照编译和执行的正确处理顺序如下:
var fn //编译阶段先处理fn的声明
fn() //相当于undefined(),从之前对异常的介绍可以知道,属于非法操作因此会报TypeError
a() //未找到a的声明,因此会报ReferenceError
fn = function () { //赋值操作不会被提升
var a = ...self...
···
}
暂时死区
想必大家对于暂时死区都不陌生,实际上就是由于let和const不会被提升导致的,因此就会有如下代码的输出:
console.log(a) //ReferenceError
let a = 1
ES6标准中对let/const声明中的解释,第13章中有如下一段文字:
当程序的控制流程在新的作用域(module function 或 block 作用域)进行实例化时,在此作用域中用let/const声明的变量会先在作用域中被创建出来,但因此时还未进行词法绑定,所以是不能被访问的,如果访问就会抛出错误。因此,在运行流程进入作用域创建变量,到变量可以被访问之间的这一段时间,就称之为暂时死区。
从我个人的理解就是,let声明的变量,在编译阶段是会被创建的,但是不会绑定在词法作用域中,类似形成了一个隐形的块作用域,无法被外部访问,只有当运行时代码执行到了变量声明的位置时,才会放开访问权限
上述代码可以描述成类似下面这样一段代码:
{
let a //提前创建变量a,但无法被访问
}
console.log(a) //ReferenceError
a = 1 //放开对a的访问权限,并赋值
看到这里想必大家已经对变量提升有了透彻的理解,达到了所谓的通透。再随便给你一段代码,也能按照编译和执行的阶段分析出正确的处理顺序了吧
总结
-
一个简单的赋值语句,被拆开成两部分进行,第一部分在
编译阶段完成对变量和函数的声明,就是所谓的提升,第二部分在执行阶段完成对变量的赋值 -
声明本身会被
提升,而包括函数表达式的赋值在内的赋值操作并不会提升 -
要注意避免重复声明!否则会引起很多意想不到的问题
作用域闭包
本文的重头戏闭包终于隆重登场了,想必这个近乎神话的概念,曾经也让不少同学痛苦不堪。今天就让我们在对作用域有了透彻的理解之后,再来揭开闭包的神秘面纱。引用原文的一段话:
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。
闭包
首先回顾一下闭包的定义:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 我们用一段代码来解释一下:
function fn() {
var a = 1
function bar() {
console.log(a)
}
bar()
}
从嵌套作用域的知识我们可以了解到,基于词法作用域的查找规则,函数bar可以访问外部作用域中的变量a。而这种基于词法作用域的规则,就是闭包最核心的一部分。从学术的角度说,函数bar其实已经拥有了一个涵盖fn作用域的闭包,也可以理解成bar封闭在了fn的作用域中,但是通过这种方式定义的闭包并不能直接进行观察,我们换一种写法:
function fn() {
var a = 1
function bar() {
console.log(a)
}
return bar
}
var baz = fn()
baz() //输出2,这就是闭包的效果
在fn执行后,因为我们知道由于看上去fn的内容不会再被使用,所以很自然地会期待引擎的垃圾回收器来释放不再使用的内存空间,从而销毁fn的整个内部作用域,对其进行回收。而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域呢,正是bar本身。在这个例子中,var baz = fn()创建了baz对bar的引用,因此执行baz就相当于执行了内部的函数bar,也就相当于bar在自身词法作用域之外执行了(本例在全局作用域进行了执行),并访问到了自身所在词法作用域中的变量a,这就完全跟概念吻合上了。
再看另外两个例子:
function fn() {
var a = 1
function bar() {
console.log(a)
}
baz(bar)
}
function baz(fn) {
fn() //这里同样也是闭包
}
我们可以发现,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包
无论通过何种手段将内部函数传递到所在的
词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
上述代码可能有点刻意而为之,但我保证看了下面的例子,你会发现闭包其实在你的编码过程中无处不在:
function wait(msg) {
setTimeout(function timer() {
console.log(msg)
}, 1000)
}
wait('hello') //1s后执行timer输出hello
函数timer具有涵盖wait的作用域的闭包,因此还保留有对msg的引用
//使用jQuery
function clickHandler(el, name) {
$(el).click(function active() {
console.log('clicked:' + name)
})
}
clickHandler('#btn1', 'btn1') //单击btn1,相当于执行active,输出‘clicked btn1’
clickHandler('#btn2', 'btn2') //单击btn2,相当于执行active,输出‘clicked btn2’
函数active具有涵盖clickHandler的作用域的闭包,因此还保留有对name的引用
引用原文的表述:
本质上无论何时何地,如果将(访问它们各自
词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
立即执行函数与闭包
关于立即执行函数:
var msg = 'hello'
(function() {
console.log(msg) //hello
})()
严格来讲它并不是闭包。因为函数并不是在它本身的词法作用域以外执行,而是在全局作用域执行。msg是通过普通的词法作用域查找而非闭包被发现的。
立即执行函数是最常用来创建可以被封闭起来的闭包的工具,例如:
var sayHello = (function() {
var msg = 'hello'
return function() {
console.log(msg)
}
})()
sayHello() //hello
循环与闭包
回忆之前讲let时提到,可以通过块作用域解决循环内部输出理想值的问题,其实也可以通过闭包来解决:
for(var i=0;i < 10;i++) {
(function(j) {
setTimeout(function() {
console.log(j) //依次输出0,1,2,3,4,5,6,7,8,9
})
})(i)
}
就如同let创建块作用域一样,每次循环都通过立即执行函数创建了封闭的作用域,而每一个定时器中的回调函数都保持着对这个作用域中变量j的引用,这个变量j实际就是我们传入的值i,因此每次绑定的j值都是不一样的,最终才会依次输出0到9
模块
模块也是一种利用闭包强大威力的编码方式,一种最简单的写法就是:
function Module() {
var food = 'apple'
var water = 'water'
function eat() {
console.log('eat ' + food)
}
function drink() {
console.log('drink ' + water)
}
return {
eat: eat,
drink: drink
}
}
var fn = Module()
fn.eat() //eat apple
fn.drink() //drink water
eat和drink函数具有涵盖模块实例内部作用域的闭包(通过调用Module实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。
ES6中为模块增加了一级语法支持。在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。
关于ES6中的模块本文不做展开。
总结
-
当
函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包 -
立即执行函数并不等于闭包,但是是最常用来创建可以被封闭起来的闭包的工具 -
利用
闭包的特性可以在循环中实现块作用域的效果 -
可以利用
闭包的特性来实现模块 -
闭包无处不在
结语
下篇文章将会把《你不知道的JavaScript(上卷)》后半部分梳理完,涉及的知识点包括:
- this
- 对象
- 类
- 原型
- 委托
敬请期待···