JavaScript 論程式碼執行上下文

艾倫先生發表於2017-12-14

導讀

本片文章,在前人的基礎上,加上自己的理解,解釋一下JavaScript的程式碼執行過程,順道介紹一下執行環境和閉包的相關概念。

分為兩部分。第一部分是瞭解執行環境的相關概念,第二部分是通過實際程式碼瞭解具體執行過程中執行環境的切換。

執行環境

執行環境的分類

  • 1.全域性執行環境 是JS程式碼開始執行時的預設環境(瀏覽器中為window物件)。全域性執行環境的變數物件始終都是作用域鏈中的最後一個物件。
  • 2.函式執行環境 當某個函式被呼叫時,會先建立一個執行環境及相應的作用域鏈。然後使用arguments和其他命名引數的值來初始化執行環境的變數物件。
  • 3.使用eval()執行程式碼

沒有塊級作用域(本文不涉及ES6中let等概念)

執行上下文(執行環境)的組成

執行環境(execution context,EC)或稱之為執行上下文,是JS中一個極為重要的概念。當JavaScript程式碼執行時,會進入不同的執行上下文,而每個執行上下文的組成,基本如下:

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的執行上下文,執行上下文棧就可以表示為:

JavaScript 論程式碼執行上下文

執行全域性程式碼時,會產生一個執行上下文環境,每次呼叫函式都又會產生執行上下文環境。當函式呼叫完成時,這個上下文環境以及其中的資料都會被消除,再重新回到全域性上下文環境。處於活動狀態的執行上下文環境只有一個。

JavaScript 論程式碼執行上下文

產生執行上下文的兩個階段

當一段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_var1global_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_var1global_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是這樣的:

JavaScript 論程式碼執行上下文

建立出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是這樣的:

JavaScript 論程式碼執行上下文

準備執行函式內的程式碼

做好了準備工作,直譯器開始執行函式裡面的程式碼了,此時我們稱函式是在執行上下文中執行的。

第一句

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物件,把它轉移到了另一塊堆記憶體中。為什麼不銷燬呢?因為還有物件引用著它呢。引用鏈如下:

JavaScript 論程式碼執行上下文

這意味著什麼呢?這說明,當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_function1scope屬性中引用著為執行global_function1建立的execute_context.Variable_Object物件,導致global_function1在執行完畢後,它的execute_context.Variable_Object物件並不會被回收,此時我們稱函式local_function1是一個閉包,因為它除了是一個函式外,還儲存著建立它的執行上下文的變數資訊,使得我們在呼叫它時,仍然能夠訪問這些變數。

函式將建立它的上下文中的VO物件封閉包含在自己的scope屬性中,函式就變成了一個閉包。從這個廣泛的意義上來說,global_function1也可以叫做閉包,因為它的scope內部屬性也包含了建立它的全域性上下文的變數資訊,也就是global_context.VO

推薦文章

相關文章