【機制】js的閉包、執行上下文、作用域鏈
- 2021 年 2 月 3 日
- 筆記
- javascript
1.從閉包說起
什麼是閉包
一個函數和對其周圍狀態(詞法環境)的引用捆綁在一起,這樣的組合就是閉包。
也就是說,閉包讓你可以在一個內層函數中訪問到其外層函數的作用域。
在 JavaScript 中,每當創建一個函數,閉包就會在函數創建的同時被創建出來。
上面是MDN對閉包
的解釋,這幾句話可能不太好懂,沒關係,我們先來看下能懂的:
閉包
是和函數有關- 這個函數可以訪問它外層函數的作用域
- 從定義看,每個函數都可以稱為
閉包
雖然從定義來看,所有函數都可以稱為閉包
,但是當我們在討論它的時候,一般是指這種情況:
//code-01
function cat() {
var name = "小貓";
function say() {
console.log(`my name is ${name}`);
}
return say;
}
var fun = cat();
//---cat函數已經執行完,下面卻還能夠訪問到 say函數的內部變量 name
fun();
//> my name is 小貓
當一個函數的返回值是一個內部函數時(cat函數返回say函數),在這個函數已經執行完畢後,這個返回的內部函數還可以訪問到已經執行完畢的函數的內部變量,就像 code-01
中fun可以訪問到cat函數的name,一般我們談論的閉包
就是指這種情況。
那麼這是什麼原因呢?這就涉及到函數的作用域鏈
和執行上下文
的概念了,我們下面分別來說。
2.執行上下文
定義
什麼是執行上下(Execution context )呢?簡單來說就是全局代碼或函數代碼執行的時候的環境,它包含三個部分內容:
- 1.變量對象(Variable object,vo),
- 2.作用域鏈(Scope chain,sc)
- 3.this的指向(這篇先不談)
我們用一個對象來表示:
EC = {
vo:{},
sc:[],
this
}
然後代碼或函數需要什麼變量的時候,就會在這裏面找。
創建時間
執行上下文(EC)是什麼時候創建的呢?這裡分為兩種情況:
- 全局代碼:代碼開始執行,但是還沒有執行具體代碼之前
- 函數代碼:函數要執行的時候,但是還沒值執行具體代碼之前
其實如果把全局的代碼理解為一個大的函數,這兩者就可以統一了。
每一個函數都會創建自己的執行上下文
,他們以棧的形式存儲在一起,當函數執行完畢,則把它自己的執行上下文
出棧,這就叫執行上下文棧
(Execution context stack,ECS)
下面我們通過一段代碼實例來看一下
聲明語句與變量提升
具體分析之前,我們先來說聲明語句
,什麼是聲明語句
呢?
聲明語句
是用來聲明一個變量,函數,類的語句- 比如:var,let,const,function,class
- 其中 var 和 function 會造成
變量提升
,其他不會,如果var和function同名的話,則函數聲明優先
那什麼是變量提升呢?
// code-02
console.log(varVal); // 輸出undefined
console.log(fun); // 輸出 fun(){console.log('我是函數體') },
//console.log(letVal) //報錯 letVal is not defined
var varVal = "var 聲明的變量";
let letVal = "let 聲明的變量";
function fun() {
console.log("我是函數體");
}
var fun = "function"; //與函數同名,函數優先,但是可以重新賦值
console.log(varVal); // >> "var 聲明的變量"
console.log(letVal); // >> "let 聲明的變量"
//fun(); // 報錯,因為fun被賦值為'function'字符串了
var name = "xiaoming";
在js執行代碼的時候,會先掃一遍代碼,把var,function的聲明先執行,var聲明的變量會先賦值為undefined,function聲明的函數會直接就是函數體,這就叫變量提升
,而其他的聲明,比如let,則不會。
所以在變量賦值之前,console.log(varVal)
,console.log(fun)
可以執行,而console.log(letVal)
則會報錯。
其中fun被重新聲明為’function’字符串,但是在變量提升的時候,函數優先,所以console.log(fun)
打印出來的是函數體,而代碼執行賦值語句的時候,fun被賦值成了字符串,所以fun()
會報錯
代碼執行過程分析–變量對象
我們先上一段簡單的代碼,通過這段代碼,來分析一下 執行上下文
創建和作用的過程,對其內容我們先只涉及變量對象
vo:
//code-03
var name = 'xiaoming'
function user(name){
var age = 27
console.log(`我叫${name},今年${age}`)
}
user(name)
console.log(name)
我們現在來分析一下這段代碼執行過程中,執行上下文的作用過程,會加入變量對象
vo,作用域鏈
scope會在下面講,this的指向這次不講,所以就不加上去了
1.代碼執行之前,先創建 全局的執行上下文G_EC,並壓入執行上下棧ECS
ECS = [
G_EC : {
vo:{
name:undefined,
user(name){
var age = 27
console.log(`我叫${name},今年${age}`)
},
},
sc
}
]
2.代碼開始執行,name被賦值,執行user(name)
3.函數執行的時候,具體代碼還沒執行之前,創建函數執行上下文
user_EC,並壓入ECS
ECS = [
user_EC : {
vo:{
name:undefined,
age:undefined,
},
sc
},
G_EC : {
vo:{
name:'xiaoming',
user(name){
var age = 27
console.log(`我叫${name},今年${age}`)
}
},
sc
}
]
4.開始執行函數代碼,給形參name賦值,變量age賦值,執行console.log的時候需要變量name
,age
,於是從它自己的執行上下文
user_EC中的變量對象
vo里開始查找
ECS = [
user_EC : {
vo:{
name:'xiaoming',
age:27,
},
sc
},
G_EC : {
vo:{
name:'xiaoming',
user(name){
var age = 27
console.log(`我叫${name},今年${age}`)
}
},
sc
}
]
5.發現找到了,於是打印 我叫xiaoming,今年27
,至此函數user執行完畢了,於是把其對應的執行上下文
user_EC出棧
ECS = [
G_EC : {
vo:{
name:'xiaoming',
user(name){
var age = 27
console.log(`我叫${name},今年${age}`)
}
},
sc
}
]
6.代碼繼續執行,console.log(name),發現需要變量那麼,於是從它自己的執行上下文
中的變量對象
開始查找,也就是G_EC中的vo,順利找到,於是打印”xiaoming”
7.至此代碼執行結束,但全局的執行上下文好像要等到當前頁面關閉才出棧(瀏覽器環境)
3.作用域鏈
上面我們分析代碼執行過程的時候,有說到如果要用到變量的時候,就從當前執行上下文
中的變量對象
vo里查找,我們剛好是都有找到。
那麼如果當前執行上下文
中的變量對象
中沒有需要用的變量呢?根據我們的經驗,它會從父級的作用域來查找,那麼這是根據什麼來查找的呢?
所有接下來我們繼續來看 ‘作用域鏈’(scope chain,sc),它也是執行上下文
得另一個組成部分。
** 函數作用域 **
在說執行上下
中的作用域鏈
之前,我們要先來看看函數作用域
,那麼這是個什麼東西呢?
- 每一個函數都有一個內部屬性【scope】
- 它是函數創建的時候構建的
- 它是一個列表,會把函數的所有父輩的
執行上下
中的變量對象
存在其中
舉個例子:
//code-04
function fun_1(){
function fun_2(){}
}
1.我們看上面的代碼,當fun_1函數創建的時候,它的父級執行上下文
是全局執行上下文 G_EC
,所以fun_1的函數作用域
【scope】為:
fun_1.scope = [
G_EC.vo
]
2.當fun_2函數創建的時候,它的所有父級執行上下文
有兩個,一個是全局執行上下文 G_EC
, 還有一個是函數fun_1的執行上下文 fun_1_EC
, 所以fun_2的函數作用域
【scope】為:
fun_1.scope = [
fun_1_EC.vo,
G_EC.vo
]
執行上下文的作用域鏈
上面我們說的是函數作用域
,它包含了所有父級執行上下的變量對象,但是我們發現它沒有包含函數自己的變量對象,因為這個時候函數只是聲明了,還沒有執行,而函數的執行上下文
是在函數執行的時候創建的。
當函數執行的時候,會創建函數的執行上下文
,從上面我們知道,這個時候會創建執行上下文
的變量對象
vo,而賦值執行上下文
的作用域鏈
sc的時候,會把vo加在scope前面,作為一個隊列,賦值給作用域鏈
,
就是說:EC.sc = [EC.vo,...fun.scope]
,我們下面舉例說明,這段代碼與code-03的區別只是不給函數傳參,所以會用到父級作用域的變量。
//code-05
var name = 'xiaoming'
function user(){
var age = 27
console.log(`我叫${name},今年${age}`)
}
user()
console.log(name)
1.代碼執行之前,先創建 全局的執行上下文G_EC,並壓入執行上下棧ECS,同時賦值變量對象
vo、作用域鏈
sc,注意:當函數user被聲明的時候,會帶有函數作用域
user.scope
ECS = [
G_EC : {
vo:{
name:undefined,
user // user.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
2.代碼開始執行,name被賦值,執行user()
3.函數執行的時候,具體代碼還沒執行之前,創建函數執行上下文
user_EC,並壓入ECS,同時賦值變量對象
vo和作用域鏈
sc:
ECS = [
user_EC : {
vo:{
age:undefined,
},
sc:[user_EC.vo, ...user.scope]
},
G_EC : {
vo:{
name:'xiaoming',
user // user.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
4.開始執行函數代碼,給變量age賦值,執行console.log的時候需要變量name
,age
,這裡我們上面說是從變量對象
里找,這裡更正一下,其實是從作用域鏈
中查找
ECS = [
user_EC : {
vo:{
age:27,
},
sc:[user_EC.vo, ...user.scope]
},
G_EC : {
vo:{
name:'xiaoming',
user, // user.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
5.我們發現在作用域鏈
的第一個對象中(user_EC.vo)找到了age,但是沒有name
,於是開始查找作用域鏈
的第二個對象,依次往下找,如果都沒找到,則會報錯。
這裡的話,我們發現作用域鏈
的第二個元素user.scope析構出來的,也就是G_EC.vo,這個裏面有找到name=’xiaoming’
於是打印 我叫xiaoming,今年27
,至此函數user執行完畢了,於是把其對應的執行上下文
user_EC出棧
ECS = [
G_EC : {
vo:{
name:'xiaoming',
user, // user.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
6.代碼繼續執行,console.log(name),發現需要變量那麼,於是從它自己的執行上下文
中的作用域鏈
開始查找,在第一個元素G_EC.vo就順利找到,於是打印”xiaoming”
7.至此代碼執行結束,
4.回歸到閉包的問題
到此為止我們介紹完了執行上下
文,那麼現在我們回歸到剛開始的閉包
為什麼能訪問到已經執行完畢了的函數的內部變量問題。我們再來回顧一下代碼:
//code-06
function cat() {
var name = "小貓";
function say() {
console.log(`my name is ${name}`);
}
return say;
}
var fun = cat();
fun();
我們來照上面的步驟來分析下代碼:
1.代碼執行之前,先創建 全局的執行上下文G_EC,並壓入執行上下棧ECS,同時賦值變量對象
vo、作用域鏈
sc
ECS = [
G_EC : {
vo:{
fun:undefined,
cat, // cat.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
2.代碼開始執行,執行cat()函數
3.函數執行的時候,具體代碼還沒執行之前,創建函數執行上下文
cat_EC,並壓入ECS,同時賦值變量對象
vo和作用域鏈
sc:
ECS = [
cat_EC : {
vo:{
name:undefined,
say, // say.scope:[cat_EC.vo,G_EC.vo]
},
sc:[cat_EC.vo, ...cat.scope]
},
G_EC : {
vo:{
fun:undefined,
cat, // cat.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
4.開始執行函數代碼,給變量name賦值,然後返回say函數,這個時候函數執行完畢,它的值被付給變量fun,它的執行上下文
出棧
ECS = [
G_EC : {
vo:{
fun:say, // say.scope:[cat_EC.vo,G_EC.vo]
cat // cat.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
5.代碼繼續執行,到了fun(),
6.當函數要執行,還沒執行具體代碼之前,創建函數執行上下文
fun_EC,並壓入ECS,同時賦值變量對象
vo和作用域鏈
sc:
ECS = [
fun_EC : {
vo:{},
sc:[fun_EC.vo, ...fun.scope]//fun==cat,所以fun.scope = say.scope = [cat_EC.vo,G_EC.vo]
},
G_EC : {
vo:{
fun:say, // say.scope:[cat_EC.vo,G_EC.vo]
cat // cat.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
7.函數fun開始執行具體代碼:console.log(
my name is ${name})
,發現需要變量name
,於是從他的fun_EC.sc中開始查找,第一個fun_EC.vo沒有,於是找第二個cat_EC.vo,發現這裡有name=”小貓”,
於是打印 my name is 小貓
,至此函數fun執行完畢了,於是把其對應的執行上下文
fun_EC出棧
ECS = [
G_EC : {
vo:{
fun:say, // say.scope:[cat_EC.vo,G_EC.vo]
cat // cat.scope:[G_EC.vo]
},
sc:[G_EC.vo]
}
]
8.至此代碼執行結束
到這裡我們知道閉包
為什麼可以訪問到已經執行完畢的函數的內部變量,是因為在的執行上下文
中的作用域鏈
中保存了變量的引用,而保存的引用的變量不會被垃圾回收機制所銷毀。
閉包的優缺點
優點:
- 可以創建擁有私有變量的函數,使函數具有封裝性
- 避免全局變量污染
缺點:
- 增大內存消耗
參考
1.JavaScript深入之詞法作用域和動態作用域
2.JavaScript深入之執行上下文棧
3.setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop