【機制】js的閉包、執行上下文、作用域鏈
阿新 • • 發佈:2021-02-03
###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. 可以建立擁有私有變數的函式,使函式具有封裝性
2. 避免全域性變數汙染
缺點:
1. 增大記憶體消耗
**參考**
1.[JavaScript深入之詞法作用域和動態作用域](https://github.com/mqyqingfeng/Blog/issues/3)
2.[JavaScript深入之執行上下文棧](https://github.com/mqyqingfeng/Blog/issues/4)
3.[setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop](https://www.cnblogs.com/dennisj/p/1255099