執行上下文、變數物件、作用域鏈、this,看這篇就夠了!

明易發表於2018-12-24

不管是前端老司機,還是前端小白,看到標題中列舉的這些概念,想必都是頭大。其實你知道麼?這些概念背後是有聯絡的,理清楚他們的關係,你才能準確且牢靠地記住他們。

也只有理清楚這些基本且重要的概念,你才能在前端的道路上越走越遠。

好了,讓我們開始吧。

執行上下文

執行上下文可以理解為函式執行的環境。每個函式執行時,都會給對應的函式建立這樣一個執行環境。

JS執行環境大概包括三種情況:全域性環境、函式環境、eval環境(不推薦使用,所以不討論)。

一個JS程式中,必定會產生多個執行上下文,JS引擎會以棧的方式處理它們,這個棧,我們稱之為函式呼叫棧。棧底永遠都是全域性上下文,棧頂就是當前正在執行的上下文。

由於棧是先進後出的結構,我們不難推出以下四點:

  • 只有棧頂的上下文處於執行中,其他上下文需要等待
  • 全域性上下文只有唯一的一個,它在瀏覽器關閉時出棧
  • 函式的執行上下文的個數沒有限制
  • 每次某個函式被呼叫,就會有個新的執行上下文為其建立。

當然,光知道這些還是不夠,我們還必須瞭解執行上下文的生命週期。

執行上下文的生命週期

當呼叫一個函式時,一個新的執行上下文就會被建立。而一個執行上下文的生命週期可以分為兩個階段。

建立階段

在這個階段中,執行上下文會分別建立變數物件,建立作用域鏈,以及確定this的指向。

程式碼執行階段

建立完成之後,就會開始執行程式碼,這個時候,會完成變數賦值,函式引用,以及執行其他程式碼。

執行上下文、變數物件、作用域鏈、this,看這篇就夠了!

至此,我們終於知道執行上下文跟變數物件、作用域鏈及this的關係。

接下來我們重點介紹這三個概念。

變數物件

當一個函式被呼叫時,執行上下文就建立了,執行上下文包含了函式所有宣告的變數和函式,儲存這些變數跟函式的物件,我們稱之為變數物件。

變數物件的建立,依次經歷了以下幾個過程。

  • 建立arguments物件。檢查當前上下文中的引數,建立該物件下的屬性與屬性值。
  • 檢查當前上下文的函式宣告,也就是使用function關鍵字宣告的函式。在變數物件中以函式名建立一個屬性,屬性值為指向該函式所在記憶體地址的引用。如果函式名的屬性已經存在,那麼該屬性將會被新的引用所覆蓋。
  • 檢查當前上下文中的變數宣告,每找到一個變數宣告,就在變數物件中以變數名建立一個屬性,屬性值為undefined。如果該變數名的屬性已經存在,為了防止同名的函式被修改為undefined,則會直接跳過,原屬性值不會被修改。
    執行上下文、變數物件、作用域鏈、this,看這篇就夠了!
    舉個反例,很多人對以下程式碼存在疑問,既然變數宣告的foo遇到函式宣告的foo會跳過,可是為什麼最後foo的輸出結果仍然是被覆蓋了?
function foo() { console.log('function foo') }
var foo = 20;

console.log(foo); // 20
複製程式碼

這是因為上面的三條規則僅僅適用於變數物件的建立過程。也就是執行上下文的建立過程。而foo = 20是在執行上下文的執行過程中執行的,輸出結果自然會是20。對比下例。

console.log(foo); // function foo
function foo() { console.log('function foo') }
var foo = 20;
複製程式碼
// 上慄的執行順序為

// 首先將所有函式宣告放入變數物件中
function foo() { console.log('function foo') }

// 其次將所有變數宣告放入變數物件中,但是因為foo已經存在同名函式,因此此時會跳過undefined的賦值
// var foo = undefined;

// 然後開始執行階段程式碼的執行
console.log(foo); // function foo
foo = 20;
複製程式碼

再看一個例子:

// demo01
function test() {
    console.log(a);
    console.log(foo());

    var a = 1;
    function foo() {
        return 2;
    }
}

test();
複製程式碼

我們直接從test()的執行上下文開始理解。全域性作用域中執行test()時,test()的執行上下文開始建立。為了便於理解,我們用如下的形式來表示

// 建立過程
testEC = {
    // 變數物件
    VO: {},
    scopeChain: {}
}

// 因為本文暫時不詳細解釋作用域鏈,所以把變數物件專門提出來說明

// VO 為 Variable Object的縮寫,即變數物件
VO = {
    arguments: {...},  //注:在瀏覽器的展示中,函式的引數可能並不是放在arguments物件中,這裡為了方便理解,我做了這樣的處理
    foo: <foo reference>  // 表示foo的地址引用
    a: undefined
}

複製程式碼

未進入執行階段之前,變數物件中的屬性都不能訪問!但是進入執行階段之後,變數物件轉變為了活動物件,裡面的屬性都能被訪問了,然後開始進行執行階段的操作。

變數物件和活動物件其實都是同一個物件,只是處於執行上下文的不同生命週期。不過只有處於函式呼叫棧棧頂的執行上下文中的變數物件,才會變成活動物件。

// 執行階段
VO ->  AO   // Active Object
AO = {
    arguments: {...},
    foo: <foo reference>,
    a: 1,
    this: Window
}
複製程式碼

因此,上面的例子demo1,執行順序就變成了這樣

function test() {
    function foo() {
        return 2;
    }
    var a;
    console.log(a);
    console.log(foo());
    a = 1;
}

test();
複製程式碼

作用域鏈與閉包

變數物件講完了,接著是作用域鏈,這裡就不得不先提下作用域。

作用域

作用域最大的用處就是隔離變數,不同作用域下同名變數不會有衝突。

JavaScript中只有全域性作用域與函式作用域。言外之意是:javascript除了全域性作用域之外,只有函式可以建立的作用域

JavaScript程式碼的整個執行過程,分為兩個階段,程式碼編譯階段程式碼執行階段

編譯階段由編譯器完成,將程式碼翻譯成可執行程式碼,這個階段作用域規則會確定。

執行階段由引擎完成,主要任務是執行可執行程式碼,執行上下文在這個階段建立。

執行上下文、變數物件、作用域鏈、this,看這篇就夠了!

理解這點很重要,我們面試過程中,經常會被問到“自由變數”的取值問題。

什麼是“自由變數”?先看個例子:

var x = 10;
function fn() {
    var b = 20;
    console.log(x+b); // x在這裡就是一個自由變數
}
複製程式碼

取x的值時,需要到另一個作用域中取,x就被稱作“自由變數”。

“自由變數”的取值,難倒一片的人,不信,看看下面這個例子:

var x = 10;
function fn() {
    console.log(x);
}
function show(f){
    var x = 20;
    (function () {
        f(); // 這裡輸出什麼???
    })();
}
show(fn);
複製程式碼

你的第一反應是不是20?答案是10!!

其實這個問題很簡單,自由變數要到建立這個函式的那個作用域中取值——是“建立”,而不是“呼叫”

為什麼呢?因為作用域是在程式碼編譯過程就確定下來的,然後就不會改變,這就是所謂的“靜態作用域”。

本例中,在fn函式取自由變數x的值時,要到哪個作用域中取?——要到建立fn函式的那個作用域中取——無論fn函式將在哪裡呼叫。fn明顯是在全域性環境下建立的,x明顯就是10。

作用域鏈

上面的例子,只是跨一個作用域去尋找。

如果跨了一步,還沒找到呢?——接著跨!——一直跨到全域性作用域為止。要是在全域性作用域中都沒有找到,那就是真的沒有了。

這個一步一步“跨”的路線,我們稱之為——作用域鏈。

我們拿文字總結一下取自由變數時的這個“作用域鏈”過程:(假設a是自由量)

第一步,現在當前作用域查詢a,如果有則獲取並結束。如果沒有則繼續;

