javascript棄坑之路-搞定執行環境物件、變數訪問和作用域

weixin_34148340發表於2018-01-15

可以說只要寫程式碼,就離不開變數訪問,先來看一下下面幾道關於變數的值的題目:

  • 題一
var a=10;
function getA(){
   var a=20;
   console.log(a);
}
getA();//20
console.log(a)//10
複製程式碼
  • 題二
var a=10;
function getA(){
   console.log(a);
}
getA();//10
複製程式碼
  • 題三
var a=10;
function getA(){
    var a=20;
    inner();
}
function inner(){
    console.log(a);
}
getA();//10
複製程式碼
 - 題四
複製程式碼
var a=10;
function getA(){
   var a=20;
   return function(){
       console.log(a);
   }
}
var innerfunc=getA();
innerfunc();//20
複製程式碼
  • 題五
var a=10;
function getA(){
   console.log(a);
   var a=20;
}
getA();//undefined
複製程式碼
  • 題六
 var a=10;
 function getA(a){
   console.log(a);
   var a=20;
 }
 getA(10);//10
複製程式碼

以上幾個題目如果你都能輕鬆地答對答案,並且知其所以然的話,那麼或許你已經掌握了作用域,至少能獨立解決作用域的大部分問題了,如果有些不明白為什麼會是這樣的結果的話,可以接著閱讀下面的內容,或許能找到答案~

執行環境和執行環境物件

為什麼列印一個變數,在不同的情況下會出現那麼多中結果?這就和執行環境物件有關了,JavaScript引擎把變數作為屬性儲存在了一個物件中,這個物件正是執行環境物件,那麼執行環境物件又和執行環境有什麼關係呢?
執行環境是一種概念,表示正在執行中的程式碼所在的環境(可以是全域性的,普通函式,或者eval函式),這種概念包含了對變數或函式有權訪問的其他資料的定義,它本身並不是一個物件,事實上,每個執行環境都有一個與之相關的執行環境物件,同時,執行環境物件正是對ECMAScript對執行環境的實現,在ECMAScript中,執行環境有三種型別:global,function和eval,三種環境物件的執行環境物件也有不同的表現,在執行環境物件的生命週期也會有所體現。

執行環境物件的生命週期

執行環境物件也是一個物件,那麼這個物件的生命週期又是什麼樣的呢?它是天然存在,永生不死的嗎?我們知道,物件是需要建立才會生成的,執行環境物件也不例外,只有當進入了某個執行環境中,才會產生這個執行環境所物件的執行環境物件。

全域性執行環境

我們把不處於任何函式中的程式碼所在的環境稱為全域性執行環境,當一段程式開始執行時,首先進入的就是全域性執行環境,此時就生成了全域性執行函式物件,在此之後所有的變數定義,函式宣告都離不開全域性執行環境,因此全域性執行環境物件會存活於程式的整個生命週期,知道程式結束。

function執行環境

除全域性執行環境外,所有其他的執行環境都是需要執行函式來啟用的,也就是說只有當開始執行某個函式了,才會進入這個函式對應的執行環境,與此同時,相關的執行環境變數物件會被建立,這裡的執行環境變數物件也就是,我們通常所說的執行上下文,到這裡也可以發現,原來有幾個概念是基本等同的:開始執行某個函式==進入對應的執行環境==建立生成物件的執行環境物件==產生對應的執行上下文==啟用上下文,也就是執行環境物件相當於執行上下文,本文中,除上下文堆疊外,均用執行環境物件來表述。

eval執行環境

eval函式比較特殊,它執行的是JavaScript程式碼字串,當直接呼叫eval時,等同於在被呼叫者的作用域中呼叫,若不是直接呼叫,則等同於在全域性作用域中呼叫。可以看如下示例

```
function evaldirect(){
    var a=10;
    eval("a=20");
    console.log(a);
}
function evalindirect(){
    var a=10;
    var evalfunc=eval;
    evalfunc("a=20");
    console.log(window.a);
}
function 
evaldirect();//列印結果為20,即eval內對a的賦值作用到了被呼叫者evaldirect的變數物件中
evalindirect();//列印結果為20,即eval內對a的賦值作用到了被全域性物件window的變數物件中,因為這裡不是直接呼叫的eval,而是將eval賦值給了evalfunc.
```

明白了eval的執行環境,對以前比較迷惑的setTimeout,setInterval函式有了一些想法,或許這兩個函式就是用了間接呼叫eval的方法來執行字串程式碼的?所以才處於全域性作用域中,還有某種對於元素的事件繫結方法,如onClock=" alert(this.a)",是不是也是通過間接呼叫eval的方式來執行呢?(之前被坑過~)
複製程式碼

上下文堆疊

每當開始執行一個函式時,就會生成一個執行環境物件,也即執行上下文,那麼,一個函式可能產生不止一個上下文,這是很常見的,比如在一個函式中呼叫另一個函式,就會產生另一個上下文,更甚者,出現迴圈呼叫或遞迴時,會產生很多個上下文,那麼,在很多個上下文的情況下,js引擎是如何處理呢?這麼多個上下文都是有效的嗎?他們是如何發揮作用的?
事實上,函式中程式碼的執行只和當前的執行上下文有關,那麼在它之前的那些上下文是不是就沒用了?並不是的,與上下文的建立相對於,還有上下文的銷燬操作,噹噹前函式執行結束後,與之相關的上下文物件就會被銷燬,此時在啟用它之前的上下文成為當前上下文,即當前上下文不是一成不變的,會隨著函式的執行和結束而進行切換,整個上下文切換過程類似:
當前上下文是全域性上下文->執行函式1->產生函式1上下文->當前上下文切換為函式1上下文->函式1中呼叫函式2->產生函式2上下文-》當前上下文切換為函式2上下文->函式2執行結束->函式2上下文被銷燬->當前上下文恢復為函式1上下文.... 這個過程看上去似曾相識?對,就是堆疊!可以看到上下文的切換過程與堆疊的入棧出棧極其相似,我們稱之為上下文堆疊。
瞭解了上下文堆疊的概念後再來看題一:

   var a=10;
   function getA(){
    var a=20;
    console.log(a);
   }
   getA();//20
   console.log(a)//10
複製程式碼

答案就很明瞭了,在全域性執行上下文中呼叫getA函式後,進入了getA函式的執行上下文 ,此時console.log(a),其中的a為當前的執行上下文中的變數a,即為20,之後getA結束執行,對應的函式上下文出棧,當前執行上下文回到全域性執行上下文,此時執行console.log(a)中的變數a為全域性執行上下文中的a 即為10。

執行環境物件的屬性

我們已經知道,訪問一個變數時,所取得的這個變數和當前的執行上下文,即執行環境物件(下文中,均稱為執行環境物件)有關,並且要訪問變數,首先要儲存變數,那麼這些變數在執行環境物件中是如何儲存的呢?或許是直接作為執行環境物件的屬性一一儲存的?---其實並不是的,通常我們所訪問的變數並不是直接作為屬性儲存在執行環境物件中的,而是儲存在一個與執行環境物件有關的變數物件中。 既然這個變數物件是和執行環境物件有關的,那麼到底是什麼關係呢?~~答案是變數物件是執行環境物件的屬性之一,執行環境物件有三大屬性,分別是變數物件、this、作用域鏈,其中除this外的兩個屬性,我們無法直接訪問,但它依然會影響我們對其他變數的訪問。

this值

由介紹知道,this和作用域鏈,變數物件是並列的關係,並不需要通過作用域鏈的訪問才能得到this值,反之this值是執行環境物件(執行上下文)的直接屬性,也就是說一旦執行環境物件確定了,必然有一個它所對應的this值,無需涉及到作用域鏈的回溯,這一點常常弄混,搞清楚這一點,應該會解決很多問題~這裡不詳細介紹了,具體可以看這篇 javascript棄坑之路之原來是這樣的this~~

變數物件

每個執行環境都有一個對應的執行環境物件,而變數物件又是執行環境物件的屬性,因此可以說,每個執行環境都有一個對應的變數物件,全域性變數物件會存在於整個程式執行期間,而執行函式時相關的變數物件即區域性變數物件則只在函式執行過程中才存在,當函式執行結束後被銷燬(閉包除外), 變數物件用於儲存被定義在執行環境物件中的變數和函式宣告(注:不包括函式表示式)。

function outer(){
  var a=10;
  function inner(){
      console.log(a);
  }
  (function inner2(){
     ... 
  })()
  inner();//10;
  inner2();//Referenced Error;
}
outer();
複製程式碼

