0%

前言

前兩篇的 JS 概念篇,分別整理了 scope 和 closure
這篇就來整理一個 javascript 面試常見的題目

經典問題

1
2
3
4
5
6
7
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i)
}, 0)
}

// 5,5,5,5,5

第一個重點是
setTimeout 會將 console.log 函式放在暫存區
for loop 結束後一共會放五個 console.log 在那邊

第二個重點是 var
在上一篇中我們提到 var 變數的生存範圍是 function
所以i的 scope 就會在 for loop 之外
所以我們可以理解成 for loop 的每個循環
都會共用同一個i (指reference)

閉包會參照環境變數值,而不是複製其值

而最後當程式要來處理暫存區的任務時
由於 local variable 中沒有i
便往上找 function variable i
而 loop 也已經結束,i已經從 0 變成 5 了
可想而知就會印出五次 5

解法1: let

這題有很多解法
下面這個應該是最簡單的了吧

1
2
3
4
5
6
7
for (let i = 0; i < 5; i++) { // 把 var 換成 let
setTimeout(function() {
console.log(i)
}, 0)
}

// 0,1,2,3,4

當我們改用 let 時
i的生存範圍便限制在每個 loop block 當中
有幾個迴圈,i就會產生幾次
setTimeout 在 block 中找得到i,便會記住每個 loop 的i
而不會像原本一樣由於在區域變數中找不到
就往上層找函數變數
這是 ES6 之後的新解法
利用 let 生存範圍的特性

解法2: 閉包

我們可以利用閉包的特性鎖住每個i

1
2
3
4
5
6
7
8
9
10
for (var i = 0; i < 5; i++) {
const timer = (n) => { // 記得定義 parameter n
setTimeout(function() {
console.log(n)
}, 0)
}
timer(i) // 記得傳入 i
}

// 0,1,2,3,4

每次運行timer(i)時會產生一個閉包
同時每次都會產生新的參數n
用來儲存當下傳入的i
所以會得到正確的結果

解法3: IIFE

Immediately-Involked Function Expression (IIFE)
立即呼叫函式表達式
定義函式之後立刻呼叫的寫法
它具有儲存當下環境的特性
是形成閉包的作法之一
以非常白話來解釋:

讓函式立刻運行,而且不用取名

1
2
3
4
5
6
7
8
9
for (var i = 0; i < 5; i++) {
(function(value) {
setTimeout(function() {
console.log(value)
}, 0)
})(i)
}

// 0,1,2,3,4

追根究柢,這其實跟方法二幾乎一樣
只是寫法不同而已

結論

這個題目 tricky 的部分在於
原本應該要利用閉包的特性鎖住每個i
卻又因為閉包只會參照位置
而導致非同步函式事後取到錯誤的值

本篇所用的三個解法的中心思想是

不要參照外部變數

只要不要參照外部變數,讓每次迴圈都產生一個變數來用
便能避免事後才取到改變後的變數

老實說到現在還沒辦法肯定的說我清楚了
但藉由思考這個題目,對於 scope, closure 肯定會比以前更有概念
也更了解 javascript 的運作方式

前言

上一篇介紹了 scope
這篇就來介紹一個平常都在用卻又不怎麼熟悉的概念 closure

閉包 Closure

Closure 光看單字就有種關了什麼起來的感覺
沒錯,閉包一言以蔽之就是

閉包會記住函式建立時的環境

也可以當作把環境鎖起來,關在閉包當中
如此一來當同時建立很多函式時,便能讓他們互不干涉
我們先從下面的例子來觀察閉包的基本行為

1
2
3
4
5
6
7
const name = 'Tony'

const greeting = () => {
console.log(name)
}

greeting()

我們並沒有將name這個變數傳入greeting中
為什麼還是印的出來呢?
正是因為當執行 greeting 函式時
它會記住建立 greeting 函式時當下的環境(name就在其中)
所以不會找不到
這個例子非常簡單,甚至平常都不會注意到這就是閉包
來看個特殊疫點的例子,巢狀函式

1
2
3
4
5
6
7
8
9
10
11
12
const counter = (num)=>{
const show = ()=>{
console.log(num++)
}
return show
}

const add = counter(1)

add() // 1
add() // 2
add() // 3

首先定義一個 counter 函式,會回傳一個內部函式 show
接著建立一個新函式 add 傳入 1 作為初始值
每次運行 add 時
參數 num 就好像偷偷被存起來一樣
每次都記得,不會被重置
原因就是每當函式被建立時,就會產生一個閉包
之後連續運行三次 add,用的都是相同的閉包
因此變數也得以保留

