由變數提升談談 JavaScript Execution Context

鈞嘢嘢發表於2018-01-17

JavaScript不同於其他語言,存在變數提升,如下面程式碼例子:

console.log(x)
var x = 'hello world';
複製程式碼

這段程式碼不會報錯,會輸出 undefined。這就是所謂的變數提升,但具體細節JS引擎是怎麼處理的,還需要理解JS的Execution Context執行上下文。

1. Execution Context

Execution Context 是JS執行程式碼時候的一個上下文環境。如執行到一個呼叫函式,就會進入這個函式的執行上下文,執行上下文中會確定這個函式執行期間用到的諸如this,變數,物件以及定義的方法等。

當瀏覽器載入script的時候,預設直接進入Global Execution Context(全域性上下文),將全域性上下文入棧。如果在程式碼中呼叫了函式,則會建立Function Execution Context(函式上下文)並壓入呼叫棧內,變成當前的執行環境上下文。當執行完該函式,該函式的執行上下文便從呼叫棧彈出返回到上一個執行上下文。

2. 執行上下文分類

  • Global execution context。當js檔案載入進瀏覽器執行的時候,進入的就是全域性執行上下文。全域性變數都是在這個執行上下文中。程式碼在任何位置都能訪問。

  • Functional execution context。定義在具體某個方法中的上下文。只有在該方法和該方法中的內部方法中訪問。

  • Eval。定義在Eval方法中的上下文。該方法不建議使用對此就不進一步研究。

3. Execution Stack

Js是單執行緒執行,每次註定只能訪問一個execution context。因此呼叫棧最上方的執行上下文將最先被執行,執行完後返回到上層的執行上下文繼續執行。引用一篇博文的動態圖示如下:

execution stack

4. 執行上下文執行詳情

execution context期間js引擎主要分兩個階段:

建立階段(函式呼叫時,但在函式執行前)

  • JS解析器掃描一遍程式碼,建立execution context內對應的variables, functions和arguments。這三個稱之為Variable Object。

  • 建立作用域鏈scope chain

  • 決定this的指向

executionContextObj = {
    'scopeChain': { /* variableObject + all parent execution context's variableObject */ },
    'variableObject': { /* function arguments / parameters, inner variable and function declarations */ },
    'this': {}
}
複製程式碼

executionContextObj由函式呼叫時執行前建立,建立階段arguments的引數會直接傳入,函式內部定義的變數會初始化為undefined。

執行階段

  • 重新掃描一次程式碼,給變數賦值,然後執行程式碼。

下面是執行上下文期間JS引擎執行虛擬碼

  1. 找到呼叫函式
  2. 執行函式程式碼前,建立execution context
  3. 進行建立階段:
    • 初始化呼叫鏈 Scope Chain
    • 建立 variable object:
      • 建立arguments物件,初始化該入參變數名和值
      • 掃描該執行上下文中宣告的函式:
        • 對於宣告的函式,variable object中建立對應的變數名,其值指向該函式(函式是存在heap中的)
        • 如果函式名已經存在,用新的引用值覆蓋已有的
      • 掃描上下文中宣告的變數:
        • 對於變數的宣告,同樣在variable object中建立對應的變數名,其值初始化為undefined
        • 如果變數的名字已經存在,則直接略過繼續掃描
    • 決定上下文this的指向
  4. 程式碼執行階段:
    • 執行函式內的程式碼並給對應變數進行賦值(建立階段為undefined的變數)

一個簡單例子如下:

console.log(foo(22))
console.log(x);
 
var x = 'hello world';

function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    
    function c() {

    }

    console.log(i)
}
複製程式碼

(a):程式碼首先進入到全域性上下文的建立階段。

ExecutionContextGlobal = {
scopeChain: {...},
variableObject: {
    x: undefined,
    foo: pointer to function foo()
},

this: {...}
}
複製程式碼

然後進入全域性執行上下文的執行階段。這一階段從上至下逐條執行程式碼,執行到console.log(foo(22))該行時,建立階段已經為variableObject中的foo賦值了,因此執行時會執行foo(22)函式。

