Javascript深入浅出之闭包

每写15行代码,就会遇到一个闭包。这句话毫不夸张,因为理论上来说,每一个函数都是一个闭包。

闭包是指那些能够访问独立(自由)变量的函数 (变量在本地使用,但定义在一个封闭的作用域中)。换句话说,这些函数可以“记忆”它被创建时候的环境。

也正是由于它的特性,还有一种说法,就是:

闭包是代码块和创建该代码块的上下文中数据的结合。

词法作用域

JavaScript 的作用域是词法作用域 (lexical scoping)。我们来解释一下,what’s this,顺便提一下动态作用域。

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

这就是词法作用域——在 JavaScript 中,变量的作用域是由它在源代码中所处位置决定的,并且嵌套的函数可以访问到其外层作用域中声明的变量。

词法作用域是在定义时确定的,而动态作用域是在运行时确定的。上例代码在词法作用域中输出的应该是 2 。

什么是闭包

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

我要是拿这个例子出来说,肯定要有人不同意的。‘这不是上一篇里面说的作用域链的事情吗,关闭包什么事?’

得了您呢,咱们换个例子瞄两眼。

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

众所周知,外部是无法访问内部作用域的内容的。那 bar 缘何可以输出 a 变量。是的,闭包!!!

我们来看一下第二段代码,其实 bar 被声明赋值的时候,就已经绑定了上下文环境了(函数内部所引用到的变量)。

1
2
3
4
5
// bar 闭包
barClosure = {
call: bar // 引用到function
lexicalEnvironment: {a: 1} // 上下文环境
};

闭包已经形成了,我们下一次执行的时候,自由变量就可以直接从闭包里寻找了。

第一段代码中的 foo 其实也已经产生闭包了,它绑定的是全局上下文中的自由变量。只不过没有通过 return 来进行一个返回,无法显示出闭包的特性。

共享作用域

通过前面几章的学习,我们应该清楚,如果两个函数是在同一个执行上下文中,实则是共享的同一个外部作用域,两个函数里的自由变量指向的是同一个 [[scopes]]

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
var a = 1;
return {
increase: function() { console.log(++a) },
decrease: function() { console.log(--a) }
}
}
var bar = foo();
bar.increase(); // 2
bar.decrease(); // 1

可以看到 bar.increasebar.decrease 指向的是同一个 [[scopes]] 中的 a

那我现在换一换。

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
var a = 1;
return function() {
console.log(++a);
}
}
var bar1 = foo();
var bar2 = foo();
bar1(); // 2
bar2(); // 2

这个时候我门可以发现, bar1 的自由变量在第一次执行的 foo 产生的上下文中, bar2 的在第二次产生的上下文中。两者的 [[scopes]] 已经是不一样的了,所以所引用的自由变量 a 其实是不同的作用域中的,所以也就无法相互影响了。

循环中的错误

这里就用 MDN 里面的例子吧。

这里之所以显示错误,原因在于三个事件响应函数(闭包)共享作用域了,这三者的 [[scopes]] 指向的是同一个 父变量对象的层级链 。 onfocus 的回调被执行时,循环已经完成,且此时 item 变量(由所有三个闭包所共享)已经指向了 helpText 列表中的最后一项。

解决办法就是让三个闭包有各自的执行上下文。

函数重载

JavaScript 中没有函数重载,但是我门可以利用参数检测来实现伪函数重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function addMethod(obj, name, fn) {
var old = obj[name];
console.log(old);
obj[name] = function() {
if (fn.length === arguments.length) {
return fn.apply(this, arguments);
} else if (typeof old === "function") {
console.log(this.old = old);
return old.apply(this, arguments);
}
}
}
addMethod(ninjas, "find", function() {
// statement
});
addMethod(ninjas, "find", function(first) {
// statement
});
addMethod(ninjas, "find", function(first, second) {
// statement
});

每一次执行 addMethod 添加方法的时候,都会产生新的执行上下文,同样的 fn 参数以及 old ,在每个新的执行上下文都有不同的值。这是重载能够实现的根本所在。每一个 old[[scopes]] 都指向的是上一次执行 addMethod 产生的 父变量对象的层级链

thisarguments 则是当前函数执行时所指向的当前值。

模块

我们经常使用 IIFE 来进行模块化的应用,它可以创建一个独立的作用域,也只能创建一次,我们可以在其中定义方法,除了这些方法外,其它地方都无法访问,它就是这么地简单,安全。

来看一下类库的封装吧,有两种方式。

1
2
3
4
5
(function() {
var jQuery = window.jQuery = function() {
// Initialize
}
});
1
2
3
4
5
6
7
var jQuery = (function() {
function jQuery() {
// Initialize
}
return jQuery;
});

他们都是通过创建独立作用域,最后会将存储了许多方法的对象暴露出去。避免了全局变量的污染,而且还提高了安全性(变量冲突)。

我们再来看一下现在的模块依赖加载器的核心概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
};
})();

Manager 函数只会执行一次,我们后面通过 MyModules.define 来实现模块化定义的时候,共享的同一个词汇环境,各个模块都会存储在 modules 中。而每次依赖模块执行时,都会从 modules 中取到模块整体并传递给要执行的函数。

很多时候我们在实现类库的时候会在 IIFE 前面添加上 ; ,这是由于 JavaScript 的 ASI 带来的,这也是为了防止有人不在 IIFE 后面添加 ; 从而导致代码压缩的时候出错。

替“内存泄漏”说句话

很多文章,很多书里,都会讲闭包会带来 内存泄漏 ,其实正相反,闭包是来消除内存泄漏的,如果产生了,对不起,那是使用者应该背的锅。正常来说,内存泄漏 是指 IE6 带来的 bug ,现代浏览器的引用计数来进行垃圾处理,几乎不会产生 内存泄漏

关于 内存泄漏 ,可以看我的 一种有趣的JavaScript内存泄漏[译]


闭包带来的妙用,远不止文中所谈的这些,这些都是用来讲解闭包的基本知识并帮助深入一点理解的。打好基础,妙用无穷,后面的学习会水到渠成。一味追求框架,不打理基础,则如高屋建瓴,慎之。

热评文章