闭包
理解闭包
我曾经遇到一个面试题大致是这样的:
设 a1 和 a2 表示任意两个数字,编写一个函数 foo,使 foo(a1)(a2) 的返回值是 a1 和 a2 的和。
分析得出,foo
返回的是一个函数,所以可以连续执行,返回的函数再次执行返回两次传入参数的和。于是可以有以下代码:
function foo(x) {
return function bar(y) {
return x + y;
};
}
foo(1)(2); // 3
这个题目并不难,我想说的是,foo
还可以照下列方式调用:
var sum = foo(1);
sum(1); // 2
sum(10); // 11
你会发现,传入 foo
的参数 1
被 foo
返回的函数保存下来了,并且可以无限次使用。
这种函数执行结束后,外层作用域被绑定到内部函数的情况被称作闭包。 上面代码中,bar
对原来的作用域的绑定,就是一个闭包。
在《你不知道的 JavaScript》一书中,闭包的定义如下:
当一个函数绑定了它所在的词法作用域时,就产生了闭包。 --- KYLE SIMPSON《你不知道的 JavaScript》
闭包可以用来保存外层作用域的变量,类似上面的情况,一个常见的闭包结构如下:
function foo() {
var a = "something";
return function () {
console.log(a);
};
}
var bar1 = foo(); // 形成一个闭包,bar1 保存了 foo 作用域的数据
// 把上面代码简写为立即执行表达式,可以直接形成一个闭包
var bar2 = (function () {
var a = "something";
return function () {
console.log(a);
};
})();
// 闭包也可能是在回调函数中实现
function baz(x) {
setTimeout(function () {
console.log(x); // 这个回调函数保存了上一级作用域的变量 x,形成了闭包
}, 5000);
}
循环
如果你不是非常了解闭包,那么在循环的时候使用闭包会得到你意料之外的结果。
for (var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
}, i * 1000);
}
上面的代码形成了闭包,每一个回调函数都保存外层作用域的变量 i
,当回调函数开始执行时,i
早已经因为循环被递加到 10
。所以打印出来的值就都是 10
。如果需要上述程序按照预期的结果执行,需要再嵌套一层闭包:
for (var i = 0; i < 5; i++) {
function foo(j) {
setTimeout(function () {
console.log(j);
}, j * 1000);
}
foo(i);
}
上面代码中,我们定义了辅助函数 foo
,然后按循环执行了:
foo(0);
foo(1);
foo(2);
foo(3);
foo(4);
从而 setTimeout
的回调函数就会形成 5 个闭包,保存了每次 foo
函数执行的参数 i
,程序的执行也会达到预期效果。观察可知,foo
函数可以被简化为一个立即执行表达式:
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(function () {
console.log(j);
}, j * 1000);
})(i);
}
闭包与模块
闭包是把双刃剑,上一小节就是在处理闭包发生的问题。不过闭包也有好的使用场景,比如使用闭包包装我们的代码。
使用闭包封装一个数据类型判断的模块:
function JSType() {
// 生成判断函数
function _judge(type) {
return function (val) {
return Object.prototype.toString.call(val) === type;
};
}
return {
isNull: _judge("[object Null]"),
isUndefined: _judge("[object Undefined]"),
isNumber: _judge("[object Number]"),
isString: _judge("[object String]"),
isObject: _judge("[object Object]"),
isArray: _judge("[object Array]"),
isFunction: _judge("[object Function]"),
};
}
在上述代码中,我们编写了一个判断 JS 数据类型的模块,这个模块暴露了判断各种数据类型的方法,各个类型的字符串都被保存到了闭包中。
其中每个判断函数都产生了一个闭包。
尝试调用:
var foo = JSType();
console.log(foo.isNumber(1)); // true
console.log(foo.isNull(null)); // true
console.log(foo.isUndefined(null)); // false
目前我们每次需要使用 JSType
的时候,都会执行一遍 JSType
,实际上这行代码用处不大,并且重复使用会消耗计算和内存成本,所以我们可以让这个模块一次生成,永久使用。尝试使用一个 IIFE 来包装它:
var jsType = (function JSType() {
// code ...
})();
console.log(jsType.isNumber(1)); // true
console.log(jsType.isNull(null)); // true
上述方式中,我们直接把 JSType
模块挂载到了全局变量 jsType
上,至少在浏览器中,我们的验证模块可以随意使用了。
但是它依旧不够完美,有当一日,你也许会在其他模块环境中运行 JavaScript,那个时候,这种包装方式也许并不适用,比如 AMD,CMD,CommonJS 等模块环境。
我们应该对模块的挂载部分单独处理,尝试再把挂载模块的部分分离出来:
function JSType() {
// code ...
}
// 挂载模块代码
function mountJSType(global, factory) {
// 这里是对模块的挂载部分
// 你可以针对各个模块系统分别挂载模块,提升你代码在各种模块环境的兼容性
// 挂载到全局对象上
global.jsType = factory();
}
// 立即挂载模块
mountJSType(window, JSType);
好像可以使用 IIFE 简化:
function JSType() {
// code ...
}
// 挂载模块代码
(function mountJSType(global, factory) {
// 这里是对模块的挂载部分
// 你可以针对各个模块系统分别挂载模块,提升你代码在各种模块环境的兼容性
// 挂载到全局对象上
global.jsType = factory();
})(window, JSType);
再把 JSType
直接写成函数表达式当做参数传入 IIFE:
(function (global, factory) {
// 这里是对模块的挂载部分
// 你可以针对各个模块系统分别挂载模块,提升你代码在各种模块环境的兼容性
// 挂载到全局对象上
global.jsType = factory();
})(window, function JSType() {
// code...
});
上述代码中,JSType
只执行了一次,暴露出来的一个个函数都绑定了它们的词法作用域,形成了闭包,外部代码无法对其内部做出访问和修改,可见闭包在处理模块化封装时的强大之处。
未来你会在大部分优质的源码中看到类似上述代风格的代码包,因为现在常见的模块化打包工具,打包后的代码风格像上面一样,这种对模块的封装方式叫 UMD(Universal Module Definition,通用模块定义)。
介于目前我们并没有学过其他模块环境,所以挂载部分的代码少得可怜,只是把模块挂载到全局上。
性能
闭包会保留函数外部作用域的数据,让函数执行结束后无法释放内存,这会给内存造成一定程度的负担,如果点开浏览器的进程,你会发现一些很复杂的页面,打开后浏览器占用的内存会飙升,这就是闭包给计算机带来的压力。