聲明提升和閉包
提升
JavaScript 是一門解釋型語言,原則上是不需要編譯的。但是它在代碼執(zhí)行之前會有一個編譯的流程,這個流程發(fā)生在代碼執(zhí)行的前一刻。
示例:
console.log(a) // undefined
var a = 2
console.log(a) // 2
在這段代碼中,按照常規(guī)邏輯,引擎通過作用域沒有找到,可能會拋出一個錯誤 ReferenceError: a is not defined,但是這種情況并沒有發(fā)生。
實際上,在代碼運行的前一刻,會執(zhí)行預(yù)編譯操作。當(dāng)遇到 var a = 2 這樣的語句時,會被拆分成兩部分,var a(變量聲明)+ a = 2(變量賦值)。其中,變量聲明會發(fā)生在預(yù)編譯階段,把 a 這個變量放到全局作用域中,沒有賦值,則為 undefined。
當(dāng)執(zhí)行第一個打印操作的時候,已經(jīng)完成了預(yù)編譯相關(guān)的處理,所以可以訪問a,得到undefined;然后開始執(zhí)行后面的賦值操作;等到第二個打印操作的時候已經(jīng)可以正常訪問 a 的值了。所以上述代碼也可以像下面這樣理解:
var a
console.log(a)
a = 2
console.log(2)
變量聲明提升,函數(shù)聲明整體提升
foo() // foo
bar() // TypeError: bar is not a function(此時bar為undefined)
function foo() {
console.log(foo.name)
}
var bar = function () {
console.log(bar.name)
}
變量聲明提升,var 聲明的變量在預(yù)編譯的時候會被提升到當(dāng)前執(zhí)行環(huán)境的頂部;
函數(shù)聲明整體提升,以函數(shù)聲明聲明函數(shù)的函數(shù),會被整體提升到當(dāng)前執(zhí)行環(huán)境的頂部,所以執(zhí)行語句可以寫在函數(shù)聲明前面。函數(shù)表達式不可以,因為函數(shù)表達式走的是變量聲明提升的規(guī)則。
同一個變量既賦值給了變量,又作為函數(shù)聲明的標(biāo)識符
console.log(foo) // function
var foo = 1
console.log(foo) // 1
function foo() {
console.log(foo.name)
}
console.log(foo)
/**
* 這里常規(guī)思路是 function,其實還是 1
* 因為函數(shù)聲明這段代碼已經(jīng)被整體提升到了當(dāng)前執(zhí)行環(huán)境的頂部,已經(jīng)在前面執(zhí)行過了
*/
閉包
一個閉包的基本示例:
function foo() {
let a = 3
let tempFunc = function () {
return a
}
return tempFunc
}
// 通過 bar 標(biāo)識符引用了 foo 內(nèi)部的函數(shù) tempFunc
let bar = foo()
console.log(bar())
在這里,函數(shù) bar 的詞法作用域能夠訪問 foo 的內(nèi)部作用域,這讓foo的內(nèi)部函數(shù)能夠在自己的詞法作用域外執(zhí)行,但是依然能夠訪問自身詞法作用域的變量。
在foo()執(zhí)行后, 通常會期待foo()的整個內(nèi)部作用域都被銷毀, 因為我們知道引擎有垃圾回收器用來釋放不再使用的內(nèi)存空間。 由于看上去foo()的內(nèi)容不會再被使用, 所以很自然地會考慮對其進行回收。
而閉包的“ 神奇”之處正是可以阻止這件事情的發(fā)生。 事實上內(nèi)部作用域依然存在, 因此沒有被回收。誰在使用這個內(nèi)部作用域?原來是bar()本身在使用。
拜bar()所聲明的位置所賜, 它擁有涵蓋foo()內(nèi)部作用域的閉包, 使得該作用域能夠一直存活,以供bar()在之后任何時間進行引用。
bar()依然持有對該作用域的引用,而這個引用就叫作閉包。
這個函數(shù)在定義時的詞法作用域以外的地方被調(diào)用。 閉包使得函數(shù)可以繼續(xù)訪問定義時的詞法作用域。
基本示例的變種:
let bar
function foo() {
let a = 2
let tempFunc = function () {
return a
}
bar = tempFunc
}
foo()
console.log(bar())
無論通過何種手段將內(nèi)部函數(shù)傳遞到所在的詞法作用域以外, 它都會持有對原始定義作用域的引用,無論在何處執(zhí)行這個函數(shù)都會使用閉包。
循環(huán)中的閉包
常見考題:
for (var i = 1; i <= 5; i ++) {
setTimeout(function () {
console.log(i)
}, i*1000)
}
// 輸出什么?
正常情況下,我們對這段代碼行為的預(yù)期是分別輸出數(shù)字1~5,每秒一次,每次一個。
但實際上,這段代碼在運行時會以每秒一次的頻率輸出五次6。
首先解釋6是從哪里來的。 這個循環(huán)的終止條件是i不再<=5。條件首次成立時i的值是6。因此,輸出顯示的是循環(huán)結(jié)束時i的最終值。
仔細想一下, 這好像又是顯而易見的, 延遲函數(shù)的回調(diào)會在循環(huán)結(jié)束時才執(zhí)行。 事實上,當(dāng)定時器運行時即使每個迭代中執(zhí)行的是setTimeout(.., 0),所有的回調(diào)函數(shù)依然是在循環(huán)結(jié)束后才會被執(zhí)行,因此會每次輸出一個6出來。
所以,可以把上面的代碼,轉(zhuǎn)換成下面的形式:
// 這里的 i 的作用域是在全局,不是預(yù)期的只有循環(huán)才能訪問的
for (var i = 1; i <= 5; i ++) {}
setTimeout(function () {
console.log(i)
}, 1*1000)
setTimeout(function () {
console.log(i)
}, 2*1000)
setTimeout(function () {
console.log(i)
}, 3*1000)
setTimeout(function () {
console.log(i)
}, 4*1000)
setTimeout(function () {
console.log(i)
}, 5*1000)
/**
* 這里的循環(huán)是同步的,而定時器里面的方法是異步調(diào)用的
* 當(dāng)回調(diào)方法執(zhí)行的時候,i 已經(jīng)變成 6 了
*/
1、使用IIFE函數(shù)改造使其符合預(yù)期
for (var i = 1; i <= 5; i ++) {
(function (j) {
setTimeout(function () {
console.log(j)
}, j*1000)
}(i))
}
/**
* 這里通過立即執(zhí)行函數(shù)給每次迭代都生成了一個新的作用域
* 通過內(nèi)部聲明的變量j,把外部的i通過j傳入內(nèi)部作用域
*/
2、通過塊級作用域使其符合預(yù)期
for (let i = 1; i <= 5; i ++) {
setTimeout(function () {
console.log(i)
}, i*1000)
}
此時,變量i在循環(huán)過程中不止被聲明一次,每次迭代都會聲明。 隨后的每個迭代都會使用上一個迭代結(jié)束時的值來初始化這個變量。
一句話總結(jié)閉包:當(dāng)函數(shù)可以記住并訪問所在的詞法作用域, 即使函數(shù)是在當(dāng)前詞法作用域之外執(zhí)行, 這時就產(chǎn)生了閉包。