很多技術筆記說明 JS變數的作用域時,都會使用迴圈與一個 JS內建函式來說明作用域的不同之處,但為什麼使用了 setTimeout()
這個語法,可以讓結果不同?
主要是因為以下的運作方式與觀念,會讓結果不同 :
- 變數宣告影響作用域
- 執行緒
- Callback Function
迴圈與變數作用域
在以下的例子中,是最常見的 for迴圈,使用不同的變數宣告方式並印出結果,而迴圈宣告的變數會建立在不同地方,假設宣告變數在在全域環境中,那麼
- 使用 var宣告的變數,被建立在全域環境底下。
- 使用 let宣告的變數,被建立在獨立的區塊中,也就是該函式的執行環境。
直接印出迴圈結果
1 2 3 4 5 6 7 8 9 10 11 12 13
| for (var i = 0 ; i < 5 ; i++) { console.log(i) }
for (let y = 0 ; y < 5 ; y++) { console.log(y) }
|
一秒後印出所有結果
- 使用 JS內建函式
setTimeout()
,卻讓執行結果改變。
- 使用 var宣告變數
- var的最小作用域為 function
- 在全域環境下,for迴圈每跑一次,事件佇列就會多一個
setTimeout()
,當 for迴圈跑完換事件佇列內的 setTimeout()
執行時,i 已經改變成 5 了,所以會有五個 5。
- 但是,為什麼結果是 5 不是 4 ?
- i 已經在全域環境下建立了,也因此執行第一次迴圈時,i++的關係,i 就變成了 1,總共要執行五次,所以 i 為 5。
1 2 3 4 5 6 7 8 9 10
| for (var i = 0 ; i<5 ; i++) { setTimeout(()=>{ console.log(i) }, 1000) }
|
- 使用 let宣告變數
- let的最小作用域為
{ }
,例如 for(){}
、if(){}
- 每當進入事件佇列時,當下的 y 值會跟著進去
1 2 3 4 5 6 7 8 9 10
| for (let y = 0 ; y<5 ; y++) { setTimeout(()=>{ console.log(y) }, 1000) }
|
上述的例子,有說到 setTimeout()
會進入事件佇列,接著因變數宣告方式不同會改變結果,這跟接下來的說明有關。
Callback Function 與執行緒
函式的參數不僅可以放單一值、陣列以及物件,也可以放函式,而 JS的執行方式會影響函式的運作時機。
Callback Function
- 某個函式做為另一個函式的參數值。
- 例如
setTimeout()
setTimeout( 某函式, 等待多久後印出的時間 )
setTimeout( function(), 1000 )
- 若依賴其他函式過深,也就是重複這種型態,就會變成所謂的波動拳。
- 衍伸出 Promise 的概念。
1 2 3 4 5
| setTimeout(()=>{ console.log('Callback Function') }, 1000)
|
執行緒
- JS執行方式為單線程
- 執行過程
- 由上到下執行。
- 執行函式時,會先放進執行堆疊,直到 return結果後離開(以下範例用
console.log()
代替)。
- 執行事件時,會等待被觸發,例如點擊,計時器之類的事件,它們會暫時在瀏覽器中等待。
- 當事件被觸發時, JS會將它要執行的函式放進事件佇列,等執行堆疊的函式處理完畢後,再處理佇列內的函式。
- 為什麼會有事件?
setTimeout()
,可以寫成 window.setTimeout()
,也就是說,事件是瀏覽器的 API給 JS使用的。
- 程式碼的事件 > 等待觸發 > 觸發 > 事件佇列
測試範例
1 2 3 4 5 6 7 8 9 10 11
|
const fnA = () => { console.log('我是函式 A') } const fnB = () => { console.log('我是函式 B') } fnA() fnB()
|
1 2 3 4 5 6 7 8 9 10 11
|
const fnA = () => { console.log('我是函式 A') fnB() } const fnB = () => { console.log('我是函式 B') } fnA()
|
1 2 3 4 5 6 7 8 9 10 11
|
const fnA = () => { fnB() console.log('我是函式 A') } const fnB = () => { console.log('我是函式 B') } fnA()
|
1 2 3 4 5 6 7 8 9 10 11
|
const fnA = () => { console.log('我是函式 A') setTimeout(fnB, 3000) } const fnB = () => { console.log('我是函式 B') } fnA()
|
- 測試五
- JS的執行方式若遇到事件時,會等待事件觸發。
- 直到執行堆疊處理完畢後,看事件是否觸發,若觸發則放入事件佇列中並執行事件內的函式。
- 也就是說,無論
setTimeout()
秒數設為 0秒還是3秒,都會先等待觸發並往下處理執行堆疊內的函式。
1 2 3 4 5 6 7 8 9 10 11 12
| const fnA = () => { setTimeout(fnB, 3000) console.log('我是函式 A') } const fnB = () => { console.log('我是函式 B') } fnA()
|
回想
以下面兩個例子來說,它包含了 :
- var的作用域
- 用 function區隔會有不同結果,可以做到與 let相同的事。
- 執行緒
- Callback Function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
const fnA = (y) => { for (var z = 0 ; z < y ; z++) { setTimeout(()=>{ console.log(z) }, 1000*z) } console.log('我是函式 A,接著執行函式 B') } fnA(5)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
const fnA = (y) => { for (var z = 0 ; z < y ; z++) { fnB(z) } console.log('我是函式 A,接著執行函式 B') } const fnB = (x) => { setTimeout(()=>{ console.log(x) }, 1000*x) } fnA(5)
|
延伸概念
參考來源
- Kuro Hsu - 重新認識 JavaScript: Day 18 Callback Function 與 IIFE
- Kuro Hsu - 重新認識 JavaScript: Day 19 閉包 Closure
- PJCHENder那些沒告訴你的小細節 - [筆記] 理解 JavaScript 中的事件循環、堆疊、佇列和併發模式(Learn event loop, stack, queue, and concurrency mode of JavaScript in depth)