建議您在閱覽此文之前學完W3school - JS Tutorial章節所有內容 經典的問題 在一些文章中或者工作面試問題上,會遇見這種看似簡單的經典問題。 for(var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); } ...
建議您在閱覽此文之前學完W3school - JS Tutorial章節所有內容
經典的問題
在一些文章中或者工作面試問題上,會遇見這種看似簡單的經典問題。
for(var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
});
}
console.log('hello word');
/*output
'hello word'
5
5
5
5
5
*/
新手第一次看到這個問題由於沒有深入瞭解setTimeout
方法的執行機制就會得到錯誤的結果。
/*output
0
1
2
3
4
'hello world'
*/
對於老鳥來說這種問題不足掛齒,但是如果你是新手正在學習js的路上如火如荼或是剛好遇到了此類問題一知半解,那麼這篇文章將帶給你視野和解答。 小小問題背後實則包含豐富有趣的學問。
認識單線程、任務隊列和事件迴圈
單線程
JS是典型的單線程語言,所謂單線程就是只能同時執行一個任務。
之所以是單線程而不是多線程,是為了避免多線程對同一DOM對象操作的衝突。比如a線程創造一div元素而b線程同時想要刪除這個div元素那麼就會出現矛盾。所以單線程是JS的核心特征。
知識延申:操作系統的進程和線程:
對於操作系統來說,一個任務就是一個進程(
Process
),比如打開一個瀏覽器就是啟動一個瀏覽器進程,打開一個記事本就啟動了一個記事本進程,打開兩個記事本就啟動了兩個記事本進程,打開一個Word就啟動了一個Word進程。
有些進程還不止同時乾一件事,比如Word,它可以同時進行打字、拼寫檢查、列印等事情。在一個進程內部,要同時乾多件事,就需要同時運行多個“子任務”,我們把進程內的這些“子任務”稱為線程(
Thread
)。
一個進程至少有一個線程,複雜的進程有多個線程。操作系統通過多核cpu快速交替執行這些線程就給人一種同時執行的感覺。
任務隊列
單線程就意味著,所有任務需要排隊,前一個任務結束,後一個任務才會執行。前面的任務耗時過長,後面的任務也得硬著頭皮等待。而任務執行慢通常不是cpu性能不行,而是I/O設備操作耗時長比如Ajax
操作從網路讀取數據)。
JS設計者意識到,遇到這種情況主線程可以完全不管I/O設備的結果,先掛起等待結果的任務,然後執行排在後面的任務。直到I/O設備返回了結果,再回過來執行先前掛起的任務。
所以,設計者把電腦的程式任務可以分為兩種,同步任務和非同步任務。同步任務:直接進入主線程執行的任務。前面的任務執行完,後面的才能執行,按順序一個接一個的執行;非同步任務:不會直接進入主線程,而是通過“任務隊列”(task queue
)通知“主線程隊列”準備就緒才會進入主線程執行。
具體來說整個機制如下:
- 所有同步任務都在主線程上執行,生成一個
執行棧
(execution context stack)。 - 主線程外單獨劃分出一個
任務隊列
(task queue)。非同步任務在同步任務執行時也不會“偷懶”,同時運行得到結果。然後非同步任務會生成一個對應的通知事件
放置於“任務隊列”。 - 待
執行棧
中的同步任務執行完畢,系統就會讀取任務隊列
中的通知事件,通知事件所對應的非同步任務就會結束等待狀態
進入執行棧
開始執行。並且通知事件
遵循先進先出原則。 - 主線程會不斷重覆以上三步。
機制流程示意圖:
執行棧
一空就會讀取任務隊列
,如此往複,這就是JS的運行機制。
事件和回調函數的關係
任務隊列
中的通知事件
包括了I/O設備事、用戶點擊、頁面滾動等等。只要指定了回調函數
(callback)這些事件就會進入任務隊列
,等待主線程讀取。
回調函數
(callback)的代碼會被任務隊列
掛起。所以需要非同步執行某個程式時就請使用回調函數
,主線程讀取任務隊列
時會先檢查通知事件
是否包含【定時器】確認執行時間之類的。
事件迴圈
主線程讀取任務隊列
事件是往複迴圈的,整個機制被稱之為事件迴圈
(event loop)。
接下來參考Philip Roberts的演講《Help, I'm stuck in an event-loop》深挖事件迴圈
從上面的圖示我們能夠看到,主線程執行時產生兩個事物,分別是堆
(heap)和棧
(stack),棧會調用各種外部的WebAPI
,它們在"任務隊列"中加入各種事件(click,load,done)。只要棧中的代碼執行完畢,主線程就會去讀取任務隊列
,依次執行那些事件所對應的回調函數。
定時器[setTimeout]
回過頭來看文章開頭那段代碼
for(var i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
});
}
console.log('hello word');
從前面【事件迴圈】小節我們知道了,setTimeout
屬於非同步任務,它會生成一個事件(對應指定的回調函數
)放進任務隊列
掛起,直到棧
中的同步任務都執行完畢後,系統讀取任務隊列
拿到通知事件
對應的回調函數
再放進棧
執行並返回結果。
所以實質上可以看作(取巧方便理解,非實質):
// 同步執行
for(var i = 0; i < 5; i++) {
}
// 同步執行
console.log('hello word');
// 非同步執行
console.log(i);
console.log(i);
console.log(i);
console.log(i);
console.log(i);
作用域 + 閉包
作用域
簡單的說就是js程式當前執行的語境,或者值和表達式可訪問和引用的語境。對象在這個語境中才能才能訪問和引用這個語境中的其他對象。子作用域的對象可以訪問和引用父作用域中的對象,反之不行。
特殊的是一個函數對象在JS中被創建的時候同時創建了閉包,閉包
是由該函數對象和它所在的語境而構成的一個組合。通常返回一個函數的引用。
// 一個典型的閉包
function makeFunc() {
var text = "hello world";
function displayName() {
console.log(text);
}
return displayName;
}
var myFunc = makeFunc();
myFunc();
回過頭來看文章開頭那段代碼,我們就可以利用閉包的原理讓定時器列印出0, 1, 2, 3, 4
。
for(var i = 0; i < 5; i++) {
((i) => {
setTimeout(function () {
console.log(i);
});
})(i);
}
console.log('hello word');
在上面的代碼中,使用了一個技巧 立即函數 給計時器單獨提供了一個新的作用域
,加上裡面的計時器就剛好組成了一個非同步的閉包
組合,而且是立刻調用的。
通過上面的手段就可以很好的避免var
聲明的迴圈變數
暴露在全局作用域帶來的問題。從而列印出0, 1, 2, 3, 4
。
另外通過let
聲明迴圈變數
也是很好的解決手段,let
允許你聲明一個被限制在塊作用域中的變數、語句或者表達式,這個就是塊級作用域
。
for (let i = 0; i < 5; i++) {
setTimeout(function () {
console.log(i);
});
}
console.log('hello word');
let
是ES6語法,而塊級作用域
的出現解決了var
迴圈變數泄露為全局變數的問題和變數覆蓋的問題。
回到上面的代碼,著重說下let
是如何做到每次迴圈能夠記憶當前i
的值並傳給下次迴圈的:
- 首先,在
for
迴圈中,設置迴圈變數的括弧實質上是一個父作用域,而迴圈體是子作用域。 let
聲明瞭該父作用域是塊級作用域
而不是全局作用域
,每次迴圈i
的值只對當前迴圈的塊級作用域
有效,就像是塊級作用域
是一支捕蟲網,捕獲迴圈更新的i
值。迴圈一次就會更新塊級作用域
以及變數i
,好比拿新的捕蟲網來捕獲新i
。- 說白了,每次迴圈變數
i
會重新聲明初始化i
。實質上是JS引擎內部會記憶上一輪迴圈的值,初始化本輪的變數i
時,就在上一輪迴圈的基礎上進行計算。 - 迴圈體內部函數會先訪問本身的
塊級作用域
,沒有i
就繼續向上查詢迴圈體作用域,沒有i
向上查詢父作用域拿到當前迴圈記憶(捕獲)的i
值最後列印出來。 - 細心的朋友其實已經發現了,迴圈體內部函數 + 往上查詢的
塊級作用域
語境剛好組成了類似閉包的組合。
對於不能相容ES6的瀏覽器,我們也可以使用ES5try...catch...
語句,形成類似閉包
的效果。
for(var i = 0; i < 5; i++) {
try {
throw(i)
} catch(j) {
setTimeout(function () {
console.log(j);
});
}
}
console.log('hello word');
參考引用:
JavaScript 運行機制詳解:再談Event Loop
阮一峰ES6文檔-let 和 const 命令