for循環里的定時器引發的思考


在學習js的時候,或者面試的時候,會經常碰到這一道經典題目:

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

熟悉這道題目的人立馬就可以說出答案:

'a'
5
5
5
5
5

結果是先打印字符串'a',然后再打印5個數字5。

有人會說這個題目並不難,而且只要你遇到過這個題目,下次再見到基本也不會答錯了,但其實這段簡單的代碼里面包含了很多js知識。

這里就整理總結一下。

單線程、任務隊列以及事件循環(event loop)

第一次看到這段代碼的時候,會給人一種錯覺

  1. 會先打印for循環里面的5次i值,然后才會去打印下面的字符串'a'
  2. for循環里面的打印結果會是0,1,2,3,4,而不是什么5個5這種奇怪的結果

但是實際運行結果跟我們預期的不一樣,原因就是因為這里涉及到了js的運行機制

單線程

JavaScript語言的一大特點就是單線程,也就是說,同一個時間只能做一件事

為什么不允許js可以實現多線程?因為如果實現了多線程,一個線程創建了一個div元素,而另外一個線程刪除了這個div元素,那么這個時候瀏覽器應該聽誰的?

所以為了避免出現這種互相沖突的操作,js從一開始就是單線程的,這就是它的核心特征。

任務隊列

單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務就不得不一直等着。

如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閑着的,因為IO設備(輸入輸出設備)很慢(比如Ajax操作從網絡讀取數據),不得不等着結果出來,再往下執行。

JavaScript語言的設計者意識到,這時主線程完全可以不管IO設備,掛起處於等待中的任務,先運行排在后面的任務。等到IO設備返回了結果,再回過頭,把掛起的任務繼續執行下去

於是,所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。

  1. 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
  2. 主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
  3. 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
  4. 主線程不斷重復上面的第三步。

只要主線程空了,就會去讀取"任務隊列",這就是JavaScript的運行機制。這個過程會不斷重復。

"任務隊列"中的事件,除了IO設備的事件以外,還包括一些用戶產生的事件(比如鼠標點擊、頁面滾動等等)。只要指定過回調函數,這些事件發生時就會進入"任務隊列",等待主線程讀取。

事件循環(event loop)

主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環)。

主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各種外部API,它們在"任務隊列"中加入各種事件(click,load,done)。只要棧中的代碼執行完畢,主線程就會去讀取"任務隊列",依次執行那些事件所對應的回調函數。

定時器

在了解了剛才那些知識之后,再回過頭來看看這段代碼:

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

為什么明明定時器的時間設置為了0(setTimeout不寫延遲時間參數默認值為0)?定時器卻在console.log('a')這句代碼運行了之后才運行?

原來在js的任務隊列里,除了放置異步操作之外,還會放置定時器事件。

當js代碼運行到有定時器的地方的時候,會把定時器操作放在任務隊列尾部,然后跟它說:“你先排隊吧,還沒有輪到你,因為同步代碼還沒有執行完。”

這里所說的 同步代碼 就是指下面的console.log('a')。

也就是說,js認為setTimeout是一個異步操作,必須讓它排隊,它只能在同步代碼執行結束后才能執行

所以這里的原因總結就是這樣一句話:

定時器並不是同步的,它會自動插入任務隊列,等待當前文件的所有同步代碼當前任務隊列里的已有事件全部運行完畢后才能執行。

這就是為什么字符串'a'在5個5之前就打印出來的原因。

那么為什么是5個5呢?為什么不是0,1,2,3,4?

這是因為在所有同步代碼執行完畢之后,for循環里的i值早已變成了5,循環已經結束。(注意,for循環的圓括號部分也是同步代碼

這就是為什么打印出來5個5,而不是0,1,2,3,4。

所以這段代碼真實的運行情況你可以假想成這樣,便於理解:

for(var i = 0; i < 5; i++) {
    
}
console.log('a');

setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
setTimeout(function () {
  console.log(i);
});
//先循環,i變成了5,然后打印a,然后再打印5次i
//這里只是假想,便於理解

作用域和閉包

這道題目還會引申出來另一個問題:

如果想要for循環里的定時器打印出0,1,2,3,4,而不是5個5,該怎么辦?

答案是:使用立即執行函數

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

打印結果:

'a'
0
1
2
3
4

這又是為什么?

這是因為for循環里定義的i變量其實暴露在全局作用域內,於是5個定時器里的匿名函數它們其實共享了同一個作用域里的同一個變量。

所以如果想要0,1,2,3,4的結果,就要在每次循環的時候,把當前的i值單獨存下來,怎么存下當前的循環i值??

利用閉包的原理,閉包使一個函數可以繼續訪問它定義時的作用域。而這個新生成的作用域將每一次循環的當前i值單獨保存了下來。

for(var i = 0; i < 5; i++) {
    (function(i) {//這個匿名函數生成了閉包的效果,新建了一個作用域,這個作用域接收到每次循環的i值保存了下來,即使循環結束,閉包形成的作用域也不會被銷毀
        setTimeout(function () {
            console.log(i);
        });
    })(i)
}    

let關鍵字、塊作用域以及try...catch語句

如果想實現for循環里的定時器打印出0,1,2,3,4,除了閉包,還可以使用ES6的let關鍵字

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

注意for循環定義i的時候把var換成了let,打印出的結果就是0,1,2,3,4

這是問什么呢?

因為let關鍵字劫持了for循環的塊作用域,產生了類似閉包的效果。並且在for循環中使用let來定義循環變量還會有一個特殊效果:每一次循環都會重新聲明變量i,隨后的每個循環都會使用上一個循環結束時的值來初始化這個變量i

let可以實現塊作用域的效果,但是它是ES6語法,在低版本語法的時候如何生成塊作用域?

答案是:使用try...catch語句

看下面的效果:

for(var i = 0; i < 5; i++) {
    try {
        throw(i)
    } catch(j) {
        setTimeout(function () {
            console.log(j);
        });
    }
}

//打印結果0,1,2,3,4

神奇的效果出現了!

這是因為try...catch語句的catch后面的花括號是一個塊作用域,和let的效果一樣。所以在try語句塊里拋出循環變量i,然后在catch的塊作用域里接收到傳過來的i,就可以將循環變量保存下來,實現類似閉包和let的效果。

 

好了,這就是關於這道面試題涉及到的知識。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM