JavaScript的闭包一直算是一个难点,在很多书上,都以如下例子或近似例子阐述了闭包的一些概念和特征。

问题产生

Ex. 假定我们页面上有五个按钮,分别为按钮0-按钮4,其各自的索引也为0-4,如下图所示:

页面效果

现在,我们想为该按钮绑定一个事件:当鼠标 点击(click) 按钮时,弹出显示框展示按钮索引,对于一些JS的初学者来说,不由分说地,也不无困难的就可以写出如下的页面逻辑:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>闭包经典例子</title>
</head>
<body>
<button type="button">按钮0</button>
<button type="button">按钮1</button>
<button type="button">按钮2</button>
<button type="button">按钮3</button>
<button type="button">按钮4</button>

<script type="text/javascript">
window.onload = function load() {
var buttons = document.getElementsByTagName('button');
for(var i= 0,length = buttons.length;i<length;i++) {
buttons[i].onclick = function click() {
alert(i);
}
}
}
</script>

</body>
</html>

事与愿违,当我们运行上述页面时,无论我们单击那个按钮,都会弹出如下信息,对的,他们全部都弹出一样的值:

alert5

我一直认为这是一个极好的关于的闭包的例子,因为这个问题

由闭包而起,也由闭包而消除

现在,我们认识闭包,我最喜欢的对于闭包的解释是:

函数在当前词法作用域之外执行,就产生了闭包—KYLE SIMPSON)

在JS中,作用域大多只存在于函数作用域(try-catch也可以模拟作用域),而不存在诸如java等语言中的块级作用域 ({}) , 现在,既然我们的点击事件能够发生,就说明各个按钮的点击事件 (onclick属性) 是成功绑定的,出错的位置应该发生在 click() 函数内部。进一步分析

function click() {
alert(i);
}

《JavaScript 语言精粹》 中,描述到:

click() 绑定了变量 i 本身,而没有绑定变量 i 的值

但作者CrockFord也并未对造成这样局面的原因多说一句。之所以这样,是因为在JS中,对于引用指向的值的确定将会放在 运行时 确定(我们称之为retrieve),在页面初始化过程中,由于 click 函数没有调用,以及 click 的执行体并没有进入运行时环境,能够确定的仅只是 alert() 函数需要一个引用为i的参数,而并不知道对应的i的值。

当我们点击某个按钮时,发生如下的函数的调用,进入 click运行时环境

click();

然后我们会去执行:

alert(i);

为了执行 alert() 函数,需要为其提供参数 i(retrieve i), 更准确地说,就是设法找到 i 指向的变量的 ,在该层作用域 click 下,我们并不能找到 i,在Firefox下,我们可以利用Firebug看到,确实,在 作用域click 下,并没有参数 i

click作用域

那么我们就回到该作用域所处的上一级作用域寻找,亦即函数 load() , 此时,成功找到 i 值,在Firebug下,我们看到,该值是 5 (因为load函数已经执行完毕了,循环索引来到5),

load作用域下的i

基于其他语言的看法,我们可能会认为 load() 函数在执行完成后(页面加载),就会丢弃他自己的局部变量 i, 但是由于 click() 函数形成的闭包要要,所以i仍然会驻留在内存当中给其获取使用。

这就是一次跨作用域形成的闭包,所以我说该问题由闭包而引起(很多身边的朋友在看到接下来用闭包解决该问题,就以为闭包是个解决问题的探长,而忽略了这个问题的凶手之一也是闭包)。

解决问题

在JS的书籍中,一般就直接摆出了解决该问题的方法,并没有做更多解释,所以很多人”大概记住了”如下的解决问题的代码片,所以在之后解决类似问题的时候,“大概记住”只能帮助我们从形式上模拟(这会在之后的“误区”一节提到)解决方式。

buttons[i].onclick = (function exec() {
var j = i;
return function click() {
alert(j);
}
})();

