js基礎梳理-如何理解做用域和做用域鏈?

本文重點是要梳理執行上下文的生命週期中的創建做用域鏈,在此以前,先回顧下關於做用域的一些知識。javascript

1.什麼是做用域(scope)?

在《JavaScritp高級程序設計》中並無找到確切的關於做用域的定義,只是在「4.2執行環境及做用域」中簡單說了下執行環境(execution context)的概念。而執行環境其實就是以前博客:js基礎梳理-究竟什麼是執行上下文棧(執行棧),執行上下文(可執行代碼)?中的執行上下文。html

而在《JavaScript權威指南》中,對做用域的描述爲:前端

變量做用域:一個變量的做用域(scope)是程序源代碼中定義這個變量的區域java

在《你不知道的Javascript·上卷》中對做用域的描述則爲:jquery

負責收集並維護由全部生命的標識符(變量)組成的一系列查詢,並實施一套很是嚴格的規則,肯定當前執行的代碼對這些標識符的訪問權限。瀏覽器

簡單來說,做用域(scope)就是變量訪問規則的有效範圍閉包

  • 做用域外,沒法引用做用域內的變量;
  • 離開做用域後,做用域的變量的內存空間會被清除,好比執行完函數或者關閉瀏覽器
  • 做用域與執行上下文是徹底不一樣的兩個概念。我曾經也混淆過他們,可是必定要仔細區分。

JavaScript代碼的整個執行過程,分爲兩個階段,代碼編譯階段與代碼執行階段。編譯階段由編譯器完成,將代碼翻譯成可執行代碼,這個階段做用域規則會肯定。執行階段由引擎完成,主要任務是執行可執行代碼,執行上下文在這個階段建立。函數

說得很深奧的樣子,其實上面這段話重點用函數做用域與函數執行上下文來區分是最好不過的了。函數做用域是在函數聲明的時候就已經肯定了,而函數執行上下文是在函數調用時建立的。假如一個函數被調用屢次,那麼它就會建立多個函數執行上下文,可是函數做用域顯然不會跟着函數被調用的次數而發生什麼變化。學習

1.1 全局做用域

var foo = 'foo';
console.log(window.foo);   // => 'foo'

在瀏覽器環境中聲明變量,該變量會默認成爲window對象下的屬性。this

function foo() {
    name = "bar"
}
foo();
console.log(window.name) // bar

在函數中,若是不加 var 聲明一個變量,那麼這個變量會默認被聲明爲全局變量,若是是嚴格模式,則會報錯。

全局變量會形成命名污染,若是在多處對同一個全局變量進行操做,那麼久會覆蓋全局變量的定義。同時全局變量數量過多,很是不方便管理。

這也是爲何jquery要在全局創建變量 $,其他私有方法屬性掛在 $ 下的緣由。

1.2 函數做用域

假如在函數中定義一個局部變量,那麼該變量只能夠在該函數做用域中被訪問。

function doSomething () {
    var thing = '吃早餐';
}
console.log(thing); // Uncaught ReferenceError: thing is not defined

嵌套函數做用域:

function outer () {
    var thing = '吃早餐';
    function inner () {
        console.log(thing);
    }
    inner();
}

outer();  // 吃早餐

在外層函數中,嵌套一個內層函數,那麼這個內層函數能夠向上訪問到外層函數中的變量。

既然內層函數能夠訪問到外層函數的變量,那若是把內層函數return出來會怎樣?

function outer () {
    var thing = '吃早餐';
    
    function inner () {
        console.log(thing);
    }
    
    return inner;
}

var foo = outer();
foo();  // 吃早餐

前面提到,函數執行完後,函數做用域的變量就會被垃圾回收。而這段代碼看出當返回了一個訪問了外部函數變量的內部函數,最後外部函數的變量得以保存。

這種當變量存在的函數已經執行結束,但扔能夠再次被訪問到的方式就是「閉包」。後期會繼續對閉包進行梳理。

