形象化模擬做用域鏈,深刻理解js做用域、閉包

前言

理解javascript中的做用域和做用域鏈對咱們理解js這們語言。此次想深刻的聊下關於js執行的內部機制,
主要討論下,做用域,做用域鏈,閉包的概念。爲了更好的理解這些東西,我模擬了當一個函數執行時,js引擎作了哪些事情--那些咱們看不見的動做。javascript

關鍵詞:java

  • 執行環境
  • 做用域
  • 做用域鏈
  • 變量對象
  • 活動對象
  • 閉包
  • 垃圾回收

執行環境與做用域鏈

咱們都知道js的執行環境最外層是一個全局環境Global,在web瀏覽器的宿主環境下,window對象被認爲是全局執行環境。在後臺的nodejs環境global做爲全局變量也是咱們能夠直接訪問到的。
某個執行環境中全部代碼執行完畢後,該環境被銷燬,保存在其中的全部變量和函數定義也隨之銷燬(全局環境到應用退出--如關閉網頁或瀏覽器)node

每一個函數也有本身的執行環境,當執行流進入函數時,函數的環境被推入一個環境棧中,函數執行完畢以後,棧將其環境彈出,把控制權返回給以前的執行環境。web

當代碼在一個環境中執行時,會建立建立變量對象的一個做用域鏈
若是環境是個函數,則將其活動對象做爲變量對象。活動對象在最開始只包含一個變量,即arguments對象,做用域鏈的下一個變量對象來自下一個包含環境,一直延續到全局環境。數組

下面咱們模擬下這個過程。瀏覽器

var name = "eric";

function say(){
    var name = "xu";
    console.log(name);
}

say();//xu

輸出「xu」,而不是「eric」,這個咱們也許都很好理解,由於函數內部定義了局部同名變量name,而不會使用全局的name。上面的環境中包含全局變量namesay函數;當say執行時,js引擎作了些什麼。下面咱們模擬下引擎「偷偷」爲咱們作的事。閉包

做用域鏈的產生過程

首先say()執行時會建立一個執行環境,爲了形象一些,我這裏以三個大括號可視化表示一個執行環境。如:say(){{{...}}}函數

這個執行環境中會自動擁有一個特殊的內部屬性[[Scope]](爲了更好的理解,能夠把它想象成若是是全局環境的window,全局環境定義的變量和函數附着在這個變量上自動成爲window的屬性和方法,這樣的一個局部功能「局部內全局對象」。但其實局部的變量和函數會被附着在其活動對象上,活動對象又是做用域鏈第一個變量對象。)this

函數調用時與執行環境同時建立的就是相應的做用域鏈[[Scope Chain]],並賦值給特殊變量Scope;指針

//step 1:建立執行環境,爲了形象一些,我這裏以三個大括號可視化表示一個執行環境

{{{...}}}
//step 2:建立做用域鏈,並賦值給特殊變量Scope,咱們用數組來模擬這個做用域鏈,隨後我會解釋爲何用數組模擬

var ScopeChain = [
    FirstVariableObject,//函數內的變量對象
    SecondVariableObject //包含這個函數的外面一層的變量對象,在上面的例子中已是全局環境了。
]
Scope = ScopeChain;

在做用域鏈生成以前,其實還有步驟,那就是做用域鏈數組的兩個變量對象的生成。那這兩個變量對象是什麼呢?

其實第一個變量對象就是函數的活動對象【activation object】,這個活動對象能夠理解成這樣一個對象

ActivationObject = {
    arguments: []  //活動對象最開始僅包含arguments(就是函數內隱藏的arguments)
}

而後內部this根據環境,加入活動對象

ActivationObject = {
    arguments: [],  //活動對象最開始僅包含arguments(就是函數內隱藏的arguments)
    this: window    //這裏的this根據執行環境和調用對象的不一樣,會動態變化,上面的例子由於是全局環境執行的因此this指向window
}

而後開始尋找var的變量定義,或者函數聲明(咱們都知道的函數聲明會被提高)。
此時的活動對象變成:

//活動對象,即函數內部全部變量的綜合,會自動成爲第一個變量對象
ActivationObject = {
    arguments: [],
    this: window,
    name: undefined //注意引擎此時並不會初始化賦值,只有讀到賦值那一行時纔會賦值
}

這樣咱們就能很好的理解咱們熟悉的經典例子,爲何下面的console.log不會報錯,也不是輸出'xu',而是undefined

<script>
console.log(name);//undefined
var name = 'xu';
</script>

由於咱們的活動對象會自動變爲第一個活動對象,因此第一個變量對象就等於活動對象

FirstVariableObject = ActivationObject;

同理做用域中的第二個變量對象SecondVariableObject,或者咱們也能夠命名爲GlobalVariableObject,由於在上面的例子中已是全局環境了

//做用域鏈的第二個,也是最後一個(全局變量對象)
SecondVariableObject = {
    this: window,
    say: function (){...},
    name: "eric"
}

第二個變量對象不包含arguments,由於它是全局環境,而不是函數。say函數聲明被提高做爲window的全局方法,還有全局的name屬性。都被掛在第二層的做用域鏈的變量對象上。

至此做用域鏈建立完畢。做用域鏈會成爲這樣的好理解的樣子:

//形象的做用域鏈
Scope = ScopeChain = [
    {
        arguments: [],
        this: window,
        name: undefined
    },
    {
        this: window,
        say: function (){...},
        name: "eric"
    }
]

