淺談js執行環境——宣告提升的本質
(function() { console.log(typeof foo); // 這裡會打印出什麼? console.log(typeof bar); // 這裡會打印出什麼? var foo = 'hello', bar = function() { return 'vian'; }; function foo() { return 'hello'; } })(); 複製程式碼
我們在接觸JavaScript這門語言時,會經常遇到這種問題,經過後續的學習,我們可能知道了這種現象在JavaScript中叫宣告提升(hoisting),但是我們可能只知道宣告提升的現象,卻不清楚造成這種現象的本質,而這個本質卻是JavaScript最為重要的知識之一。解答上述程式碼中的問題之前,我們先看一下JavaScript中的執行環境。 ###執行環境(執行上下文)
執行環境(execution context)定義了變數或函式有權訪問的其他資料,決定了它們各自的行為。每個執行環境都有一個與之關聯的變數物件(variable object),環境中定義的所有變數和函式都儲存在這個物件中。雖然我們編寫的程式碼無法訪問這個物件,但解析器在處理資料時會在後臺使用它。
在JavaScript中可執行程式碼可以分為三類:
- 全域性程式碼 - JavaScript 程式碼開始執行的預設環境,即全域性的、不在任何函式裡面的程式碼
- 函式程式碼 - 函式體內的程式碼
- eval程式碼 - eval(...)函式中動態執行的程式碼(我們這裡不討論)
全域性執行環境是最外圍的一個執行環境。在Web瀏覽器中,全域性執行環境是window物件。當代碼載入瀏覽器時,全域性執行環境被建立,直到網頁或瀏覽器被關閉,全域性執行環境才被銷燬。 在一個Javascript程式中,必須且只能存在一個全域性執行環境,和任意個的非全域性執行環境,程式每呼叫一個函式,都會建立新的執行環境,如下圖所示。

該JavaScript程式中含有一個全域性執行環境、三個函式執行環境。為以正確的順序執行程式碼,JavaScript中用堆疊的形式來處理執行環境——執行環境棧(Execution Context Stack)。 ###執行環境棧 瀏覽器中JavaScript是單執行緒的,這意味著執行在瀏覽器中的JavaScript程式碼,同一時間只有一件事件、動作發生。除了當前正在執行的程式碼,其他程式碼都在一個佇列中排隊,這個佇列就是執行環境棧。 為了更好的說明執行環境棧,我們結合一個例子來說明:
(function test(i) { if (i === 1) { return; } test(++i); })(0); 複製程式碼
下面我們用圖來表示上述程式碼執行時,執行環境棧中的變化過程:

-
當JavaScript程式碼執行時,第一個建立的總是全域性執行環境,因此全域性執行環境也總是在執行環境棧的最底部。
-
程式碼執行時,呼叫了函式test(0),此時建立新的執行環境test EC,並壓入執行棧。
-
在執行第一次執行函式test(i)時(i=0),執行到函式體中的程式碼test(++i),程式再一次呼叫函式test(i),建立新的執行環境test EC1,並壓入執行棧。
-
函式test(i = 1)中的程式碼執行完畢,該執行環境出棧銷燬,程式回到上一層執行環境中繼續執行。
5.函式test(i = 0)中的程式碼執行完畢,該執行環境出棧銷燬,程式回到全域性執行環境中繼續執行,全域性環境的程式碼即使執行完畢,也不會銷燬,直至網頁或瀏覽器被關閉了才出棧銷燬。
上面就是程式執行過程中,執行環境棧的變成過程。關於執行環境棧,有以下幾點要特別注意:
- 單執行緒
- 同步執行
- 只有一個全域性執行環境
- 可以有無數個的非全域性執行環境
- 每一次函式呼叫都會建立一個新的執行環境,即使是呼叫自身
###詳解執行環境 從上文我們已經知道了,每當一個函式被呼叫時,一個新的執行環境就會被建立。實際上,執行環境可以分為兩個階段,建立階段和執行階段。JavaScript宣告提升的祕密也在其中,我們繼續往下看。
- 建立階段 (函式被呼叫,同時在執行函式內的程式碼前) 在這個階段會發生以下的事: 建立變數物件 (VO,Variable Object) 建立作用域鏈 (Scope Chain) 確定this的指向
- 執行階段 在這個階段進行賦值、函式引用、執行程式碼。
我們完全可以把執行環境當作一個含有三個屬性的物件,如下:
executionContextObj = { 'variableObject': {...}, //函式的arguments、引數、函式內的變數及函式宣告 'scopeChian': {...}, //本層變數物件及所有上層執行環境的變數物件 'this': {} } 複製程式碼
宣告提升的祕密就發生在變數物件VO中。 #####變數物件/活動物件(VO/AO)
每個執行環境都有一個與之關聯的變數物件,環境中定義的所有變數和函式都儲存在這個物件中。
說到執行環境的建立過程就會涉及到變數物件和活動物件,很多人對這兩個概念會模糊不清。其實,變數物件VO和活動物件AO是同一個物件在不同階段的表現形式。當進入執行環境的創捷階段時,**變數物件被建立,這時變數物件的屬性無法被訪問。**進入執行階段後, 變數物件被啟用變成活動物件,此時活動物件的屬性可以被訪問。 下面來看一下,執行環境建立階段中變數物件建立中,JavaScript解析器做的事情:
- 根據函式引數,建立並初始化arguments物件,及形參屬性
- 檢查上下文中的函式宣告,將函式名作為變數物件的屬性,函式引用作為值。 如果該函式名在變數物件中已存在,則覆蓋已存在的函式引用。
- 檢查上下文的變數宣告,將變數名作為變數物件的屬性, 值設定為undefined。如果該變數名在變數物件中已存在,為防止與函式名衝突,則跳過,不進行任何操作。
JavaScript中宣告提升的背後原因已經很清晰了,你發現了嗎?請先思考一下,我們下文將結合例子進行講解。先讓我們結合一段程式碼,結合上文的知識,回顧一下程式碼執行時,會發生什麼事情。
function greet(name) { var say = 'hello'; function action() { console.log(say + name); } action(); } greet('vian'); 複製程式碼
- 進入全域性執行環境,執行程式碼。(JavaScript程式碼執行時,第一個進入的總是全域性執行環境)
- 呼叫函式greet(...),執行函式內的任何程式碼前,建立執行環境。
- 進入建立階段:
- 建立變數物件:
- 檢查函式引數建立arguments物件,及設定函式形參。此時:
executionContextObj = { arguments: {0: 'vian', length: 1}, name: 'vian' } 複製程式碼
- 掃描函式宣告,設定函式名為變數物件的屬性,函式引用為屬性值,遇到同名屬性則覆蓋函式引用值。此時:
executionContextObj = { arguments: {0: 'vian', length: 1}, name: 'vian', action: <action> } 複製程式碼
- 掃描變數宣告,設定變數名為變數物件的屬性,undefined為屬性值,為遇到同名屬性則跳過。此時:
executionContextObj = { arguments: {0: 'vian', length: 1}, name: 'vian', action: <action>, say: undefined } 複製程式碼
- 初始化作用域鏈
- 確定this指向
- 建立變數物件:
- 進入執行階段,執行程式碼。變數物件變成活動物件,遇到查詢變數和函式引用的時候,先去活動物件中找,找不到的情況下沿作用域鏈往上找。直至找到為止,否則為undefined。
- 函式內的程式碼執行完畢,該函式執行環境出棧銷燬,程式執行流回到全域性執行環境.. ###再看宣告提升 通過對JavaScript中執行環境的瞭解,令人奇怪的宣告提升機制也變得清晰明瞭。回到本文的開頭,造成這種宣告提升現象的本質,究竟是什麼呢?——執行環境的建立階段,變數物件建立的方式所造成。下面我們來解釋一下本文開頭的程式碼。
(function() { console.log(typeof foo); // 這裡會打印出什麼? console.log(typeof bar); // 這裡會打印出什麼? var foo = 'hello', bar = function() { return 'vian'; }; function foo() { return 'hello'; } })(); 複製程式碼
根據上文中分析變數物件建立過程的方法:
executionContextObj = {} 1.初始化arguments物件,及形參 executionContextObj = { arguments: {length: 0} } 2.掃描函式宣告,並進行處理: 遇到函式宣告 function foo(){} executionContextObj中沒有foo屬性,將foo設為executionContextObj的屬性,函式引用作為值。 executionContextObj = { arguments: {length: 0}, foo: <function> } 3.掃描變數宣告,並進行處理: 遇到變數宣告var foo,executionContextObj已存在foo屬性,跳過。 遇到變數宣告var bar,executionContextObj不存在bar屬性,將其設定為變數物件屬性,值為undefined。 executionContextObj = { arguments: {length: 0}, foo: <function>, bar: undefined } 複製程式碼
結論:
console.log(typeof foo); // 'function' console.log(typeof bar); // 'undefined' 複製程式碼
到這裡,我們就再也不怕宣告提升的坑和問題啦。需要注意的是,es6中新增的let和const變數宣告都不會進行變數提升,重複賦值及宣告前引用變數都會報錯。