1.3 塊級做用域

不少書上都有一句話,javascript沒有塊級做用域的概念。所謂塊級做用域,就是{}包裹的區域。可是在ES6出來之後,這句話並不那麼正確了。由於能夠用 let 或者 const 聲明一個塊級做用域的變量或常量。

好比:

for (let i = 0; i < 10; i++) {
    // ...
}
console.log(i); // Uncaught ReferenceError: i is not defined

發現這個例子就會和函數做用域中的第一個例子同樣的錯誤提示。由於變量i只能夠在 for循環的{ }塊級做用域中被訪問了。

擴散思考:

究竟何時該用let?何時該用const?

默認使用 const,只有當確實須要改變變量的值的時候才使用let。由於大部分的變量的值在初始化以後不該再改變,而預料以外的變量的修改是不少bug的源頭。

1.4 詞法做用域

詞法做用域,也能夠叫作靜態做用域。意思是不管函數在哪裏調用,詞法做用域都只在由函數被聲明時所處的位置決定。
既然有靜態做用域,那麼也有動態做用域。
而動態做用域的做用域則是由函數被調用時執行的位置所決定。

var a = 123;
function fn1 () {
    console.log(a);
}
function fn2 () {
    var a = 456;
    fn1();
}
fn2();   // 123

以上代碼,最後輸出結果 a 的值,來自於 fn1 聲明時所在位置訪問到的 a 值 123。
因此JS的做用域是靜態做用域,也叫詞法做用域。

上面的1.1-1.3能夠看作做用域的類型。而這一小節,其實跟上面三小節仍是有差異的,並不屬於做用域的類型,只是關於做用域的一個補充說明吧。

2. 什麼是做用域鏈(scope chain)

在JS引擎中,經過標識符查找標識符的值,會從當前做用域向上查找,直到做用域找到第一個匹配的標識符位置。就是JS的做用域鏈。

var a = 1;
function fn1 () {
    var a = 2;
    function fn2 () {
        var a = 3;
        console.log(a);
    }
    fn2 ();
}
fn1(); // 3

console.log(a) 語句中,JS在查找 a變量標識符的值的時候,會從 fn2 內部向外部函數查找變量聲明,它發現fn2內部就已經有了a變量,那麼它就不會繼續查找了。那麼最終結果也就會打印3了。

3. 做用域鏈與執行上下文

在此前的博客:js基礎梳理-究竟什麼是執行上下文棧(執行棧),執行上下文(可執行代碼)?中講到執行上下文的生命週期:

3.執行上下文的生命週期

3.1 建立階段

  • 生成變量對象(Variable object, VO)
  • 創建做用域鏈(Scope chain)
  • 肯定this指向

3.2 執行階段

  • 變量賦值
  • 函數引用
  • 執行其餘代碼

上面作了那麼多鋪墊,其實重點是想梳理這一小節。
下面,以一個函數的建立和激活兩個時期來說解做用域鏈是如何建立及變化的。

3.1函數建立階段

上文中講到,函數的做用域在函數定義的時候就決定了。

這是由於函數有一個內部屬性[[scope]],當函數建立的時候,就會保存全部父變量對象到其中,可是注意:此時[[scope]]並不表明完整的做用域鏈,由於在建立階段,它尚未包括本身的做用域。

舉個栗子:

function foo () {
    function bar () {
        ...
    }
}

函數建立時,各自的[[scope]]爲:

foo.[[scope]] = [
    globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.AO
];

3.2 函數激活階段

當函數激活時,進入函數上下文,建立VO/AO後,就會將活動對象添加到做用域鏈的前端。

這時候執行上下文的做用域鏈,命名爲 Scope:

Scope = [AO].concat([[scope]]);

至此,做用域鏈建立完畢。

3.3 舉個栗子

如下面的例子爲例,結合以前的變量對象,活動對象和執行上下文棧,總結一下函數執行上下文中做用域鏈和變量對象的建立過程:

var x = 10;
 
