JavaScript—for循环中的闭包以及事件机制

在JavaScript中,有一个非常容易出现错误的场景是:在for循环中给DOM元素定义事件。

<!-- HTML CODE -->
<ol id="ol">
  <li>li1</li>
  <li>li2</li>
  <li>li3</li>
  <li>li4</li>
</ol>
// JavaScript
var olList = document.getElementById('ol');
var liItems = olList.getElementsByTagName('li');
for(var i=0;i<liItems.length;i++){
    var liItem = liItems[i];
    liItem.onclick = function(){
            alert(i);
    };
}

上面的例子中点击每一个li都会弹出4,而并不是我们所期待的0,1,2,3。我们期望将每一个i变量传递给对应的事件处理函数。


初步的解决方案


有朋友说,使用闭包,并给出以下代码:

var olList = document.getElementById('ol');
var liItems = olList.getElementsByTagName('li');
for(var i=0;i<liItems.length;i++){
    var liItem = liItems[i];
    liItem.onclick =  (function(i){
        return function(){
            alert(i);
        };
    })(i);
}

但多数人并不能解释清楚4的来源,有些人会说for语句会先执行完,然后再执行函数的时候调用的时候,因为JavaScript没有块级作用域,所以只能访问到全局中的i,此时已经是4了。

这样的解释是接近事实真相的,但揭露真相前的最后一步,隐藏在JavaScript和浏览器处理事件的机制过程中。

假设语句中没有注册click事件,而是以下两种代码:

// 示例1
var olList = document.getElementById('ol');
var liItems = olList.getElementsByTagName('li');
for(var i=0;i<liItems.length;i++){
    var liItem = liItems[i];
    alert(i); // 输出 0,1,2,3
}

// 示例2

var olList = document.getElementById('ol');
var liItems = olList.getElementsByTagName('li');
for(var i=0;i<liItems.length;i++){
    var liItem = liItems[i];
    (function(){alert(i)})(); // 输出 0,1,2,3
}

这样是可以正常打印变量的值的。我们可以看出输出全是4是click事件带来的效果,而不是赋值给事件的函数的作用。而我们使用函数闭包的目的无非是为了解决这种事件(异步)带来的影响,为其对应变量单独开辟一个作用域,以便执行事件句柄时获取对应变量。

本文以此例为引子,初探JavaScript中的事件处理机制,并涉及闭包和作用域的一些基础知识。


JavaScript的事件处理机制


为了更好的解释JavaScript中的事件机制,我们引入如下示意图(转引自Philip Roberts的演讲《 Help, I’m stuck in an event-loop》)。


上图我们可以分为两部分来理解,一部分为JavaScript引擎(左上角v8)本身,一个是JavaScript的运行时(runtime),web中也可以简单认为是宿主浏览器。

JavaScript引擎本身是对ECMAScript标准的实现。内部主要有堆和栈两部分。堆的作用主要是分配内存等等复杂操作,在此我们不关注堆的功能。栈的作用是追踪函数的执行过程,碰到函数调用则入栈,函数return或者执行到语句末尾则出栈。如下示例:

function foo(){
     return 'foo';
}

function bar(){
     foo();
}

function barz(){
     bar():
}

barz();

它的执行过程如下:

  1. barz函数入栈。
  2. barz调用bar,bar入栈
  3. bar调用foo,foo入栈。
  4. foo遇到return出栈
  5. bar遇到函数结尾的大括号,出栈
  6. 。。。

这样执行到栈为空为止。

这样一步一步的执行过程对同步的任务是可以的。对于有些异步调用,如ajax请求,鼠标的点击事件等等,什么时候完成时不能预测的。像这样的等待是不可接受的。

一般而言,操作分为:发出调用和得到结果两步。发出调用,立即得到结果是为同步。发出调用,但无法立即得到结果,需要额外的操作才能得到预期的结果是为异步。同步就是调用之后一直等待,直到返回结果。异步则是调用之后,不能直接拿到结果,通过一系列的手段才最终拿到结果(调用之后,拿到结果中间的时间可以介入其他任务)—— 引自 朴灵的评注

如前所述,JavaScript引擎只实现了ECMAScript标准的要求。这是一个非常简单的模型。这个模型中只有一个线程,它一次只能处理一件事情。对于这种异步的请求它是无能为力的。

但是浏览器(或者运行时环境,以web宿主为例)的能力是强大的。JS引擎是单线程,但浏览器可以是多线程的。当然这并不是重点。我们的重点是浏览器为实现异步的调用采用了什么样的方案。

答案是event loop(事件循环)。

接下来我们将讨论一下event loop的工作机制。回到我们原始的click事件上面:

for(var i=0;i<liItems.length;i++){
    var liItem = liItems[i];
    liItem.onclick = function(){
            alert(i);
    };
}

onclick事件是一个异步的调用。它首先会调用浏览器对应的web api,浏览器负责监听对应对象的click事件。当事件发生后,将这个事件排到上图最下面的事件队列(event queue,即图中的callback queue)里。这里是事件的响应过程。

当引擎解析完所有语句与dom之后。event loop便开始一直询问下面的event queue。若event queue中的有对应的回调函数,则放入堆栈中去执行。这是一个轮询的过程。

所谓轮询:就是你在收银台付钱之后,坐到位置上不停的问服务员你的菜做好了没。 
所谓(事件):就是你在收银台付钱之后,你不用不停的问,饭菜做好了服务员会自己告诉你。———— 引自 朴灵的评注

所以,上例中的for循环执行过程大致如下所述:

  1. for循环每执行一次。调用dom中的onclick事件。并加入到事件队列中,一共有四个onClick事件。
  2. 所有的语句执行完(当然包括for语句),event loop 开始起作用,轮询事件队列,分别执行。
  3. 一共有4个li,从0开始计算,0-3,然后最后i++,i的值最终为4。

至此,为什么输出都为4的问题便解决了。


为什么闭包能解决问题


当执行click事件的回掉函数时,访问的i为全局变量中的i。这个i是属于全局的,并不属于每一个回掉函数。我们需要的是与各个回调函数对应的i。

liItem.onclick =  (function(i){
        return function(){
            alert(i);
        };
})(i);

在这里我们把全局中的i传递给一个IIFE(匿名函数自执行)结构。让每一个回掉函数访问函数内部的i。这样便能对应起来了。

这也是我们前面说的,JavaScript“没有”块级作用域,只有函数作用域的实例。


总结:


关于事件的部分,写的不太详细,文章参考了以下三个来源:

1.视频

2. 阮一峰的文章 

3. 朴灵对阮的评注 

关于闭包的部分,请大家多参考《You don’t konw JS:Scope and closures》。这本书是我见过对闭包写的最好的一本。

猜你喜欢

转载自blog.csdn.net/hefeng6500/article/details/80471073