在本例中呼叫outer函式,在outer內部依次呼叫了inner函式,和inner2函式,而inner函式能成功執行,inner2函式卻無法執行,正是因為我們在outer函式中宣告瞭inner函式,而inner2只是一個立即執行函式,參考函式宣告會儲存在變數物件中,而函式表示式不會被儲存,所以在這裡訪問inner2會發生引用錯誤。
在這個例題中也會發現,呼叫inner函式時,執行console.log(a)能夠列印出10,10明明是外部的outer函式所定義的變數a的值,inner函式的變數物件明明沒有a的定義啊?使得,inner函式的變數物件確實沒有a的定義,但是在這個函式中確實能訪問到外部的a值,這個不是bug,正是接下來要說的執行環境的另一個屬性,作用域鏈。

作用域鏈

以上例的inner函式為例,在建立inner()函式時,會建立一個預先包含外部所有變數物件的作用域鏈,從上到下依次是outer函式變數物件,全域性變數物件,這個作用域鏈被儲存在內部的[[scope]]屬性中([[scope]]屬性無法直接呼叫),當呼叫inner()函式時,會為函式建立一個執行環境物件,並構建該執行物件的屬性之一作用域鏈,通過賦值[[scope]]屬性中的物件開始構建該作用域鏈,之後又有一個活動物件(作為變數物件)被建立並推入作用域鏈的前端,活動物件最開始時只包含一個變數,即arguments物件(這個物件在全域性環境是不存在的),之後會陸續建立其他變數(同一般變數物件中的變數)。

作用域鏈的特點

在作用域鏈中有幾個需要注意的點:

  • arguments物件只存在於活動物件中
    像上面提到的,arguments是在函式呼叫時,新建立的活動物件所特有的一個變數,因此它永遠存在且僅存在於作用域鏈的最前端變數物件中(全域性執行環境物件不存在arguments)
  • 作用域鏈的構造
    因此作用域鏈中的變數物件從上到下的順序總是:最上面當前活動物件,下一個變數來自包含環境,再下一個則來自下一個包含環境,依次類推,直到全域性執行環境中的變數物件。
  • 變數名的解析 在執行期間,javascript會通過檢索層級的作用域來解析變數名,它從作用域鏈的頂層,即活動物件開始搜尋,然後逐級的向後檢索,直到找到對應的變數為止,當檢索到作用域鏈的最底層,即undefined,依然沒找到,則變數值為undefined. 現在再來看最開始的題二
題二:
var a=10;
function getA(){
   console.log(a);
}
getA();//10
複製程式碼

這裡呼叫getA()時,取到的a的值時10已經可以理解了~正是因為作用域鏈的原因,訪問a變數時當前活動物件沒有a變數,於是去了下一級變數物件尋找,即全域性變數物件中,這時候找到了全域性變數a. 那麼再來看一下題三呢?
題三

var a=10;
function getA(){
    var a=20;
    inner();
}
function inner(){
    console.log(a);
}
getA();//10
複製程式碼

這裡或許依然有疑惑,在getA中呼叫inner方法時,當前活動物件屬於inner函式,inner方法列印a,既然inner內並沒有關於a的定義,那麼應該去下一級物件去找a變數啊,作用域鏈的下一級不是應該來自inner函式的包含環境即呼叫它本身的getA函式嗎,那也是20啊,可是結果為什麼是10呢,明顯是全域性作用域中的變數值啊。是的,或許有些意外,但結果就是這樣,這就和另一個常常弄混的概念有關了-靜態作用域。

靜態作用域

上節也有提到,作用域鏈的確是在函式開始執行後,作為環境執行物件的屬性之一建立的,如題三中,是在getA方法的執行過程中呼叫了inner函式,inner函式開始執行後,建立了當前的作用域鏈,這沒有問題,但問題是,作用域鏈的來源之一函式本身的[[scope]]屬性是在函式定義的時候,就根據其層層的包含環境建立好了,當建立作用域鏈時,即是先複製了函式的原有的[[scope]]屬性,再將新建立的活動物件推入作用域鏈的最頂層。
所以,函式的外部作用域是在函式被建立時(定義)就確定好的,只有活動物件是在函式執行時建立的,這種作用域鏈機制就是靜態作用域(又有詞法作用域的說法,即函式作用域是通過詞法來劃分的,在定義函式的時候作用域鏈就固定了)

