XieYang-blog

javascript难点剖析二:closure(闭包)

在上一篇“javascript 难点剖析一:prototy(原型)”后, 我们紧接着来理解什么是闭包。闭包虽不是 javascript 的特色功能, 但要理解还真要费那么点工夫。

在理解闭包之前, 首先要清楚 javascript 中的作用域只有2种: 全局作用域方法作用域。全局作用域很好理解了, 方法作用域就是指一个 function 形成一个独立的作用域, 而且方法作用域还能够嵌套。


与别的语言不同的是: 花括号 “{}” 不能形成一个独立的作用域, 例如 Java 中的作用域

作用域

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
28
29
30
31
32
33
34
35
36
var global = 0;

function foo() {

/**
* 这里面就形成了一个方法作用域
* 能够保护其中的变量不能被外部访问
* 但此方法作用域能够访问全局作用域
*/

var local1 = 1;
console.log(global);

/**
* 嵌套方法作用域
*/
function inner() {

/**
* 这里再度形成了一个方法作用域
* 这个作用域内可以访问外部的作用域变量 local1
*/

var local2 = 2;
console.log(local1);
}

/**
* 在 inner 方法的作用域外就不能访问其内属性了
*/

console.log(local2); // 报错 ReferenceError: aa is not defined
}

foo();
console.log(local1); // 报错 ReferenceError: a is not defined

闭包

我们以 for 循环开始

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

思考:这段代码会输出什么呢?
仔仔细细看了 30 秒,战战兢兢地说:每隔 1 秒后按顺序输出 0-9 吗?
0, 1, 2, 3, 4, 5, 6, 7, 8, 9


我们将这段代码放到浏览器中执行一下得出结果:
在 1 秒后几乎同时输出了 10 个 10(注意不是每隔 1 秒输出一个 10)

为什么?

原因

因为 setTimeout 是异步执行!!

原理

这是由于 javascript 的 “消息队列” 和 “异步函数” 机制导致的结果。

知识普及:

栈:
JavaScript 是单线程语言,主线程执行同步代码。
函数调用时,便会在内存形成了一个“调用记录”,又称“调用帧”,保存调用位置和内部变量等信息。如果函数内部还调用了其他函数,那么在调用记录上方又会形成一个调用记录,所有的调用记录就形成一个“调用栈”。

堆:
对象被分配在一个堆中,一个用以表示一个内存中大的未被组织的区域。

消息队列与事件循环Event Loop:
一个 JavaScript 运行时包含了一个待处理的消息队列(异步任务),(内部是不进入主线程,而进入”任务队列”(task queue)的任务。比如UI事件、ajax网络请求、定时器setTimeout和setInterval等。
每一个消息都与一个函数(回调函数callback)相关联。当栈为空时,从队列中取出一个消息进行处理。这个处理过程包含了调用与这个消息相关联的函数(以及因而创建了一个初始堆栈帧)。当栈再次为空的时候,也就意味着消息处理结束。
这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

javascript 是单线程,主线程执行同步代码,事件、 I/O 操作等异步任务,将会进入任务队列执行,异步执行有结果之后,就会变为等待状态,形成一个先进先出的执行栈,主线程的同步代码执行完之后,再从”任务队列”中读取事件,执行事件异步任务的回调。
这就是为什么执行顺序是, 同步 > 异步 > 回调
更简单的说:只要主线程空了(同步),就会去读取”任务队列”(异步),这就是 JavaScript 的运行机制。


举个野栗子便于理解

因为 setTimeout 是异步执行,所以我们可以将这个 for 循环拆成 2 个部分:
第一个部分专门处理 i 值的变化
第二个部分专门执行 setTimeout

因此我们可以得到如下代码

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
28
29
30
31
32
33
34
var i = 0;

/**
* 第一个部分
*/
i++;

// 省略 8 次

i++;

/**
* 直到这里,一直在做变量 i 的自增计算
* i 的值自增到10
*/

/**
* 第二个部分
*/
setTimeout(function () {
console.log(i);
}, 1000);

//省略 8 次

setTimeout(function () {
console.log(i);
}, 1000);

/**
* 到这里,执行了 10 次完全相同的代码
* 且同步设置了延迟为 1 秒的10个定时器
* 1秒后,10 个同时输出了 i 的值
*/

当执行到第一个 setTimeout 时,javascript 内核检测到这是一个异步函数,所以将这个异步函数移入了消息队列,等待所有主线同步任务的完成后再继续执行。
接着第二个、第三个 setTimeout 异步函数依次执行相同操作。
直到将第 10 个 setTimeout 异步函数加入消息队列后,同步任务执行完成,然后才开始以先进先出的原则调用“消息队列”里面的异步函数。

提升

以上的疑惑解决了之后,我们再来看一个例子:

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

这段代码与上面的不同之处是将 1000 的延迟改为了 0!

思考:这段代码会输出什么呢?
随便想了想,上面的有 1000 毫秒的延迟,变量 i 才会瞬间执行到 10,而这里为 0,说明没有延迟,也就是说没有给变量 i 变化的时间,所以答案肯定是
0, 1, 2, 3, 4, 5, 6, 7, 8, 9


我们将这段代码放到浏览器中执行一下得出结果:
几乎同时输出了 10 个 10
有的童鞋可能又要疑惑了,为什么?????
有这样疑问的童鞋建议把原理认认真真再看一遍,还要多吃点上面的‘野栗子’,有助于理解。

为什么 setTimeout 中匿名函数没有形成闭包呢?

原因

因为 setTimeout 中的匿名函数没有将 i 作为参数传入来固定这个变量的值, 让其保留下来, 而是直接引用了外部作用域中的 i, 根据上面的 javascript 机制(先同步,再异步),因此 i 变化时, 也影响到了匿名函数。

因此如果我们定义一个外部函数, 让 i 作为参数传入,即可“闭包”我们要的变量了!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (var i = 0; i < 10; i++) {
// 注意关键是我们把想要闭包的值当参数传入一个方法
// 这个方法 return 一个新的方法 -- 闭包!!
setTimeout(fn(i), 1000);
}

function fn() { // 为了深刻理解闭包, 这个函数我没有用参数
// 神奇的"闭包"发生在这一步, 其实就是作用域和值复制在起了关键作用,
// 对于数字/字符等类型是复制值, 而不是引用
var a = arguments[0];
return function () {
console.log(a); // 注意现在我操作的变量已经变成 a 了,
// 已经和 i 没有半毛线关系了!
// 而 a 的值就是当时执行时赋予的一个确定值,
// 不会因 i 的变化而变化了!
};
}

提升

1
2
3
4
5
6
7
8
9
for (var i = 0; i < 10; i++) {
(function (a) {
// 变量 i 的值在传递到这个作用域时被复制给了 a,
// 因此这个值就不会随外部变量而变化了
setTimeout(function () {
console.log(a);
}, 1000);
})(i); // 我们在这里传入参数来"闭包"变量
}

写在最后:真正理解了作用域也就理解了闭包

🐶 您的支持将鼓励我继续创作 🐶