作用域和闭包
实质问题
下面用一些代码来解释这个定义。
这段代码看起来和嵌套作用域中的示例代码很相似。基于词法作用域的查找规则,函数bar()可以
访问外部作用域中的变量a(这个例子中的是一个RHS引用查询)。
这是闭包吗?
技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释bar()对a的
引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部
分!)
从纯学术的角度说,在上面的代码片段中,函数bar()具有一个涵盖foo()作用域的闭包(事实上,涵
盖了它能访问的所有作用域,比如全局作用域)。也可以认为bar()被封闭在了foo()的作用域中。为
什么呢?原因简单明了,因为bar()嵌套在foo()内部。
但是通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工
作的。我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后的神秘阴影里,并不那么容易
理解。
下面我们来看一段代码,清晰地展示了闭包:
函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当作一个值类型
进行传递。在这个例子中,我们将bar所引用的函数对象本身当作返回值。
在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过
不同的标识符引用调用了内部的函数bar()。
bar()显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。
在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用
来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进
行回收。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回
收。谁在使用这个内部作用域?原来是bar()本身在使用。
拜bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以
供bar()在之后任何时间进行引用。
bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
因此,在几微秒之后变量baz被实际调用(调用内部函数bar),不出意料它可以访问定义时的词法
作用域,因此它也可以如预期般访问变量a。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作
用域。
当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。
把内部函数baz传递给bar,当调用这个内部函数时(现在叫作fn),它涵盖的foo()内部作用域的闭
包就可以观察到了,因为它能够访问a。
传递函数当然也可以是间接的。
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引
用,无论在何处执行这个函数都会使用闭包。
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类
型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通
信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使
用闭包!
通常认为IIFE是典型的闭包例子,但根据先前对闭包的定义,我并
不是很同意这个观点。
|
|
虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中的IIFE)并
不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是
全局作用域也持有a)。a是通过普通的词法作用域查找而非闭包被发现的。
尽管技术上来讲,闭包是发生在定义时的,但并不非常明显,就好像六祖慧能所说:”既非风动,亦
非幡动,仁者心动耳。”
尽管IIFE本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被
封闭起来的闭包的工具。因此IIFE的确同闭包息息相关,即使本身并不会真的使用闭包。
循环和闭包
要说明闭包,for循环是最常见的例子。
正常情况下,我们对这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会以每秒一次的频率输出五次6。
这是为什么?
首先解释6是从哪里来的。这个循环的终止条件是i不再<=5。条件首次成立时i的值是6。因此,输出
显示的是循环结束时i的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上,当定时器
运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被
执行,因此会每次输出一个6出来。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域
的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭
在一个共享的全局作用域中,因此实际上只有一个i。
下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要
一个闭包作用域。
IIFE会通过声明并立即执行一个函数来创建作用域。
我们来试一下:
|
|
。这样不行。但是为什么呢?我们现在显然拥有更多的词法作用域了。的确每个延迟
函数都会将IIFE在每次迭代中创建的作用域封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。仔细看一下,我们的IIFE只是一个什么都
没有的空作用域。它需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中储存i的值:
行了!它能正常工作了!。
可以对这段代码进行一些改进:
let声明,可以用来劫持块作用域,并且
在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以
正常运行了:
但是,这还不是全部!for循环头部的let声明还会有一个特殊的行
为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使
用上一个迭代结束时的值来初始化这个变量。
模块
还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究
其中最强大的一个:模块。
正如在这段代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量something
和another,以及doSomething()和doAnother()两个内部函数,它们的词法作用域(而这就是闭包)也
就是foo()的内部作用域。
接下来考虑以下代码:
这个模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展
示的是其变体。
我们仔细研究一下这些代码。
首先,CoolModule()只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,
内部作用域和闭包都无法被创建。
其次,CoolModule()返回一个用对象字面量语法{ key: value, … }来表示的对象。这个返回的对
象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。
可以将这个对象类型的返回值看作本质上是模块的公共API。
这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方
法,比如foo.doSomething()。
doSomething()和doAnother()函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实
现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创
造了可以观察和实践闭包的条件。
如果要更简单的描述,模块模式需要具备两个必要条件。
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以
访问或者修改私有的状态。
一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回
的,只有数据属性而没有闭包函数的对象并不是真正的模块。
上一个示例代码中有一个叫作CoolModule()的独立的模块创建器,可以被调用任意多次,每次调用
都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例
模式:12345678910111213141516var foo = (function CoolModule() {var something = "cool";var another = [1, 2, 3];function doSomething() {console.log( something );}function doAnother() {console.log( another.join( " ! " ) );}return {doSomething: doSomething,doAnother: doAnother};})();foo.doSomething(); // coolfoo.doAnother(); // 1 ! 2 ! 3
我们将模块函数转换成了IIFE,立即调用这个函数并将返回值直接赋值给单例的模
块实例标识符foo。
模块也是普通的函数,因此可以接受参数:
模块模式另一个简单但强大的变化用法是,命名将要作为公共API返回的对象:
通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添
加或删除方法和属性,以及修改它们的值。
大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API。
这段代码的核心是modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可
以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中。
下面展示了如何使用它来定义模块:
“foo”和”bar”模块都是通过一个返回公共API的函数来定义的。”foo”甚至接受”bar”的示例作为依
赖参数,并能相应地使用它。
要理解模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个特点:为函数定义
引入包装函数,并保证它的返回值和模块的API保持一致。
换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。