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

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


 id="ol">
  
  • li1
  • li2
  • li3
  • li4
  • // 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》。这本书是我见过对闭包写的最好的一本。

    你可能感兴趣的:(JavaScript)