第二步,如果當前作用域是全域性作用域,則證明a未定義,結束;否則繼續;

第三步,(不是全域性作用域,那就是函式作用域)將建立該函式的作用域作為當前作用域;

第四步,跳轉到第一步。

閉包

閉包是一種特殊的物件。

它由兩部分組成。執行上下文(代號A),以及在該執行上下文中建立的函式(代號B)。

當B執行時,如果訪問了A中變數物件中的值,那麼閉包就會產生。

// demo01
function foo() {
    var a = 20;
    var b = 30;

    function bar() {
        return a + b;
    }

    return bar;
}

var bar = foo();
bar();
複製程式碼

上面的例子,首先有執行上下文foo,在foo中定義了函式bar,而通過對外返回bar的方式讓bar得以執行。當bar執行時,訪問了foo內部的變數a,b。因此這個時候閉包產生。

閉包的應用場景

除了面試,在實踐中,閉包有兩個非常重要的應用場景。分別是模組化與柯里化。

this

this或許是最讓初學者頭疼的概念了吧。this難就難在指向上。

請記住:this的指向,是在函式被呼叫的時候確定的,在函式執行過程中,this一旦被確定,就不可更改了

我們來看看幾種情況:

全域性物件中的this

全域性環境中的this,指向它本身。

函式中的this

在一個函式上下文中,this由呼叫者提供,由呼叫函式的方式來決定。如果呼叫者函式,被某一個物件所擁有,那麼該函式在呼叫時,內部的this指向該物件。如果函式獨立呼叫,那麼該函式內部的this,則指向undefined。但是在非嚴格模式中,當this指向undefined時,它會被自動指向全域性物件。切記,函式執行過程中,this一旦被確定,就不可更改。

'use strict';
var a = 20;
function foo () {
    var a = 1;
    var obj = {
        a: 10,
        c: this.a + 20,
        fn: function () {
            return this.a;
        }
    }
    return obj.c;

}
console.log(foo());    // ?
console.log(window.foo());  // ?
複製程式碼

執行foo()時,函式獨立呼叫,所以this指向undefined(因為是嚴格模式),所以執行this.a時報錯。

執行window.foo()時,this.a = 20,結果為40.

function foo() {
    console.log(this.a)
}

function active(fn) {
    fn(); // 真實呼叫者,為獨立呼叫
}

var a = 20;
var obj = {
    a: 10,
    getA: foo
}

active(obj.getA); // 20
複製程式碼

使用call,apply顯示指定this

call與applay

建構函式與原型方法上的this

function Person(name, age) {

    // 這裡的this指向了誰?
    this.name = name;
    this.age = age;   
}

Person.prototype.getName = function() {

    // 這裡的this又指向了誰?
    return this.name;
}

// 上面的2個this,是同一個嗎,他們是否指向了原型物件?

var p1 = new Person('Nick', 20);
p1.getName();
複製程式碼

this,是在函式呼叫過程中確定,因此,搞明白new的過程中到底發生了什麼就變得十分重要。

通過new操作符呼叫建構函式,會經歷以下4個階段。

  • 建立一個新的物件;
  • 將建構函式的this指向這個新物件;
  • 指向建構函式的程式碼,為這個物件新增屬性,方法等;
  • 返回新物件。

因此,當new操作符呼叫建構函式時,this其實指向的是這個新建立的物件,最後又將新的物件返回出來,被例項物件p1接收。因此,我們可以說,這個時候,建構函式的this,指向了新的例項物件,p1。

而原型方法上的this就好理解多了,根據上邊對函式中this的定義,p1.getName()中的getName為呼叫者,他被p1所擁有,因此getName中的this,也是指向了p1。

寫在最後

本文提到的概念,都是JavaScript中相對晦澀的,平時開發過程中,要多思考其原理,這是一個必經的階段,只要不斷加深理解,我們才能真正掌握這些概念,也只有掌握好這些概念,我們才能在前端的道理上越走越遠。

相關文章