phenomLi / Blog

Comments, Thoughts, Conclusions, Ideas, and the progress.
219 stars 17 forks source link

Js循环事件绑定的坑与作用域 #10

Open phenomLi opened 6 years ago

phenomLi commented 6 years ago

一个场景

假设现在有这么一个场景,在一个<ul/>里面有10个<li/>

<ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
</ul>

然后要求每一个<li/>点击之后都会填入该<li/>index
看起来是十分简单的需求,很多经验不足的人里面就洋洋洒洒写下一段(比如以前的我):

const li = document.querySelectorAll('ul>li');

for(var i = 0; i < li.length; i++) {
    li[i].addEventListener('click', function(e) {
        this.innerHTML = i;
    });
}

这段看起来没有任何问题,按照预期,会是这样子(每一个li被点击后): 但是的代码运行起来却是这样子(每一个li被点击后):
这就奇怪了,为什么明明写明了每次click都会将当前的i赋值给当前li的innerHTML,为什么会出现10个10呢?

函数与作用域

javascript与其他语言不同,js没有块级作用域,而只有函数作用域,也就是说,js中,在forif等代码块中定义的变量都会默认变成全局变量(window对象下的一个属性):

if(exp) {
    var x = 20;
    //这里的x是全局变量
}

for(var i = 0; i < n; i++) {
    //这里的i是全局变量
}

这意味着什么呢?作用域的作用是用作隔离代码块外部对代码块内部的影响,使作用域内部的变量独立于作用域外部的变量,也就是说作用域有着锁定变量的功能。所以这意味着在上面代码中的任何一处地方更改ix的值,都会对iffor内部产生影响。
而函数的作用域要在函数运行的那一刻才会产生,函数表达式并没有作用域。

我们再来看刚刚开始的例子。 现在很明显知道,循环变量i也是一个全局变量,在addEventListener中的回调表达式并没有产生独立的作用域,所以很明显就能想到,functiion里面的i随着循环的自增一直在变化,当用户点击<li/>时,addEventListener中的function才真正执行,产生函数作用域,但是这时的i早已变成了10。

for(var i = 0; i < li.length; i++) {
    //i是全局变量,不断地++
    li[i].addEventListener('click', function(e) {
        //这里面的i是全局的i,也一直在变化
        this.innerHTML = i;
    });
}

原来如此,看来《javascript: the good part》这么薄也不是没有道理的。

解决办法

既然已经知道了原因,那么解决的办法也应该很容易想到。
目前主流的办法有三种:

第一种:使用ES6let关键字

let关键字修复了js没有块级作用域的问题,我们用let代替var改写原始代码:

const li = document.querySelectorAll('ul>li');

//使用let
for(let i = 0; i < li.length; i++) {
    li[i].addEventListener('click', function(e) {
        this.innerHTML = i;
    });
}

let应该是最简单的方法,但是let的兼容性还比较差(至少我知道微信浏览器还不兼容),所以应用的时候尽量慎重。

第二种,用IIFE

IIFE是立即执行函数表达式(Immediately Implement Function Expression)的简称,使用IIFE我们可以让一段函数表达式马上执行。 使用IIFE改写原始代码:

const li = document.querySelectorAll('ul>li');

for(var i = 0; i < li.length; i++) {

    /*使用IIFE,让马上执行一段函数表达式,每次循环都会生成一个新的函数作用域,将变量i的当前值锁定在IIFE的作用域里面,作用域里面的i不会受到外面的i的自增的影响
    /*同时返回一个闭包,这个闭包可以访问到保存后的i的值
    /*可能有点绕,但是就是这个道理
    */
    li[i].addEventListener('click', (function(i) {
        return function(e) {
            this.innerHTML = i;
        }
    })(i));
}

这种方法用得比较多,因为兼容性是最好的。但是要理解起来会花点时间,特别是对闭包和作用域不了解的朋友。

第三种:我觉得是最优雅的方法,使用map

为什么我会觉得这种办法是最优雅呢,因为他很有functional programing的味道:

[].slice.call(document.querySelectorAll('ul>li')).map((item, index) => 
    item.addEventListener('click', e => {
        item.innerHTML = index;
    })
);

真的很美,流畅简洁直观的美。 其实这种方法跟第二种方法的本质是一样的,都是在循环的时候执行一个函数来产生函数作用域(第二种是用执行IIFE,这个是执行作为参数传入map方法的函数,循环变量就是index)。



---EOF---