導讀
本片文章,在前人的基礎上,加上自己的理解,解釋一下JavaScript的程式碼執行過程,順道介紹一下執行環境和閉包的相關概念。
分為兩部分。第一部分是瞭解執行環境的相關概念,第二部分是通過實際程式碼瞭解具體執行過程中執行環境的切換。
執行環境
執行環境的分類
- 1.全域性執行環境 是JS程式碼開始執行時的預設環境(瀏覽器中為window物件)。全域性執行環境的變數物件始終都是作用域鏈中的最後一個物件。
- 2.函式執行環境 當某個函式被呼叫時,會先建立一個執行環境及相應的作用域鏈。然後使用arguments和其他命名引數的值來初始化執行環境的變數物件。
- 3.使用eval()執行程式碼
沒有塊級作用域(本文不涉及ES6中
let
等概念)
執行上下文(執行環境)的組成
執行環境(execution context,EC)或稱之為執行上下文,是JS中一個極為重要的概念。當JavaScript程式碼執行時,會進入不同的執行上下文,而每個執行上下文的組成,基本如下:
- 變數物件(Variable object,VO): 變數物件,即包含變數的物件,除了我們無法訪問它外,和普通物件沒什麼區別
- [[Scope]]屬性:陣列。作用域鏈是一個由變數物件組成的帶頭結點的單向連結串列,其主要作用就是用來進行變數查詢。而[[Scope]]屬性是一個指向這個連結串列頭節點的指標。
- this: 指向一個環境物件,注意是一個物件,而且是一個普通物件,而不是一個執行環境。
若干執行上下文會構成一個執行上下文棧(Execution context stack,ECS)。而所謂的執行上下文棧,舉個例子,比如下面的程式碼
var a = "global var";
function foo(){
console.log(a);
}
function outerFunc(){
var b = "var in outerFunc";
console.log(b);
function innerFunc(){
var c = "var in innerFunc";
console.log(c);
foo();
}
innerFunc();
}
outerFunc()
複製程式碼
程式碼首先進入Global Execution Context,然後依次進入outerFunc,innerFunc和foo的執行上下文,執行上下文棧就可以表示為:
執行全域性程式碼時,會產生一個執行上下文環境,每次呼叫函式都又會產生執行上下文環境。當函式呼叫完成時,這個上下文環境以及其中的資料都會被消除,再重新回到全域性上下文環境。處於活動狀態的執行上下文環境只有一個。
產生執行上下文的兩個階段
當一段JS程式碼執行的時候,JS直譯器會通過兩個階段去產生一個EC
- 建立階段(當函式被呼叫,但是開始執行函式內部程式碼之前)
- 建立變數物件VO
- 設定[[Scope]]屬性的值
- 設定this的值
- 啟用/程式碼執行階段
- 初始化變數物件,即設定變數的值、函式的引用,然後解釋/執行程式碼。
建立變數物件VO過程
- 1.根據函式的引數,建立並初始化arguments object
- 2.掃描函式內部程式碼,查詢函式宣告(function declaration)
- 對於所有找到的函式宣告,將函式名和函式引用存入VO中
- 如果VO中已經有同名函式,那麼就進行覆蓋
- 3.掃描函式內部程式碼,查詢變數宣告(Variable declaration)
- 對於所有找到的變數宣告(通過var宣告),將變數名存入VO中,並初始化為undefined
- 如果變數名跟已經宣告的形參或函式相同,則什麼也不做
注:步驟2和3也稱為宣告提升(declaration hoisting)
通過一段程式碼來了解JavaScript程式碼的執行
我們舉例說明,假如我們有一個js檔案,內容如下:
var global_var1 = 10;
function global_function1(parameter_a){
var local_var1 = 10 ;
return local_var1 + parameter_a + global_var1;
}
var global_sum = global_function1(10);
alert(global_sum);
複製程式碼
下面我們來一步一步說明直譯器是如何執行這段程式碼的:
1.建立全域性上下文
首先,在直譯器眼中,global_var1
、global_sum
叫做全域性變數,因為它們不屬於任何函式。local_var1
叫做區域性變數,因為它定義在函式global_function1
內部。global_function1叫做全域性函式,因為它沒有定義在任何函式內部。
然後,直譯器開始掃描這段程式碼,為執行這段程式碼做了一些準備工作——建立了一個全域性上下文。
全域性上下文,可以把它看成一個JavaScript物件,姑且稱之為global_context
。這個物件是直譯器建立的,當然也是由直譯器使用。(我們的JavaScript程式碼是接觸不到這個物件的)
global_context物件大概是這個樣子的:
global_context = {
Variable_Object :{......},
Scope :[......],
this :{......}
}
複製程式碼
可以看到,global_context有三個屬性
-
Variable_Object(以下簡稱VO) { global_var1:undefined global_function1:函式 global_function1的地址 global_sum:undefined }
直譯器在VO中記錄了變數全域性變數
global_var1
、global_sum
,但它們的值現在是undefined
的,還記錄了全域性函式global_function1
,但是沒有記錄區域性變數local_var1
。VO的原型是Object.prototype
。 -
Scope陣列中的內容如下:
[ global_context.Variable_Object ] 複製程式碼
我們看到,Scope陣列中只有一個物件,就是前面剛建立的物件VO。
-
this
this的值現在是undefined
global_context物件被直譯器壓入一個棧中,不妨叫這個棧為context_stack。現在的context_stack是這樣的:
建立出global_context後,直譯器又偷偷摸摸幹了一件事,它給global_function1設定了一個內部屬性,也叫scope,它的值就是global_context中的scope!也就是說,現在:
global_function1.scope === [ global_context.Variable_Object ];
複製程式碼
我們獲取不到global_function1的scope屬性的,只有直譯器自己能獲取到。
2.逐行執行程式碼
直譯器在建立了全域性上下文後,就開始執行這段程式碼了。
第一句:
var global_var1 = 10;
複製程式碼
直譯器會把VO中的global_var1屬性的值設為10。現在global_context物件變成了這樣:
global_context = {
Variable_Object :{
global_var1:10,
global_function1:函式 global_function1的地址,
global_sum:undefined
},
Scope :[ global_context.Variable_Object ],
this :undefined
}
複製程式碼
第二句:
直譯器繼續執行我們的程式碼,它碰到了宣告式函式global_function1,由於在建立global_context物件時,它就已經記錄好了該函式,所以現在它什麼也不用做。
第三句:
var global_sum = global_function1(10);
複製程式碼
直譯器看到,我們在這裡呼叫了函式global_function1(
直譯器已經提前在global_context
的VO中記錄下了global_function1
,所以它知道我們這裡是一個函式呼叫),並且傳入了一個引數10
,函式的返回結果賦值給了全域性變數global_sum
。
直譯器並沒有立即執行函式中的程式碼,因為它要為函式global_function1建立一個專門的context,我們叫它執行上下文
(execute_context)吧,因為每當直譯器要執行一個函式時,都會建立一個類似的context。
execute_context
也是一個物件,並且與global_context
還很像,下面是它裡面的內容:
execute_context = {
Variable_Object :{
parameter_a:10,
local_var1:undefined,
arguments:[10]
},
Scope :[execute_context.Variable_Object, global_context.Variable_Object ],
this :undefined
}
複製程式碼
我們看到,execute_context與global_context相比,有以下幾點變化:
- VO
- 首先記錄了函式的形式引數parameter_a,並且給它賦值10,這個10就是我們呼叫函式時傳遞進去的。
- 然後記錄了函式體內的區域性變數local_var1,它的值還是undefined。
- 然後是一個arguments屬性,它的值是一個陣列,裡面只有一個10。
你可能疑惑,不是已經在parameter_a中記錄了引數10了嗎,為什麼直譯器還要搞一個arguments,再來記錄一遍呢?原因是如果我們這樣呼叫函式:
global_function1(10,20,30);
複製程式碼
在JavaScript中是不違法的。此時VO中的arguments會變成這樣:
arguments:[10,20,30]
複製程式碼
parameter_a的值還是10。可見,arguments是專門記錄我們傳進去的所有引數的。
- Scope
Scope屬性仍然是一個陣列,只不過裡面的元素多了個execute_context.Variable_Object,並且排在了global_context.Variable_Object前面。
直譯器是根據什麼規則決定Scope中的內容的呢?答案非常簡單:
execute_context.Scope = execute_context.Variable_Object + global_function1.scope。
複製程式碼
也就是說,每當要執行一個函式時,直譯器都會將執行上下文(execute_context)中Scope陣列的第一個元素設為該執行上下文(execute_context)的VO物件,然後取出函式建立時儲存在函式中的scope屬性(本文中則是global_function1.scope),將其新增到執行上下文(execute_context)Scope陣列的後面。
我們知道,global_function1是在global_context下建立的,建立的時候,它的scope屬性被設定成了global_context的Scope,裡面只有一個global_context.Variable_Object,於是這個物件被新增到execute_context.Scope陣列中execute_context.Variable_Object物件後面。
任何一個函式在建立時,直譯器都會把它所在的執行上下文或者全域性上下文的Scope屬性對應的陣列設定給函式的scope屬性,這個屬性是函式“與生俱來”的。
- this this的值此時仍然是undefined的(但不同的直譯器可能有不同的賦值)
直譯器為函式global_function1建立好了execute_context(執行上下文)後,會把這個上下文物件壓入context_stack中,所以,現在的context_stack是這樣的:
準備執行函式內的程式碼
做好了準備工作,直譯器開始執行函式裡面的程式碼了,此時我們稱函式是在執行上下文中執行的。
第一句
var local_var1 = 10 ;
複製程式碼
它的處理辦法很簡單,將execute_context的VO中的local_var1賦值為10。這一點與在global_context下執行的變數賦值語句的處理一樣。此時的execute_context變成這樣:
execute_context = {
Variable_Object :{
parameter_a:10,
local_var1:10, //為local_var1賦值10
arguments:[10]
},
Scope :[execute_context.Variable_Object, global_context.Variable_Object ],
this :undefined
}
複製程式碼
第二句
return local_var1 + parameter_a + global_var1;
複製程式碼
- 直譯器進一步考察語句,發現這是一個返回語句,於是它開始計算return 後面的表示式的值。
- 在表示式中它首先碰到了變數
local_var1
,它首先在execute_context
的Scope中依次查詢,在第一個元素execute_context
的VO發現了local_var1
,並且知道它的值是10 - 然後直譯器繼續前進,碰到了變數
parameter_a
,它如法炮製,在execute_context
的VO中發現了parameter_a
,並且確定它的值是10。 - 接著發現
global_var1
,直譯器從execute_context
的Scope第一個元素execute_context.VO中查詢,沒有發現global_var1
。繼續檢視Scope陣列的第二個元素,即global_context.VO
,發現並且確定了它的值為10。 - 於是,直譯器將三個變數值相加得到了30,然後就返回了。
- 此時,直譯器知道函式已經執行完了,那麼它為這個函式建立的執行上下文也沒有用了,於是,它將execute_context從context_stack中彈出,由於沒有其他物件引用著execute_context,直譯器就把它銷燬了。現在context_stack中又只剩下了global_context。
第三句
var global_sum = 30;
複製程式碼
現在直譯器又回到全域性上下文中執行程式碼了,這時它要把30賦值給sum,方法就是更改global_context
中的VO物件的global_sum
屬性的值。
第四句
alert(global_sum);
複製程式碼
直譯器繼續前進,碰到了語句alert(global_sum);很簡單,就是發出一個彈窗,彈窗的內容就是global_sum的值30,當我們點選彈窗上的確定按鈕後,直譯器知道,這段程式碼終於執行完了,它會打掃戰場,把global_context,context_stack等資源全部銷燬。
再遇閉包
現在,知道了上下文,函式的scope屬性的知識後,我們就可以開始學習閉包了。讓我們將上面的js程式碼改成這樣:
var global_var1 = 10;
function global_function1(parameter_a){
var local_var1 = 10 ;
function local_function1(parameter_b){
return parameter_b + local_var1 + parameter_a + global_var1;
}
return local_function1 ;
}
var global_sum = global_function1(10);
alert(global_sum(10));
複製程式碼
這段程式碼與原先的程式碼最大的不同是,在global_function1
內部,我們建立了一個函式local_function1
,並且將它作為返回值。
當直譯器執行函式global_function1
時,仍然會為它建立執行上下文,只不過此時execute_context.VO
中多了一個函式屬性local_function1
。然後,直譯器就會開始執行global_function1
中的程式碼。
我們直接從建立local_function1
語句開始分析,看直譯器是怎麼執行的,閉包的所有祕密就隱藏在其中。
當直譯器在execute_context
中執行建立local_function1
時,它仍然會將execute_context
的Scope設定給函式local_function1
的scope屬性,也就是這樣:
local_function1.scope = [ execute_context.Variable_Object, global_context.Variable_Object ]
複製程式碼
然後,直譯器碰到了返回語句,把local_function1
返回並賦值給了全域性變數global_sum
。此時global_context
的VO中global_sum
的值就是函式local_function1
。
此時,函式global_function1
已經執行完了,直譯器會怎麼處理它的execute_context
呢?
首先,直譯器會把execute_context從context_stack中彈出,但並不把它完全銷燬,而是保留了execute_context.Variable_Object物件,把它轉移到了另一塊堆記憶體中。為什麼不銷燬呢?因為還有物件引用著它呢。引用鏈如下:
這意味著什麼呢?這說明,當global_function1
結束返回後,它的形式引數parameter_a
,區域性變數local_var1
以及區域性函式local_function1
都沒有銷燬,還仍然存在。這一點,與物件導向的語言Java中的經驗完全不同,這也是閉包難以理解的根本所在。
下面我們的直譯器繼續執行語句alert(global_sum(10))
;alert引數是對函式global_sum
的呼叫,global_sum
的引數為10,我們知道函式global_sum
的程式碼是這樣的:
function local_function1(parameter_b){
return parameter_b + local_var1 + parameter_a + global_var1;
}
複製程式碼
要執行這個函式,直譯器仍然會為它建立一個執行上下文,我們姑且稱之為local_context2
,這個物件的內容是這樣的:
execute_context2 = {
Variable_Object :{
parameter_b:10,
arguments:[10]
},
Scope :[execute_context2.Variable_Object, execute_context.Variable_Object, global_context.Variable_Object ],
this :undefined
}
複製程式碼
這裡我們重點看看Scope屬性,它的第一個元素毫無疑問是execute_context2.Variable_Object
,後面的元素是從local_function1.scope
屬性中獲得的,它是在local_function1
建立時所在的執行上下文的Scope屬性決定的。
建立的execute_context2
壓入context_stack
後,直譯器開始執行語句
return parameter_b + local_var1 + parameter_a + global_var1;
複製程式碼
對於該句中四個變數,直譯器確定它們的值的辦法一如既往的簡單,首先在當前執行上下文(也就是execute_context2)的Scope的第一個元素中查詢,第一個找不到就在第二個元素中查詢,然後就是第三個,直至global_context.Variable_Object。
然後,直譯器就會將四個變數值相加後返回。彈出execute_context2
,此時execute_context2
已經沒有物件引用著它,直譯器就把它銷燬了。
最後,alert函式會收到值40,然後發出一個彈窗,彈窗的內容就是40。程式結束
說到現在,啥是閉包啊?
簡單講,當我們從函式global_function1
中返回另一個函式local_function1
時,由於local_function1
的scope
屬性中引用著為執行global_function1
建立的execute_context.Variable_Object
物件,導致global_function1
在執行完畢後,它的execute_context.Variable_Object
物件並不會被回收,此時我們稱函式local_function1
是一個閉包,因為它除了是一個函式外,還儲存著建立它的執行上下文的變數資訊,使得我們在呼叫它時,仍然能夠訪問這些變數。
函式將建立它的上下文中的VO物件封閉包含在自己的scope屬性中,函式就變成了一個閉包。從這個廣泛的意義上來說,global_function1
也可以叫做閉包,因為它的scope內部屬性也包含了建立它的全域性上下文的變數資訊,也就是global_context.VO
推薦文章
- 教你步步為營掌握JavaScript閉包(本篇文章脫胎於這篇文章)
- 理解JS執行環境
- 深入理解JavaScript閉包和原型鏈