非同步函式的例子

在下面的例子中
我做了兩個 timer
並連 call 兩次 start 使他們開始運轉
能看到這兩個 timer 各自成一家,自己數自己的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const timer1 = document.querySelector('.timer1')
const timer2 = document.querySelector('.timer2')

// let counter = 0 //如果在這定義 counter,會變成被所有 start 共用的變數

const start = (timer, time) => {
// 每次運行 start,這裡的環境就會被存成一個閉包
let counter = 0
const tick = () => {
counter++
timer.innerText = counter.toString()
}
setInterval(tick, time)
}

const startButton=document.querySelector('.start')
startButton.onclick = ()=>{
start(timer1, 1000)
start(timer2, 500)
}

每當 start 被呼叫時
記憶體中的某處就會空出一個位子給 counter 變數
當我又呼叫一次 start,又會有一個 counter 的位子
這些 counter 互不干涉
這就是閉包的效果

此範例的完整 code 請見 我的 codepen

結論

閉包 Closure:

  1. 是函式與建立函式當下的環境的集合
  2. 會使函式記住宣告它當下的環境
  3. 能讓函式被多次呼叫卻又互不影響

前言

接下來幾篇會著重在一些 javascript 的重要觀念
未來如果忘了哪部分就可以回來看看

作用域 Scope

Scope 其實就是字面上的意思
一個變數或函式所能作用的範圍
在作用域以外的地方,就無法存取該變數
現在就用變數的宣告來解釋 scope 吧

在我開始學程式之前
曾經有個東西風靡一時
那就是var,函數變數
在我寫這篇文章之前,原本以為var是宣告全域變數
結果仔細一查才發現並不是
連現今常用的letconst也不僅僅只是區域變數而已
只要搞清楚它們的作用域範圍就會懂了

var 的作用域

var 用來宣告函數變數 function variable
意即其作用域是在宣告他的函數內
來看個簡單的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
const printText = ()=>{
var text1 = 'Earth'
console.log(`text1: ${text1}`)

if (true){
var text2 = 'Moon'
}
console.log(`text2: ${text2}`)
}
printText()

// "text1: Earth"
// "text2: Moon"

text1 能被順利印出很正常
但 text2 也印的出來就沒那麼直觀了
這正是因為用var宣告的變數,在整個宣告它的函數內都有效
就算是在迴圈中宣告也一樣
var變數不會被限制在迴圈的 block 中
但如果在父層函數內的話就行不通囉~ 如下

1
2
3
4
5
6
7
8
9
const printText = ()=>{
const innerPrint = ()=>{
var text = 'Sun'
}
console.log(text)
}
printText()

// Throwing error

text 變數的作用域只在 innerPrint 函式內
超出這個 scope 就取不到了

話說那我們要怎麼宣告全域變數呢
只要在所有函式之外進行 var 宣告就可以了
只是要注意,不要再說「var就是宣告全域變數」囉

let 和 const 的作用域

letconst都是用來宣告「區塊變數 block variable」
他們的作用域相同,都是在區塊內
如迴圈的區塊,或if區塊
再來一個簡單的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
const printText = ()=>{
let text1 = 'Earth'
console.log(`text1: ${text1}`)

if (true){
let text2 = 'Moon'
}
console.log(`text2: ${text2}`)
}
printText()

// "text1: Earth"
// Throwing error

text2 就印不出來了
因為 text2 只能在 if statement 中生存
這就是超出作用域的例子

var VS let/const

另外補充一個varlet的不同之處是
var會將宣告上提 (hoisting)
只是不會初始化
可看看下列例子

1
2
3
4
5
6
7
8
9
const printText = ()=>{
console.log('inside function before var:', text)
var text = 'Earth'
console.log('inside function after var:', text)
}
printText()

// "inside function before var:", undefined
// "inside function after var:", "Earth"

雖然在宣告 text 前就取值
但只顯示undefined,並不會報錯
因為 text 已經被宣告了,只是沒賦值而已

反之,letconst則會有所謂的暫時執行死區(TDZ)
所以這樣寫會出錯

1
2
3
4
5
6
7
const printText = ()=>{
console.log('inside function before var:', text)
let text = 'Earth'
}
printText()

// Throwing error

有關 hoisting 就留到之後幾篇再說吧

結論

Scope: 作用域,變數或函式的作用範圍,也可以想成生存範圍

var變數的作用域在宣告時的函式內
let變數和const常數的作用域則僅限於宣告時的區塊內