function foo() {
  var y = 20;
 
  function bar() {
    var z = 30;
    console.log(x +  y + z);
  }
 
  bar();
}
 
foo(); // 60

你們確定都知道打印結果會是60。可是從第一行代碼開始到最後一行代碼結束,整個代碼的執行上下文棧以及做用域鏈是怎樣變化的呢?

// 第一步:進入全局上下文,此時的執行上下文棧是這樣:
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];

// 第二步:foo函數被建立,此時的執行上下文棧沒有變化,可是建立了foo函數的做用域,保存做用域鏈到內部屬性[[scope]]。
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];
foo.[[scope]] = [
    globalContext.VO
];

// 第三步:foo函數執行,進入foo函數執行上下文的建立階段
// 這個階段它作了三件事:
// 1.複製以前的foo.[[scope]]屬性到foo函數上下文下,建立foo函數的做用域鏈;
// 2. 建立foo函數上下文的變量對象,並初始化變量對象,依次加入形參,函數聲明,變量聲明
// 3. 把foo函數上下文的變量對象加入到第一步建立的foo函數做用域鏈的最前面。
// 最終,通過這三個步驟以後,整個執行上下文棧是這樣

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: undefined
        },
        Scope: [foo.VO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.VO,
    globalContext.VO
];

// 第四步:foo函數執行,進入foo函數執行上下文的執行階段。
// 這個階段又作了如下2件事:
// 1. 把foo執行上下文的變量對象VO改爲了活動對象AO,而且修改AO中變量的值
// 2. 發現建立了一個 bar函數,就保存了bar函數的全部父變量對象到bar函數的[[scope]]屬性上。


ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    foo.AO,
    globalContext.VO
];

// 第五步,bar函數執行,進入bar函數執行上下文的建立階段
// 與第三步相似,也作了三件事,只不過主體變成了bar
// 1.複製以前的bar.[[scope]]屬性到bar函數上下文下,建立foo函數的做用域鏈;
// 2. 建立bar函數上下文的變量對象,並初始化變量對象,依次加入形參,函數聲明,變量聲明
// 3. 把bar函數上下文的變量對象加入到第一步建立的bar函數做用域鏈的最前面。
// 最終,通過這三個步驟以後,整個執行上下文棧是這樣

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            z: undefined
        },
        Scope: [bar.VO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.VO,
    foo.AO,
    globalContext.VO
];

// 第六步:bar函數執行,進入bar函數執行上下文的執行階段
// 與第四步相似。不過此時bar函數裏面不會再建立新的函數上下文了
// 1. 把bar執行上下文的變量對象VO改爲了活動對象AO,而且修改AO中變量的值
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            z: 30
        },
        Scope: [bar.AO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.AO,
    foo.AO,
    globalContext.VO
];

// 第七步:執行bar函數中的console.log(x + y +z),查找x, y, z三個標識符

- "x"
-- <bar>functionContext.AO   // 沒找到,繼續到foo.AO中找
-- <foo>functionContext.AO   // 還沒找到,再往globalContext.VO中找
-- globalContext.VO     // 找到了,值爲 10

- "y"
-- <bar>functionContext.AO   // 沒找到,繼續到foo.AO中找
-- <foo>functionContext.AO   // 找到了,值爲20

-- "z"
-- <bar>functionContext.AO   // 找到了,值爲 30

打印結果: 60。

// 第八步:bar函數執行完畢,將其從執行上下文棧中彈出,foo函數執行完畢,將其從執行上下文棧中彈出。最終,執行上下文棧,只剩下globalContext

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
]

感受其實能夠簡化理解一下,把第三四步,第五六步分別分紅一個步驟。

打算每週定一個小主題,多是基礎知識鞏固,也多是本身學習新知識的記錄。在下一篇博文中,將對this指向問題進行梳理。若是你也感興趣,也能夠去搜集下相關資料,到時候你們共同窗習探討一下。

相關文章
相關標籤/搜索