有了以上的說明再看以下的三,答案應該很明瞭了~
那麼題四呢?
題四

var a=10;
function getA(){
   var a=20;
   return function(){
       console.log(a);
   }
}
var innerfunc=getA();
innerfunc();//20
複製程式碼

這裡將getA()函式的返回值賦給innerfunc變數,getA()方法返回值後,方法已經結束,一般來說函式執行結束後,getA()函式的變數物件會被銷燬,可是這裡呼叫innerfunc()的時候為什麼還能訪問到getA()中的變數物件呢?這就是傳說中的閉包機制在發揮作用了。

閉包

閉包是指有權訪問另一個函式作用域中的變數的函式-這是JavaScript高階程式設計中對閉包的一句話定義,雖然很簡潔,但卻實在的閉包這個概念進行了概括,建立閉包的常見方式就是在一個函式中建立另一個函式,就行剛才的題四,在getA()函式內部建立了一個匿名函式,這個匿名函式可以訪問到getA()中定義的變數,所以這裡匿名函式就是一個閉包,這裡getA()方法在結束時,將匿名函式返回,那麼到底是什麼力量讓閉包能夠在getA()方法結束後依然能夠訪問它的變數呢?
一般來說,當函式執行結束之後,當前活動物件會被銷燬,但閉包的情況就會不止這麼簡單。像之前所提到的,在函式建立的時候,函式的[[scope]]屬性也會建立,也就是函式被包裹的層層外部環境的變數物件都會處於[[sope]]中,而這裡匿名函式是在getA()函式執行過程中建立的,此時活動物件是getA()的變數物件,匿名函式建立後,它的外部函式getA()的函式的活動物件會處於它的作用域鏈的上層,這樣,匿名函式就可以訪問getA()函式的變數了,而正因為執行 var innerfunc=getA()時,這個匿名函式被返回且賦值了,所以該函式的作用域鏈中的變數物件是不會被銷燬的(因為還有利用價值吧~),也就是說getA()方法雖然結束了,但是因為它的活動物件被其他有意義的函式的作用域鏈之中,所以不會被銷燬,(這裡的有意義的,不防理解為,可以被訪問到的,假如單純的執行了getA()方法而沒有賦值,那麼即使匿名函式被返回了,也永遠不可能被訪問到,此時getA()的變數物件依然會被銷燬。)
這裡有一點要注意的是,儘管getA()結束後,它的活動物件沒有被銷燬,但getA()的執行環境物件是被銷燬了的,包括執行環境相關的this值和作用域鏈也被銷燬了(還記得執行環境物件的三大屬性嗎?變數物件,this值,作用域鏈).

變數提升

到這裡只剩最後一個問題了,再看看下題五
題五

var a=10;
function getA(){
   console.log(a);
   var a=20;
}
getA();//undefined
複製程式碼

這裡答案是undefined,還是比較奇怪,按理說至少可以得到全域性變數a,也是10啊?這裡其實原因在與變數提升了~

什麼是變數提升呢?這和JavaScript引擎機制有關,JavaScript引擎在進入作用域時,會分兩輪處理程式碼,第一輪:初始化變數,第二輪:執行程式碼。 在初始化變數的過程中會宣告並初始化引數和函式,但對於區域性變數,只會宣告,並不初始化。 有了對上面兩輪處理過程的瞭解,再來看上面的題四,在第一輪中將a進行了宣告但未初始化,相當於var a,接下來再第二輪執行程式碼的過程中,console.log(a)時,已經能夠取得當前作用域中的a變數,這是這時的a變數尚未初始化,相當於undefined,所以無需去它的上級作用域即全域性作用域中去尋找a了,直接去了了值為undefined的a值,這整個過程就達到了變數提升的效果~

還剩最後一道題六

  • 題六
 var a=10;
 function getA(a){
   console.log(a);
   var a=20;
 }
 getA(10);//10
複製程式碼

這裡也是變數提升的一道題目,當這道題中,因為呼叫getA(a)方法時,傳入了引數a=10,即a已經由引數賦值,所以即使var a再次宣告a時,也不會被undefined覆蓋了,這裡的宣告是多餘的(看來還剩比較智慧的~)

相關文章