1. 什么是闭包
先说结论:当函数可以记住并访问所在的词法作用域变量时,就产生了闭包,即使函数是在当前词法作用域之外执行
。
思考以下代码:
function foo() {var a = 2;function bar() {console.log(a);}bar();
}
foo();
函数bar
可以访问外部作用域中的变量a
,这就是闭包吗?
严格来说这并不是,在上面代码片段中,函数bar
具有一个覆盖foo
作用域的闭包。也可以认为bar
封闭在了foo
的作用域中,但是通过这种定义无法明白这个代码片段中的闭包是如何工作的。
下面一段代码清晰的展示了闭包
function foo() {var a = 2;function bar() {console.log(a);}return bar;
}
const baz = foo();
baz(); // 2 这里输出的结果2就是闭包的效果
分析一下:在foo
正常执行后,其返回值赋值给变量baz
并调用baz
,实际上只是通过不同的标识符引用调用了内部的函数bar
。bar
肯定可以被正确执行,但是在这个例子中bar
是在自己定义的词法作用域意外的地方执行的。
在foo
被执行后通常会期待
foo
的整个内部作用域会被销毁,因为垃圾回收器会释放不在使用的内存空间,由于看上去foo
的内部不会在被使用,所以很自然的认为垃圾回收器会将foo
进行回收。但是闭包的神奇之处就在于它可以阻止这个行为的发生。事实上内部作用域依然存在,因为bar
对内部的作用域依然进行着引用,保证bar
在任何时候可以正常的执行。
bar
依然保持着对该作用域的引用,这个引用就叫做闭包。因此在baz
即将执行的时候它依然可以正常的访问到变量a
。
无论以何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。
再来看个例子:
function foo() {var a = 2;function baz () {console.log(a); // 2}bar(baz);
}
function bar(fn) {fn(); // 这里是闭包
}
foo();
来吧好好分析一下这段代码:
先来一段看似正确的分析:
首先在foo
被调用的时候,foo
内部创建了baz
函数,随即baz
函数被当做bar
函数的参数传递了进去,最终在bar
函数内部完成了对baz
函数的调用。对于参数fn
其实就是函数baz
,fn
一直保持着对baz
的引用,并可以在非baz
的词法作用域的任意位置进行调用。
上述说法并不正确,回顾一下闭包的定义:当函数可以记住并访问所在的词法作用域变量时,就产生了闭包。记住是访问词法作用域的变量。
正确的解析为:内部函数baz
传递给bar
,当调用这个内部函数时(fn),baz
可以正常的访问到foo
作用域的a
,这时闭包就产生了。baz
对a
的引用就是闭包。
再来一个贴合实际的例子:
function wait(message) {setTimeout(function timer() {console.log(message);}, 1000);
}
wait('Hello World!');
在实际开发中类似这种代码肯定写过很多了,将一个内部函数(timer)传递给setTimeout
。timer具有覆盖wait
作用域的闭包,对变量message
保留着引用,因此wait
执行1000毫秒后,它的内部作用域并不会消失。
再来归总一下:当函数当做一级的值类型到处传递,就非常容易产生闭包(如果函数对其所在的词法作用域有变量的引用)。
2. 循环闭包
对于循环闭包,for
循环是一个很好的例子。
for(var i = 1; i <= 5; i++) {setTimeout(function timer () {console.log(i);}, i * 1000);
}
来分析一下这段代码,一眼看上去应该输出什么呢?可能会觉得每隔一秒输出当时i
的值:1-5。但是这段代码最终的输出结果是每隔一秒输出一次6
,一共输出5
次6
。这是为什么呢?6
又是从哪里来呢?下面来分析一下。
这个循环的终止条件式i > 5
也就是i = 6
的时候,这里容易忽略的是:只有当循环执行完之后,setTimeout回调函数才开始执行,即使setTimeout执行的延迟时间为0即setTimeout(function timer(){...}, 0);
。所有的回调函数是在循环结束时才开始执行,而这时i = 6
。又因为每次回调都是拿i
的值6
,所以最终的输出结果就是5
个6
。
思考一下:这到底是因为什么导致代码的行为跟实际想要的结果有偏差呢。
原因是:我们会自然的认为每个迭代在运行的时候会把自己要用的i
保存下来。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数都是在各自的迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,实际上它们共享一个i
。
那么如何解决这个问题呢?在前面说过立即执行函数会创建新的作用域,用立即执行函数可以解决这个问题吗?
for(var i = 1; i <= 5; i++){(function() {setTimeout(function timer(){console.log(i);}, i * 1000);})();
}
分析一下这段代码:首先这段代码并不能解决问题,虽然立即执行函数创建了新的词法作用域,但是这个词法作用域是空的,最终还是会根据作用域链找到最终外部作用域中的i = 6
。这时候需要在立即执行函数的词法作用域中来保存i
值。
for(var i = 1; i <= 5; i++){(function () {var j = i;setTimeout(function timer() {console.log(j);}, j * 1000);})();
}
上面这段代码就可以解决问题了,每次在迭代内使用立即执行函数都会生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代的内部,在每个迭代中都会有一个具体的正确的值来提供访问。
再来思考一下,使用立即执行函数在每次迭代时会创建新的作用域,换句话说,每次迭代我们都需要一个新的块级作用域来保存本次迭代i
的值,在前面介绍过let
声明,它可以劫持块作用域,并且在这个块作用域中声明变量。
for(var i = 1; i <= 5; i++){let j = i;setTimeout(function timer(){console.log(j);}, j * 1000);
}
本质上这是将一个块转成一个可以被关闭的作用域。它会劫持当前for
循环大括号声明的块。
这里多说一下:for
循环头部使用let
声明还会有一个特殊行为:变量在循环过程中不止被声明一次,每次迭代都会声明。随后每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
最终修改后的代码
for(let i = 1; i <= 5; i++){setTimeout(function timer(){console.log(i);}, i * 1000);
}
这样代码的执行结果跟我们想让它执行的结果就一致了,这就是块作用域跟闭包结合使用的一个例子。
3. 模块
模块是利用闭包的一个典型事例。
function foo() {var s1 = 'module';var s2 = 'JavaScript';function bar() {console.log(s1);}function baz() {console.log(s2);}
}
上面那段代码并没有明显的闭包,只有两个私有变量和内部函数,它们的词法作用域(就是闭包)也就是foo
的内部作用域。
思考一下代码
function FooModule() {var s1 = 'fmodule';function foo(){console.log(s1);}return {foo}
}
var fm = FooModule();
fm.foo(); // module
这种模式在JavaScript中被称为模块。最常见的实现模块的方式通常被称为模块暴露。
下面来分析一下,FooModule
只是一个函数,必须要通过调用来创建一个模块实例。如果不执行,内部的作用域和闭包都无法被创建,FooModule
返回一个对象字面量来表示对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。保持内部数据变量是隐藏且私有的状态。
模块模式的两个必要条件:
1、必须有外部的封闭函数,该函数必须至少被调用一次。
2、封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或修改私有的状态。
一个具有函数属性的对象本身并不是真正的模块。一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
用另一种方式创建模块
var foo = (function FooModule(){var s1 = 'module';function bar () {console.log(s1);}return {bar}
})();
foo.bar(); // module
将模块函数转为立即执行函数,模块也是普通的函数,因此也可以传递参数。
function FooModule(s1){function bar(){console.log(s1);}return {bar}
}
var foo1 = FooModule('foo1');
var foo2 = FooModule('foo2');
foo1.bar(); // foo1
foo2.bar(); // foo2
模块模式另一个强大的用法是命名将要作为公共API返回的对象。
看下面代码:
var foo = (function FooModule(id){function change() {publicAPI.identify = identify2;}function identify1(){console.log(id);}function identify2(){console.log(id.toUpperCase());}var publicAPI = {change,identify: identify1}return publicAPI;
})('foo module');
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE
通过模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改他们的值。
3.1现在模块机制
大多数模块依赖加载器/管理器本质上都是将这种模块定义封装到一个友好的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[name] = impl.apply(impl, deps)
。为了模块的定义引入了包装函数,并且返回值,储存在一个根据名字来管理的模块列表中。
下面来使用一下
MyModules.define('bar', [], function() {function hello(s1){return '这是:' + s1;}return {hello: hello}
});
MyModules.define('foo', ['bar'], function (bar){var s2 = 's2';function baz() {console.log(bar.hello(s2).toUpperCase());}return {baz: baz}
});
var bar = MyModules.get('bar');
var foo = MyModules.get('foo');
console.log(bar.hello('s2')); // 这是:s2
foo.baz(); // 这是:S2
这段代码可能一下子不好理解,好好捋一下吧。
3.2 未来模块机制
ES6中为模块增加了一级语法支持。在通过模块系统进行加载时,ES6会将文件当做独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,也可以导出自己的API成员。
基于函数的模块并不是一个能被静态识别的模式,它们的API语义只有在运行时才会被考虑进来,因此可以在运行时修改一个模块的API。
但是ES6模块API是静态的。因此可以在编译期检查对导入模块的API成员的引用是否真是存在。
ES6模块没有“行内”格式,必须被定义在一个文件中(一个文件一个模块)。浏览器或引擎有一个默认的“模块加载器”可以在导入模块时加载模块文件(下面是伪代码)。
// bar.js
function hello(who){return '你是:' + who;
}
export hello;
// foo.js
import hello from 'bar.js'
function baz () {console.log(hello('apple').toUpperCase());
}
模块文件中的内容会被当做好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。
4. 小结
当函数可以记住并访问所在的词法作用域变量时,就产生了闭包,即使函数是在当前词法作用域之外执行。如果没有认清闭包,也不了解它的工作原理,在使用的过程中就很容易犯错,比如在循环中,学习完后可以发现我们的代码中闭包随处可见。