javascript闭包与IIFE

先来看看这段代码是否能正确运行,我们期望的结果是输出“5,4, 3, 2, 1, go!”

for (var i = 5; i >= 0; i--) {
  setTimeout(function () {
    console.log(i === 0 ? 'go!' : i);
  }, (5 - i) * 1000);
}
// 延时输出了6个-1

运行后发现输出了“-1, -1, -1, -1, -1,-1”,这是为什么呢?

javascript 的运行机制

首先我们来了解一下 javascript 的运行机制,javascript 是单线程的,也就是说 javascript 在同一个时间只能做一件事。这就意味着所有的任务都要排队。例如通过网络访问读取数据的任务因为网络的问题通常返回结果较慢,此时主线程已经空闲,但后面的任务不得不排队等待。于是设计者就把任务分为同步任务与异步任务,同步任务是指在主线程上按顺序排队的任务,异步任务则是不进入主线程,而进入任务队列的任务。只有任务队列通知主线程可以执行某个异步任务了,该任务才会进入主线程。

大致运行机制如下:

  1. 所有同步任务都在主线程上运行,形成一个执行栈
  2. 主线程外还有一个任务队列,异步任务运行有了结果就在任务队列中放置一个事件
  3. 一旦执行栈中所有同步任务执行完毕,就会读取任务队列中的事件,事件对应的异步任务就结束等待状态,进入执行栈,开始执行
  4. 主线程不断重复上面的三步,这样的重复就叫作事件循环

一个原则:执行栈中的代码(同步任务),总是在读取“任务队列”(异步任务)之前执行,异步任务执行时就是在调用回调函数。

知识回顾:什么是闭包?函数记住并访问所在的词法作用域,叫做闭包现象,函数对作用域的引用就叫闭包。

问题分析

有了这部分知识,我们再来看上面那段代码。for 循环是同步任务,在主线程中先运行,定时器 setTimeout 是异步任务,第一个参数是异步回调函数,第二个参数是推迟执行毫秒数。因此 for 循环第一次执行时,setTimeout 按推迟执行毫秒数在任务队列中设置了一个事件,此时又继续执行第二次 for 循环,又在任务队列中设置一个事件,依次类推,最后一次 for 循环执行完毕,任务队列中共设置了六个事件,此时主线程空闲,读取任务队列,开始按照不同的推迟毫秒数执行回调函数。回调函数声明时虽然是出现在 setTimeout 的参数里,但回调函数并不是在 setTimeout 中声明的(初学者很容易在这里搞错),又由于这里没有 ES6 的 let 或 const 声明,所以 for 循环中没有形成块作用域,回调函数相当于是在全局中声明,并作为参数传递给 setTimeout 方法。那么根据闭包的规则,回调函数形成了对全局作用域的闭包(一般我们不把对全局作用域的闭包叫做闭包,因为全局一直存在,不会被垃圾回收,我们就称为词法作用域)。根据 javascript 的词法作用域,回调函数调用时自身作用域中没有变量 i,沿作用域链向上查找是全局作用域,此时同步任务的 for 循环早已经执行完毕,i 变量的值是-1。故会按推迟的毫秒数输出 6 个-1。

IIFE

如何解决这个问题呢?相信看过闭包那章的人已经想到几种解决方案。我们先来看看 IIFE 的方案。严格来说 IIFE 本身并没有形成闭包,因为 IIFE 并没有在声明以外的地方被调用,不满足闭包的定义,但是 IIFE 利用函数表达式与其立即调用,形成了一个新的即时绑定的做用域。

这段代码会奏效吗?

for (var i = 5; i >= 0; i--) {
  setTimeout(
    (function () {
      console.log(i === 0 ? 'go!' : i);
    })(),
    (5 - i) * 1000
  );
}
// 5, 4, 3, 2, 1, go!没有延时就输出了

确实不会再输出-1 了,但是又出现了新问题,并没有按定时器定义的时间执行,而是直接全部输出了。这是因为 IIFE 立即执行的特点,虽然 IIFE 的立即执行使内部的匿名函数访问到了 for 循环每次调用时即时的变量 i,但这也使本应该在任务队列的异步回调函数成为了主线程中的即时调用函数,定时器就失效了。

那么我们把 IIFE 放在 setTimeout 方法的外部,这样就只建立一个新的作用域,而没有改变 setTimeout 异步回调函数的执行顺序,这个方案会解决问题吗?