我们分析一下解决思路,现在先抛开解决的代码片,我们想一下,如果我们还是要依赖于循环的索引去绑定按钮的弹出文字,应该怎么办,我想,解决方案的核心应当是:

“快照”我们的循环索引 i,让按钮的点击事件函数能够真正绑定到索引值,而不仅只是引用

基于之前的知识,我们知道,要想确定一个引用指向的值,我们需要到达运行时环境,换句话说,我们可以通过执行一次函数调用来到达运行时环境,在运行时环境,我们产出需要绑定的值。所以,在上述代码片中,我们通过一个立即执行函数(也称为即时函数) exec() 来到达运行时环境。

在函数的执行体中,我们将以运行时的眼光来看待,首先,我们取得 i 的值(0,1,2,3,4),并赋值给 j, 即记录了循环索引,但此时,还没有为绑定保存索引:

var j = i;

之后,返回一个事件绑定函数 click。现在,我们可以清晰的知道 click 将会成为一个闭包,由于改闭包需要 j,所以外部作用域 exec在执行完成后,内存中仍然保有j的值(0,1,2,3,4),即我们此时真正快照需要的循环索引。下图中,Firebug显示了作用域 exec()j 成功的为需要的他的作用域 click() 保留:

j得到了保留

所以,我们又通过闭包实现了快照,解决了这个恼人的绑定问题。我更愿意把这个问题的产生和解决归纳为如下过程

  1. 我们犯错是因为我们并没有绑定到循环索引的值,而只是绑定了引用 i
  2. JS的作用域是函数级别的,我们虽然没有在绑定函数中找到引用 i 对应的值,但是通过闭包(凶手)通过作用域链,我们最后还是拿到了 i 的值,它是5,并不是我们期望的。
  3. 然后我们知道可以通过函数调用进入运行时取得循环的索引的值,所以我们就通过一个立即函数来进入运行时。
  4. 仅仅取得值是不够的,我们还要缓存这个值,因为我们的事件依赖于这个值。所以,能够达到此目的的仍然是闭包,让闭包去强行包住我们要的值(解决问题的保护伞)

误区

之前,我发现周围一些同学在认识这个例子的解决策略的时候,只认识到了“其形”,也就产生如下的几个误区。

误区之一: 通过立即函数解决问题

有些同学认为是立即函数解决了这个问题, 隔了几天,当他回顾着想要再写一遍解决代码的时候, 他尝试回顾着 立即执行函数 这个概念:

buttons[i].onclick = (function exec() {
return function click() {
alert(i);
}
)();

其实这和解决之前的代码片是基本一样的,在这样的执行体中,我们并未能 快照 到循环索引。即便不通过立即函数,我们也能解决问题,如下:

buttons[i].onclick = exec();

function exec() {
return function click() {
alert(i);
}
}

产生这个误区的原因是,没有看到立即执行函数只是为了产生一次函数调用(立即执行函数甚至匿名执行函数只是为了让调用更简洁点),从而进入进行时去追溯循环索引 i 的值。

误区之二:通过标记值解决问题

同样的在看过解决代码片后的几天,有的同学大概又只能依稀想起 var j=i; ,然后仅用此去尝试解决问题:

buttons[i].onclick = function click() {
var j = i;
alert(j);
}

因为没有进行运行时环境,所以我们并没有动态的对 j 进行赋值( var j=i 并没有在循环内得到执行),所以最后当点击事件发生时,过程如下

var j = i; 这一赋值操作需要去找到 i,我们仍然是在外层作用域或者说闭包指向的域中找到了 i

所以,这种解决方式将毫无作用,产生这种误区的原因是没有理解,函数体的逻辑只有在运行时才会发生,所以想要通过额外变量来记录循环索引,我们得想办法进入运行时。

同理,如下的代码片仍然不起作用,只是这次弹出显示的数字将永久为4, 因为 ii 最后的一次赋值结果为4;

for(var i= 0,length = buttons.length;i<length;i++) {
var ii = i;
buttons[i].onclick = function click() {
alert(ii);
}
}