做用域鏈查找在js執行過程當中的模擬

而後js開始一句一句解析say函數的代碼,

第一句,var name = "xu"
此時,活動對象的name值纔會將undefined變爲'xu';

而後執行第二句console.log(name);
這句中有一個變量name,這個時候做用域鏈就該出場了。

js引擎會開始執行查找,首先從ActivationObject活動對象中開始找,由於通過var name = "eric";
此時做用域鏈的第一個,即活動對象已經變成

{
    arguments: [],
    this: window,
    name: 'xu'
}

因此輸出‘xu’,而不是‘eric’

若是咱們將say函數,作下改動以下:

var name = "eric";

function say(){
    var age = 99;
    console.log(name);
}

say();//eric

由於內部的沒有定義name變量,這個結果不出意料的咱們都知道,但這個過程我把它模擬成如下查找過程:

//從當前函數的活動對象開始,一層一層向上查找,直到頂層全局做用域
//break這句至關重要,當前這一層找到了,再也不向上一層找了。即在這一層環境中找到了變量name

for (var i=0;i<Scope.length;i++){
    if (name in Scope[i]){
        console.log(Scope[i].name);
        break;
    }
}

我以爲這段代碼,能夠很是形象的表達了做用域鏈的查找過程
即首先查找第一個變量對象,其實就是函數內部的活動對象,若是找到則不進行下一個變量對象的查找,若是內部函數沒有,纔會沿着做用域鏈找下一個值,直到頂層的全局環境。

這就是爲何我用數組去模擬做用域鏈的緣由,由於做用域鏈能夠理解是個有序列表(其實做用域鏈的本質就是指向變量對象的指針列表),查找過程是按順序查找的。

經過上面的形象化解釋,是否是很是好理解做用域和做用域鏈了呢!!!

垃圾回收

咱們都知道在函數執行完畢以後,內部的變量和內部定義的函數會隨之銷燬,也就是被垃圾回收機制所回收,以下:

function talk(){
    var name = 'eric';

    function say(){
        console.log(name);
    }

    say();
}
talk();

當talk函數執行後,內部的變量name和聲明的函數say會從內存中銷燬,但閉包的狀況就不會。如:

function createTalk(){
    var name = 'eric';
    var age = 99;
    return function (){
        var innerName = name;
        console.log(innerName);
    }

}
var talk = createTalk();
talk();

閉包中沒有釋放局部變量的緣由

閉包的本質實際上是有權訪問另外一個函數做用域中變量的函數

根據咱們上面模擬的做用域鏈模型,上面的例子中當talk執行時,整個做用域鏈能夠形象化爲:

ScopeChain = [
    {
        arguments:[],
        this: window,
        innerName: undefined
    },
    {
        arguments:[],
        this: window,
        name: eric,
        age: 99
    },
    {
        this: window,
        createTalk: function (){...},
        talk: function (){...} //內部return的匿名函數
    },
]

這樣當createTalk執行後,talk變量仍然保持了對函數內部變量和內部匿名函數的引用,所以即便createTalk執行完畢,雖然其執行環境被銷燬,但返回的匿名函數的做用域鏈被初始化爲createTalk()函數的活動對象和全局變量對象,內部變量仍然沒有被垃圾回收機制所回收。雖然返回的匿名函數,僅使用了外一層的name變量,而沒有使用age變量。但其內部保存的仍然是整個外層變量對象,即

{
    arguments:[],
    this: window,
    name: eric,
    age: 99
}

而不只僅是外層的name變量一個值,由於查找過程當中,使用的是整個的變量對象來查找的。由於是查找,因此存在遍歷整個對象的過程,而不是簡單的賦值

這就是爲何閉包會佔用更多的內存的緣由,由於其保存了整個變量對象。雖然咱們的例子可能就幾個,但在實際應用中可能存在很是多。
這也是咱們要謹慎使用閉包的緣由。

閉包的經典實例

接下來咱們看一個經典的閉包示例。

var result = [];

for (var i=0;i<10;i++){
    result[i] = function (){
        return i;
    }
}

結果或許你們都知道了,result數組的任何一個執行,都會返回10。下面咱們用上面模擬的做用鏈,形象話的看下,
好比result[9]()函數執行的初始化做用域鏈以下:

ScopeChain = [
    //第一層是內部匿名函數的變量對象
    {
        arguments:[],
        this: window
    },
    //第二層是外部的,也就是全局變量對象
    {
        this: window,
        result: [Array],
        i: 10 //此時全局環境的i已經通過for循環變成了10
    },
]

天然任何一個result的值調用函數,都會是返回10。
經過變形符合預期的閉包以下:

var result = [];

for (var i=0;i<10;i++){
    result[i] = function (num){
        return function (){
            return num;
        }
    }(i);
}

上面這個經典的閉包返回的就是咱們想要的各自的i,爲了更好理解,我仍是使用形象的做用域鏈。
當匿名函數執行時,看下它的初始做用域鏈:

ScopeChain = [
    //第一層爲傳入參數i的自執行函數
    {
        arguments:[],
        this: window,
    },
    {
        arguments:[num],
        num: 9, 
        this: window,
    }
    {
        this: window,
        result: [Array],
        i: 10
    }
]

咱們能夠理解爲多了一層做用域鏈的變量對象,使其能保留對num副本的引用,而不是對i的引用。

好了,經過深刻理解做用域鏈,咱們能跟好的理解js的運行機制和閉包的原理。

相關文章
相關標籤/搜索