由一道關於變數作用域的面試題,來加深對var和let的理解

zarknight發表於2018-07-14

最近,有一道JavaScript面試題挺流行的,很多朋友去面試的時候都遇到了。這道題目大致是這個樣子的:

以下這段程式碼執行後,結果為什麼不是依次輸出0到9?如果要讓它實現這樣的輸出,你會怎麼來修改這段程式碼?

for (var i = 0 ; i < 10; i++) {
  setTimeout(function () {
    console.log(i)
  })
}

那讓我們先來看一看,在這段程式碼中列印變數i的最終輸出結果到底會是什麼呢?待一陣十指亂動,風一般的敲出執行程式碼的命令,只見螢幕一閃,亮出十行大字:

10
10
10
10
10
10
10
10
10
10

What?! 它輸出的居然是10個10而不是更貼近我們第一感覺的0到9,這是怎麼回事兒?又是一個什麼坑……還能不能好好的寫JavaScript了……

原因分析

其實,這個鍋也不能全由JavaScript來背,有可能是你沒有完全理解JavaScript導致的。產生這個執行結果的關鍵點就在於for語句中的var i = 0;這句變數宣告程式碼。

我們都知道,var是用來宣告變數的,並且我們通常也知道,一個語句從哪裡開始宣告就會在哪裡開始被處理。但是var是JavaScript語法中的一個例外!我們來看一下Mozilla官方文件中對var的定義:

var變數宣告,無論發生在何處,都在執行任何程式碼之前進行處理。

用var宣告的變數的作用域是它當前的執行上下文,它可以是巢狀的函式,也可以是宣告在任何函式外的變數。如果你重新宣告一個 JavaScript 變數,它將不會丟失其值。

由於上述定義的原因,var變數宣告(以及其他宣告,比如函式宣告)總是在任意程式碼執行之前處理的,所以在程式碼中的任意位置宣告變數總是等效於在程式碼開頭宣告。這意味著變數可以在宣告之前使用,這個行為叫做變數提升(Hoisting)。變數提升就像是把所有的變數宣告移動到函式或者全域性程式碼的開頭位置:

bla = 2
var bla

// 可以理解為:
var bla
bla = 2

因此對於我們這道題,變數i的宣告就相當於提前到了for語句的外面,相應的,變數i的作用域範圍也同時擴大到了for語句的外面,與以下的寫法相互等效:

var i = 0;

for (; i < 10; i++) {
  setTimeout(function () {
    console.log(i)
  })
}

另外一點,我們得明白setTimeout()的執行時機:它總是在當前的同步程式碼執行完成後開始執行。可以在前面的程式碼中加入一些log進行跟蹤並驗證這一點:

var i = 0;

for (; i < 10; i++) {
  console.log(`+++++`, i)

  setTimeout(function () {
    console.log(i)
  })
}

執行這段程式碼後的結果:

+++++ 0
+++++ 1
+++++ 2
+++++ 3
+++++ 4
+++++ 5
+++++ 6
+++++ 7
+++++ 8
+++++ 9
10
10
10
10
10
10
10
10
10
10

由此可見,當開始執行setTimeout()中的程式碼時for迴圈外面的變數i就已經變成了10,使用console.log(i)從作用域查詢到的i值就是10,因此十次setTimeout()中的程式碼就都列印出了10

解決方式

原因找到了,罪魁禍首說到底就是由於var變數的作用域特性以及作用域範圍導致的。那解決這個問題的關鍵點還是在怎麼控制變數的作用域。

方法一

要控制變數的作用域,最常見的手段,就是使用函式閉包將變數值封閉在指定的作用域內。

我們可以在setTimeout()的外面進行一層簡單的包裝來形成閉包,達到將每次迴圈時的i值封閉在閉包內部:

for (var i = 0 ; i < 10; i++) {
  (function (i) {
    setTimeout(function () {
      console.log(i)
    })
  })(i)
}

這樣的話,在setTimeout()中查詢變數i的時候,就會獲取到封入閉包並以引數形式傳入的引數i了。

方法二

除了函式閉包,我們還可以使用的解決方案,就是ES6中新引入的let變數宣告。與var不同的是,由let宣告的變數的作用域是隻在其宣告的塊或子塊中可用,所以它被稱為塊級作用域變數。

我們這道題的程式碼只要做很小的修改,只需要將var替換成let,就能如我們期望的那樣工作了:

for (let i = 0 ; i < 10; i++) {
  setTimeout(function () {
    console.log(i)
  })
}

使用了let後,變數i的作用域被限定在for語句塊以及子塊setTimeout()中,並且:

子塊中的變數值是該子塊產生時的那個值

是不是覺得let變數的作用域關係比較清晰?在現在的實際開發中,我們也更推薦使用let來替代var進行變數宣告,它會使你的程式碼更清晰更簡化,不容易出bug。


相關文章