當執行foo(22)函式時,又將進入foo()的執行上下文,詳見(b)。

當執行到console.log(x)時,此時x在variableObject中賦值為undefined,因此列印出undefined,這也正是變數提升產生的結果。

當執行到var x = 'hello world';,variableObject中的x被賦值為hello world

繼續往下是foo函式的宣告,因此什麼也不做,執行階段結束。下面是執行階段完成後的ExecutionContextGlobal。

ExecutionContextGlobal = {
scopeChain: {...},
variableObject: {
    x: 'hello world',
    foo: pointer to function foo()
},

this: {...}
}
複製程式碼

(b):當js呼叫foo(22)時,進入到foo()函式的執行上下文,首先進行該上下文的建立階段。

ExecutionContextFoo = {
    scopeChain: {...},
    variableObject: {
    	arguments: {
    		0: 22,
    		length: 1
    	},
    	i: 22,
    	c: pointer to function c()
    	a: undefined,
    	b: undefined
    },
    this: {...}
}
複製程式碼

當執行階段執行完後,ExecutionContextFoo如下。

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}
複製程式碼

理清了JS中的執行上下文,就很容易明白變數提升具體是怎麼回事了。在程式碼執行前,執行上下文已經給對應的宣告賦值,只不過變數是賦值為undefined,函式賦值為對應的引用,而後在執行階段再將對應值賦值給變數。

5. 區分函式宣告和函式表示式

首先看下面幾個程式碼片段,分別輸出是什麼?

Question 1:

function foo(){
    function bar() {
        return 3;
    }
    return bar();
    function bar() {
        return 8;
    }
}
alert(foo());
複製程式碼

Question 2:

function foo(){
    var bar = function() {
        return 3;
    };
    return bar();
    var bar = function() {
        return 8;
    };
}
alert(foo());
複製程式碼

Question 3:

alert(foo());
function foo(){
    var bar = function() {
        return 3;
    };
    return bar();
    var bar = function() {
        return 8;
    };
}
複製程式碼

Question 4:

function foo(){
    return bar();
    var bar = function() {
        return 3;
    };
    var bar = function() {
        return 8;
    };
}
alert(foo());
複製程式碼

上面4個程式碼片段分別輸出 8,3,3,[Type Error: bar is not a function]

函式宣告(Function Declaration)

function name([param,[, param,[..., param]]]) { [statements] }

函式宣告以關鍵字function開頭定義函式,同時有確定的函式名。如最簡單的栗子:

function bar() {
    return 3;
}
複製程式碼

通過函式執行上下文,函式宣告會產生hoisted,即函式宣告會提升到程式碼最上面。

所以在Question 1中,foo.VO中 bar:pointer to the function bar(),因為有宣告瞭兩次bar()函式,所以後面的定義覆蓋前面的定義。

函式表示式(Function expression)

var myFunction = function [name]([param1[, param2[, ..., paramN]]]) { statements };

函式表示式中,函式名字可以省略,簡單栗子如下:

//anonymous function expression
var a = function() {
    return 3;
}
 
//named function expression
var a = function bar() {
    return 3;
}
 
//self invoking function expression
(function sayHello() {
    alert("hello!");
})();
複製程式碼

以上三種都是函式表示式,最後一種是立即執行函式。函式表示式不會提升到程式碼最上面,如Question 2中,在函式執行上下文的建立階段中,foo.VO 中 bar : undefined,在執行階段才進行賦值。

在回頭看看Question 4:

function foo(){
    return bar();   // 執行階段返回撥用bar(),但建立階段bar被賦值為 undefined,所以報Type Error。
    var bar = function() {
        return 3;
    };
    var bar = function() {
        return 8;
    };
}
alert(foo());
複製程式碼

參考

What is the Execution Context & Stack in JavaScript?

Execution context, Scope chain and JavaScript internals

JavaScript. The core.

相關文章