引子

假定我们页面上有 5 个按钮,分别为 按钮 0,…, 按钮 4,如下图所示:



现在,我们想为该按钮绑定一个事件:当鼠标点击按钮时,弹出显示框展示按钮索引,对于一些 JavaScript 的初学者来说,不由分说地就写出了下面的代码:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title> 闭包经典例子 < span class="tag"></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>

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



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

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

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

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

在 JavaScript 中,作用域大多只存在于 函数作用域(try-catch 也可以模拟作用域),而不存在诸如 Java 等语言中的块级作用域 {}

现在,既然我们的点击事件能够发生,就说明各个按钮的 onclick 属性是成功绑定的,出错的位置应该发生在 click() 函数内部。进一步分析:

function click() {
alert(i);
}

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

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

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

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

click();

然后我们会去执行:

alert(i);

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



那么我们就回到 click 函数作用域的上一级作用域寻找,在 load() 函数作用域下成功找到 i 值:



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

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

解决问题

在很多 JavaScript 书籍中,一般就直接摆出了解决该问题的方法,并没有做更多解释,所以很多人 “大概记住了” 解决问题的代码:

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

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

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

基于之前的知识,我们知道,要想确定一个引用指向的值,我们需要到达运行时环境,并且我们可以通过 执行一次函数调用来到达运行时环境 ,在运行时环境,我们产出需要绑定的值。所以,我们通过一个 立即执行函数(也称为即时函数) exec() 来到达运行时环境,快照了 i

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


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

  1. 我们犯错是因为我们并没有绑定到循环索引的值,而只是绑定了引用 i
  2. JavaScript 的作用域是函数级别的,我们虽然没有在绑定函数中找到引用 i 对应的值,但是通过 闭包(凶手) 形成的作用域链,我们最后还是拿到了 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);
}
}