for (var i = 5; i >= 0; i--) {
  (function () {
    setTimeout(function () {
      console.log(i === 0 ? 'go!' : i);
    }, (5 - i) * 1000);
  })();
}
// 延时输出了6个-1

看起来,问题又回到了起点,还是输出了 6 个-1,但其实更进了一步,现在确实通过 IIFE 建立了一个新的作用域。for 循环完成后,从任务队列进入到主线程的回调函数对 IIFE 作用域的闭包使回调函数访问到了这个作用域,但是在这个作用域中并没有保存任何变量,回调函数继续沿作用域链向全局作用域查找变量 i,此时 for 循环已经结束,i 的值为-1。

那么我们将变量保存在这个作用域中,是不是就解决问题了呢?

for (var i = 5; i >= 0; i--) {
  (function (j) {
    // 这里j是形参,可以任意起名字,也可以叫i
    setTimeout(function () {
      console.log(j === 0 ? 'go!' : j);
    }, (5 - j) * 1000);
  })(i); // 这里i是实参
}
// 正确地按定时输出了“5, 4, 3, 2, 1, go!”

上面这段代码中我们把变量 i 作为参数传递给了 IIFE,在 IIFE 中我们可以给参数起任意的名称,这里是 j(也可以继续叫 i)。这样我们就把每次 for 循环产生的正确的 i 保存在了每次 for 循环新建的 IIFE 作用域中,调用 setTimeout 的回调函数时,就能通过闭包访问到正确的 i 了。

我们也可以不通过 IIFE 传递变量,而是直接在 IIFE 中声明变量,并将 i 的值赋给新声明的变量。这样也可以在新建的作用域中保存正确的 i 的拷贝。

for (var i = 5; i >= 0; i--) {
  (function () {
    const j = i;
    setTimeout(function () {
      console.log(j === 0 ? 'go!' : j);
    }, (5 - j) * 1000);
  })();
}
// 正确地按定时输出了“5, 4, 3, 2, 1, go!”

多重函数闭包

看过闭包那章就知道还可以用多重函数闭包来创建新的作用域。回调函数作为 setTimeout 方法的参数被传递,因此回调函数记住了自身声明时的 timeoutFunc 的作用域,即回调函数与 timeoutFunc 的作用域形成闭包。for 循环每次生成的变量 i 作为参数传递给 timeoutFunc 的作用域并通过闭包保存下来,使得回调函数在调用时能够访问到正确的 i。

function timeoutFunc(i) {
  setTimeout(function () {
    console.log(i === 0 ? 'go!' : i);
  }, (5 - i) * 1000);
}
for (var i = 5; i >= 0; i--) {
  timeoutFunc(i);
}

同样的道理,我们还可以在回调函数内部形成闭包,这有点类似于第一次不成功的 IIFE。callbackFunc 中的匿名函数通过 return 的方式与 callbackFunc 函数的作用域形成闭包,for 循环每次生成的 i 作为 callbackFunc 的参数被传递到 callbackfunc 中并通过闭包保存下来,使内部的匿名函数在被调用时依然能够访问到闭包中正确的 i。

for (var i = 5; i >= 0; i--) {
  setTimeout(callbackFunc(i), (5 - i) * 1000);
}
function callbackFunc(i) {
  return function () {
    console.log(i === 0 ? 'go!' : i);
  };
}

let 块作用域

最后一种方式则是最简单的方式,就是利用 ES6 中的 let 关键字有块作用域的特点,使 for 循环每次生成的 i 都在相互隔绝的块作用域中,回调函数与块作用域形成闭包,回调函数在被调用时可以访问到相对应的块作用域中正确的变量 i。

for (let i = 5; i >= 0; i--) {
  setTimeout(function () {
    console.log(i === 0 ? 'go!' : i);
  }, (5 - i) * 1000);
}

前方高能预警


其实将 for 循环每次产生的 i 做为参数传递给 setTimeout 的回调函数就能解决这个问题。回调函数的参数 i 会屏蔽全局变量 i,使回调函数内的 i 正确绑定 for 循环每次生成的 i。setTimeout 从第三个参数开始的参数就是可以传递给回调函数的参数。

for (var i = 5; i >= 0; i--) {
  setTimeout(
    function (i) {
      console.log(i === 0 ? 'go!' : i);
    },
    (5 - i) * 1000,
    i
  );
}

参考链接
[https://www.jianshu.com/p/867cbe73d534]
[https://segmentfault.com/a/1190000007396482#articleHeader2]
[http://www.ruanyifeng.com/blog/2014/10/event-loop.html]