原文:你不知道的js系列
一个简单粗暴的定义
闭包就是即使一个函数在它所在的词法作用域外部被执行,这个函数依然可以访问这个作用域。
比如:
function foo() { var a = 2; function bar() { console.log( a ); // 2 } bar(); } foo();
这个代码和我们讨论嵌套作用域时一样,函数 bar() 可以访问外部作用域中的变量 a(RHS 查询)
这是 “闭包” 吗?……不完全是。这只是解释了对 a 的引用是通过词法作用域的查询规则,这些规则只是闭包的一个重要部分。
从纯粹的学术角度看,上面的代码的作用是,函数 bar() 有了一个在 foo() 的作用域之上的闭包。(事实上,它甚至可以访问其它作用域,比如这里的全局作用域)。
换句话说,bar() 封闭了 foo() 的作用域,因为 bar() 嵌套在 foo() 里面。
这样定义闭包的话,它就不能直接被观察到了,我们也看不到这个闭包的操作。我们可以明确地看到词法作用域,但是闭包仍然像代码背后的神秘阴影。
下面的代码可以让闭包的概念更清晰:
function foo() { var a = 2; function bar() { console.log( a ); } return bar; } var baz = foo(); baz(); // 2 -- Whoa, closure was just observed, man.
函数 bar() 可以访问 foo() 内部的作用域。然后,将 bar() 这个函数作为一个值,通过 return 语句,通过 bar 这个引用得到这个函数本身。
在执行了 foo() 之后,将返回值(内部的 bar 函数)赋值给变量 baz,然后调用 baz() ,实际上只是通过标识符 baz,调用了内部函数 bar()。
bar() 被执行了,但是它执行于声明它的词法作用域外部。
在 foo() 被执行之后,你可能认为 foo() 内部的作用域会消失,因为引擎会使用垃圾回收器,释放不再被使用的内存。在这里 foo() 内部的代码似乎不会再运行了。
但是闭包不会让这样的事情发生。内部的作用域实际上还在被使用着,因为函数 bar() 还在使用这个作用域。
bar() 有一个词法作用域闭包作用在 foo() 的作用域上,这就导致这个作用域会一直存在,只要 bar() 在之后的任何时间被引用到。
bar() 有一个对这个作用域的引用,这个引用就是闭包。
所以当变量 baz 被调用时(实际上调用的是内部命名为 bar 的那个函数),它就可以访问 bar 被声明时所在的那个作用域,所以它可以访问变量 a。
函数在定义它的词法作用域外部也可以被成功调用。闭包使得这个函数可以继续访问被定义时的那个词法作用域。
当然,任何把函数作为值传递,在其它位置调用的方式,都可以作为观察/操作闭包的示例。
function foo() { var a = 2; function baz() { console.log( a ); // 2 } bar( baz ); } function bar(fn) { fn(); // look ma, I saw closure! }
上面的代码中,将函数 baz 传递给函数 bar,然后调用内部函数 (在 bar 中是形参 fn),这时,闭包就可以被观察到了,因为这里 fn() 调用时访问了变量 a。
这种以参数形式传递函数也可以是间接的:
var fn; function foo() { var a = 2; function baz() { console.log( a ); } fn = baz; // assign `baz` to global variable } function bar() { fn(); // look ma, I saw closure! } foo(); bar(); // 2
无论以什么形式将一个内部函数传递到词法作用域外部,它都会保持一个对原始声明位置所在的那个词法作用域的引用。
不论我们在什么位置执行这个函数,闭包就会产生。
function wait(message) { setTimeout( function timer(){ console.log( message ); }, 1000 ); } wait( "Hello, closure!" );
上面的代码中,setTimeout() 的第一个参数是函数 timer,timer 有一个闭包作用在函数 wait 的作用域之上,所以可以保持对变量 message 的引用。
在执行 wait() 经过 1 秒之后,内部函数 timer 还是有一个在 wait 作用域之上的闭包,所以这个作用域不会消失。
内置函数 setTimeout() 有对某些参数的引用,可能是类似 fn 或者 func 这样的名字。引擎去调用这个函数,实际上就是调用了内部函数 timer,它的词法作用域的引用依然是完整的。
闭包
考虑下面的代码
function setupBot(name,selector) { $( selector ).click( function activator(){ console.log( "Activating: " + name ); } ); } setupBot( "Closure Bot 1", "#bot_1" ); setupBot( "Closure Bot 2", "#bot_2" );
本质上,只要你把函数作为值传递,你就会看到这些函数在使用闭包。
事件处理,Ajax 请求,跨窗口通信,web workers,或者其它异步(或者同步)的任务,当你传递一个回调函数,就会产生闭包。
循环+闭包
下面是最常见的用来展示闭包的例子:
for (var i=1; i<=5; i++) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }
你可能会期待这段代码会每隔一秒输出一个数字,1、2、3、4、5
事实上你会连续得到 6 个 5。
判断循环结束的条件是当 i <= 5 不再成立。所以会在 i 的值已经变为 6 时停止循环。所以最后控制台输出 i 的值就会是 6 。
事实上,就算设置 setTimeout 的延迟为 0 ,这个回调函数也会在循环结束之后才执行,最后输出的值依然是 6。
为什么结果不像我们语义上看起来的那样?
因为每次迭代定义的函数,一共 5 个,都是作用在外部共享的作用域上的。对 i 的引用就是那同一个变量 i。
在第三节中介绍了 IIFE 这种立即执行函数的方式:
for (var i=1; i<=5; i++) { (function(){ setTimeout( function timer(){ console.log( i ); }, i*1000 ); })(); }
现在很明显又多了一层词法作用域,函数以 IIFE 的形式在每次迭代内部的作用域中。
但是,有一个空的作用域是没什么用的,仔细观察,这里的 IIFE 内部什么都没有。我们现在需要一个内部变量,作为每次迭代变量 i 的备份值:
for (var i=1; i<=5; i++) { (function(){ var j = i; setTimeout( function timer(){ console.log( j ); }, j*1000 ); })(); }
这样就可以了!
再稍微改得好看一点:
for (var i=1; i<=5; i++) { (function(j){ setTimeout( function timer(){ console.log( j ); }, j*1000 ); })( i ); }
当然了,这种 IIFE 也只是函数,每次循环迭代传入一个参数 i ,在 IIFE 内部是形参 j,每次迭代会产生一个新的作用域
在这里每次迭代,都会有一个新的回调函数都作用在一个新的作用域上,这个作用域内部的变量 j 的值就绑定了我们需要得到的每次迭代的 i 的值。
回顾块作用域
第(三)节中有介绍 let 声明,可以把变量绑定在一个代码块中。
It essentially turns a block into a scope that we can close over.
本质上这就是将一个代码块封装为一个作用域,( close over 我的理解是防止外部变量污染,所以可以视为封闭了这段代码)
所以下面的代码也可以实现同样的效果:
for (var i=1; i<=5; i++) { let j = i; // yay, block-scope for closure! setTimeout( function timer(){ console.log( j ); }, j*1000 ); }
但这样还不够!在 for 循环使用 let 声明会有特别的表现,变量不是只声明一次,而是在每次迭代重新声明并赋值为上次迭代后的那个变量值:
for (let i=1; i<=5; i++) { setTimeout( function timer(){ console.log( i ); }, i*1000 ); }
块作用域和闭包的共同作用,能解决世界上所有问题!(大雾)
模块
模块也是一种利用了闭包的代码模式,但看起来并不是回调函数的那种形式。
function foo() { var something = "cool"; var another = [1, 2, 3]; function doSomething() { console.log( something ); } function doAnother() { console.log( another.join( " ! " ) ); } }
上面的代码里,并没有观察到闭包,只是有两个私有变量 和 内部函数,每个函数都有在 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 }; } var foo = CoolModule(); foo.doSomething(); // cool foo.doAnother(); // 1 ! 2 ! 3
这就是 JavaScript 中的模块模式,最常见的实现模块模式的方式是 “揭示模块” 模式(Revealing Module),是上面呈现的一种变体。
首先 CoolModule() 只是一个函数,调用它可以得到一个模块实例。如果没有外部函数的调用执行,内部的作用域和闭包就无法创建。
CoolModule() 返回了一个对象,用对象字面量的形式返回。这个对象有对内部函数的引用,但没有对内部变量的引用。内部变量被隐藏且私有化。我们可以把它看作为实际上返回的是模块的一个公共的API。
返回值被赋给外部的变量 foo,然后我们可以访问这个 API 的一些方法,比如 foo.doSomething() 这种形式。
注:模块返回一个字面量的对象并不是必须的,我们可以直接返回一个内部函数。jQuery 就是一个很好的例子,jQuery 和 $ 标识符就是 jQuery 模块的公共 API,但他们本身其实就是函数(因为所有的函数都是对象,所以函数也可以有自己的属性)
函数 doSomething() 和 doAnother() 都有作用在模块实例(来自 CoolMoudle() 的调用)的作用域上的闭包。我们可以通过对返回的对象的属性的引用,在作用域外部取得这些函数。
模块模式的执行有两个必要条件:
- 必须要有一个外部函数,至少被调用一次
- 这个函数必须返回至少一个内部函数,这样这个内部函数才有在内部作用域上的闭包,也就可以访问或者修改私有的变量。
单独出现的一个属性的值为函数的对象并不是真正的模块。
一个从函数调用返回值得到的对象,只有值,没有对内部函数的引用也不算是真正的模块。
下面是上面代码的一个变体,适用于只需要一个实例的时候,也就是 “单例” 模式:
var 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(); // cool foo.doAnother(); // 1 ! 2 ! 3
这里我们把模块构造函数改成了立即执行函数表达式,直接调用它并且赋值给实例标识符 foo
模块只是一些函数,所以他们可以接收参数。
function CoolModule(id) { function identify() { console.log( id ); } return { identify: identify }; } var foo1 = CoolModule( "foo 1" ); var foo2 = CoolModule( "foo 2" ); foo1.identify(); // "foo 1" foo2.identify(); // "foo 2"
另一种稍有不同但是却很强大的变体是,将返回的公共 API 对象命名:
var foo = (function CoolModule(id) { function change() { // modifying the public API publicAPI.identify = identify2; } function identify1() { console.log( id ); } function identify2() { console.log( id.toUpperCase() ); } var publicAPI = { change: change, identify: identify1 }; return publicAPI; })( "foo module" ); foo.identify(); // foo module foo.change(); foo.identify(); // FOO MODULE
这样你可以在这个模块实例中获得对这个 API 对象的引用,你可以从内部修改这个模块实例,增加或者删除内部方法和属性,或者改变他们的值。
现代模块
各种模块依赖管理器本质上邓白氏把这种模块定义的模式包装成一个友好的 API。
下面是为了说明这个概念的一个简单的例子:
var MyModules = (function Manager() { var modules = {}; function define(name, deps, impl) { for (var i=0; i<deps.length; i++) { deps[i] = modules[deps[i]]; } modules[name] = impl.apply( impl, deps ); } function get(name) { return modules[name]; } return { define: define, get: get }; })();
这段代码的关键地方是 modules[names] = impl.apply(impl, deps). 这是在调用对模块(传入的任何依赖)的定义包装函数,并且在一个内部的根据名字索引的模块列表中存储他们的返回值,也就是返回的API。
下面是利用这个模块管理程序去定义一些模块:
MyModules.define( "bar", [], function(){ function hello(who) { return "Let me introduce: " + who; } return { hello: hello }; } ); MyModules.define( "foo", ["bar"], function(bar){ var hungry = "hippo"; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; } ); var bar = MyModules.get( "bar" ); var foo = MyModules.get( "foo" ); console.log( bar.hello( "hippo" ) ); // Let me introduce: hippo foo.awesome(); // LET ME INTRODUCE: HIPPO
foo 和 bar 都是用返回了公共 API 的函数定义的。foo 还接收了 “bar” 作为依赖参数,所以 foo 的内部可以使用 bar 这个模块。
这些模块管理器并没有什么特殊的,他们满足了模块模式的两个特性:对一个函数的定义封装的调用(也就是外部函数),然后返回这个模块的 API。
换句话说,模块就是模块,即便你使用来一个封装工具来管理他们。
未来的模块
ES 添加了对模块的支持,ES6 将一个文件视为单独的模块。每个模块对可以引入其它模块或者其中特定的内容,也可以导出自己的公有 API。
注:基于函数的模块是不能被静态识别的模式,所以他们的 API 可以在运行时被修改。
相反,ES6 的模块 API 是静态的。编译器会在编译期间检查引用的导入模块的 API 成员是否存在。如果没有不存在,就会报错。
ES6 模块必须定义在一个单独的文件中,一个文件一个模块,浏览器会有一个默认的模块加载器,可以在导入模块的同时加载这个模块的文件。
示例:
bar.js
function hello(who) { return "Let me introduce: " + who; } export hello;
foo.js
// import only `hello()` from the "bar" module import hello from "bar"; var hungry = "hippo"; function awesome() { console.log( hello( hungry ).toUpperCase() ); } export awesome;
另一个文件:
// import the entire "foo" and "bar" modules module foo from "foo"; module bar from "bar"; console.log( bar.hello( "rhino" ) ); // Let me introduce: rhino foo.awesome(); // LET ME INTRODUCE: HIPPO
注: foo.js 和 bar.js 是两个单独的文件。
关键字 import 可以在当前作用域中导入其它模块 API 中的一个或者多个成员,绑定在一个变量中,比如 foo.js 中从 bar.js 中导入了 hello
关键字 module 可以导入整个 API 对象,比如第三个文件中的变量 foo 和 bar
关键字 export 可以为当前的模块导出一个公有的 API 标识
如果需要的话,这些关键字可以在一个模块定义中多次使用。
在模块文件中的内容就被当作一个作用域闭包,就像前面基于函数闭包的模块一样、
小结:
闭包就是当一个函数在它的词法作用域外部调用时,它依然可以访问那个作用域。
如果我们不能仔细识别出他们,没搞清楚他们如何运行,就会遇到问题(比如前面的循环陷阱)。同时闭包也是一个强大的工具,可以通过各种形式得到如同模块这类的设计模式。
模块一定有两个特征:
一个外部包装函数,调用它来创建封闭的作用域。
这个包装函数的返回值必须含有至少一个内部函数的引用,从而得到一在包装函数内部作用域之上的闭包。