0%

迴圈之變數作用域

很多技術筆記說明 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)
}
// 結果都是
// 0
// 1
// 2
// 3
// 4

一秒後印出所有結果

  • 使用 JS內建函式 setTimeout(),卻讓執行結果改變。
  1. 使用 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)
}
// 5
// 5
// 5
// 5
// 5
  1. 使用 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)
}
// 0
// 1
// 2
// 3
// 4

上述的例子,有說到 setTimeout() 會進入事件佇列,接著因變數宣告方式不同會改變結果,這跟接下來的說明有關。

Callback Function 與執行緒

函式的參數不僅可以放單一值、陣列以及物件,也可以放函式,而 JS的執行方式會影響函式的運作時機。

Callback Function

  • 某個函式做為另一個函式的參數值。
  • 例如 setTimeout()
    • setTimeout( 某函式, 等待多久後印出的時間 )
    • setTimeout( function(), 1000 )
    • 若依賴其他函式過深,也就是重複這種型態,就會變成所謂的波動拳。
    • 衍伸出 Promise 的概念。
1
2
3
4
5
// setTimeout(),1秒後印出該函式的結果
setTimeout(()=>{
console.log('Callback Function')
}, 1000)
// "Callback Function"

執行緒

  • JS執行方式為單線程
  • 執行過程
    • 由上到下執行。
    • 執行函式時,會先放進執行堆疊,直到 return結果後離開(以下範例用 console.log() 代替)。
    • 執行事件時,會等待被觸發,例如點擊,計時器之類的事件,它們會暫時在瀏覽器中等待。
    • 當事件被觸發時, JS會將它要執行的函式放進事件佇列,等執行堆疊的函式處理完畢後,再處理佇列內的函式。
    • 為什麼會有事件?
      • setTimeout(),可以寫成 window.setTimeout(),也就是說,事件是瀏覽器的 API給 JS使用的。
    • 程式碼的事件 > 等待觸發 > 觸發 > 事件佇列

測試範例

  • 測試一
1
2
3
4
5
6
7
8
9
10
11
// 由上到下執行,函式執行後進入執行堆疊,產生結果後離開
// 預期結果為 "我是函式 A",接著產生 "我是函式 B"
const fnA = () => {
console.log('我是函式 A')
}
const fnB = () => {
console.log('我是函式 B')
}
fnA()
fnB()
// 結果與預期相同
  • 測試二
1
2
3
4
5
6
7
8
9
10
11
// 在另一個函式內執行函式,產生結果後離開
// 預期結果為 "我是函式 A",接著產生 "我是函式 B"
const fnA = () => {
console.log('我是函式 A')
fnB()
}
const fnB = () => {
console.log('我是函式 B')
}
fnA()
// 結果與預期相同
  • 測試三
1
2
3
4
5
6
7
8
9
10
11
// 在另一個函式內執行函式,產生結果後離開
// 預期結果為 "我是函式 B",接著產生 "我是函式 A"
const fnA = () => {
fnB()
console.log('我是函式 A')
}
const fnB = () => {
console.log('我是函式 B')
}
fnA()
// 結果與預期相同
  • 測試四
1
2
3
4
5
6
7
8
9
10
11
// 在函式內執行事件 setTimeout() 
// 預期結果為 "我是函式 A",接著 3秒後產生 "我是函式 B"
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
// 預期結果為 "我是函式 B",接著 3秒後產生 "我是函式 A"
const fnA = () => {
setTimeout(fnB, 3000)
console.log('我是函式 A')
}
const fnB = () => {
console.log('我是函式 B')
}
fnA()
// 與預期結果不同 !!!
// "我是函式 A"
// "我是函式 B" <- 3秒後產生

回想

以下面兩個例子來說,它包含了 :

  • var的作用域
    • 用 function區隔會有不同結果,可以做到與 let相同的事。
  • 執行緒
    • 函式的執行堆疊與事件觸發之事件佇列(等待)。
  • Callback Function
    • setTimeout() 中執行另一個函式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 變數 z已被 function區隔,因此全域環境中是找不到 z這個變數的,
// 但它還是會存在於函式 fnA內,
// 秒數的 z值屬於 setTimeout 這個函式,也因此被區隔,
// 進入事件佇列後,console.log(z) 執行的是函式 fnA的 z值,也就是 5。
const fnA = (y) => {
for (var z = 0 ; z < y ; z++) {
setTimeout(()=>{
console.log(z)
}, 1000*z)
}
console.log('我是函式 A,接著執行函式 B')
}
fnA(5)
// 5
// 5
// 5
// 5
// 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 變數 z的值,隨著函式 fnB進入了執行堆疊,
// 執行堆疊內,函式執行了事件,等待事件觸發後,z值一起進入了事件佇列中,
// z值待在 fnB的區塊中,此時 console.log(x) 執行的是函式 fnB所帶來的值。
// 閉包的概念
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)
// 0
// 1
// 2
// 3
// 4

延伸概念

  • 閉包
  • Promise
  • 同步與非同步

參考來源

  1. Kuro Hsu - 重新認識 JavaScript: Day 18 Callback Function 與 IIFE
  2. Kuro Hsu - 重新認識 JavaScript: Day 19 閉包 Closure
  3. PJCHENder那些沒告訴你的小細節 - [筆記] 理解 JavaScript 中的事件循環、堆疊、佇列和併發模式(Learn event loop, stack, queue, and concurrency mode of JavaScript in depth)