你不知道的JavaScript--闭包

前言

继续上次的<你不知道的JavaScript>读书笔记,这次是闭包部分。

作用域闭包

什么是闭包?

当函数可以记住并访问所在词法作用域时,就形成了闭包,即使函数是在当前词法作用域之外执行

1
2
3
4
5
6
7
8
function foo(){
var a = 2;
function bar(){
console.log(a);//2
}
bar();
}
foo();
  • 确切来说,这个代码并不是真正的闭包。最准确地用来解释bar()a的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!)

再次举例:

1
2
3
4
5
6
7
8
9
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); //2

从这个例子来看闭包的特性:

  • 函数bar()的词法作用域可以访问foo()的内部作用域,我们将bar所引用的函数对象本身当做一个值类型传递。
  • 通过不同的标识符引用调用了内部函数bar()
  • 在这个例子中,函数bar()在自己定义的词法作用域之外的地方执行。
  • 阻止引擎的垃圾回收机制释放foo()函数的内部作用域。

谁还在使用这个内部作用域呢?

  • 原来是bar()函数本身在使用。由于bar()函数所声明的位置原因,导致它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()函数在之后任何时间进行引用。
  • bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function foo(){
var a = 2;
function baz(){
console.log(a); //2
}
bar(baz);
}
function bar(fn){
fn();//这就是闭包
}
foo();


var fn;
function foo(){
var a = 2;
function baz(){
console.log(a);
}
fn = baz; //将 baz 分配给全局变量
}
function bar(){
fn();//闭包
}
foo();
bar();//2

无论通过何种方式将内部函数传递到所在的词法作用域之外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、WebWorkers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

闭包无处不在!

循环和闭包

要说明闭包, for 循环是最常见的例子。

1
2
3
4
5
for(var i = 1;i <= 5;i++){
setTimeout(function timer(){
console.log(i);
},i*1000);
}

  • 正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。

但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。为什么?

我们一步一步看

首先,6从哪里来的?

  • 这个循环的终止条件是i不再<=5。条件首次成立时i的值是6。因此,输出显示的是循环结束时i的最终值。

为什么会输出五次6?

  • 延迟函数的回调会在循环结束时才执行。

代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

试试IIFE(Immediately Invoked Function Expression)立即执行函数表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
for(var i = 1;i <= 5;i++){
(function(){
setTimeout(function timer(){
console.log(i);
},i*1000);
})();
}
//
6
6
6
6
6

这样不行!为什么?

  • 的确每个延迟函数都会将 IIFE 在每次迭代中创建的作用域封闭起来。
  • 但是这个IIFE只是一个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。

它需要有自己的变量,用来在每次迭代中存储i的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for(var i = 1;i <= 5;i++){
(function(){
var j = i;
setTimeout(function timer(){
console.log(j);
},i*1000);
})();
}
//
1
2
3
4
5
1
2
3
4
5
6
7
8
//改进一下
for(var i = 1;i <= 5;i++){
(function(j){
setTimeout(function timer(){
console.log(j);
},i*1000);
})(i);
}

问题解决啦!

重返块作用域

我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。之前介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

本质上:是将一个块转换成了一个可以被关闭的作用域。

1
2
3
4
5
6
7
8
9
10
11
12
for(var i = 1;i <= 5;i++){
let j = i;
setTimeout(function timer(){
console.log(j);
},j*1000);
}
//
1
2
3
4
5

for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

1
2
3
4
5
6
7
8
9
10
11
for(let i = 1;i <= 5;i++){
setTimeout(function timer(){
console.log(i);
},i*1000)
}
//
1
2
3
4
5

模块

如何展现闭包的强大威力?

模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
  • CoolModule()只是一个函数,必须要通过调用它来创建一个模块实例。
  • CoolModule()返回一个用对象字面量语法{key:value,...}来表示对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。
  • doSomthing()doAnother()函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实现)。

模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式::

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

模块也是普通的函数,因此可以接收参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 返回的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var foo = (function CoolModule(id){
function change(){
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对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

现代的模块机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

它们符合前面列出的模块模式的两个特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。

ES6中的模块机制

ES6 中为模块增加了一级语法支持。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的API语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的 API(参考前面关于公共 API 的讨论)。

相比之下,ES6 模块 API 更加稳定(API不会在运行时改变)。由于编辑器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否 真实存在 。如果 API 引用并不存在,编译器会在运行时抛出一个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

  • ES6 的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。
  • 浏览器或引擎有一个默认的“模块加载器”(可以被重载,但这远超出了我们的讨论范围)可以在导入模块时异步地加载模块文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;


foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;


baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
  • import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello )。
  • module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foobar )。export会将当前模块的一个标识符(变量、函数)导出为公共API。这些操作可以在模块定义中根据需要使用任意多次。
  • 模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

总结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

模块有两个主要特征:

(1)为创建内部作用域而调用了一个包装函数;
(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

使用闭包主要是为了设计私有的方法和变量。

闭包的优点是可以避免全局变量的污染,缺点是闭包会常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。


感谢打赏,错误之处欢迎指正交流(`・ω・´) !~~



文章目录
  1. 1. 前言
  2. 2. 作用域闭包
    1. 2.1. 什么是闭包?
    2. 2.2. 再次举例:
    3. 2.3. 循环和闭包
      1. 2.3.1. 我们一步一步看
    4. 2.4. 重返块作用域
    5. 2.5. 模块
    6. 2.6. 现代的模块机制
    7. 2.7. ES6中的模块机制
  3. 3. 总结
|