深挖【let, for與定時器】引發的疑惑
建議您在閱覽此文之前學完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');