對JavaScript內(nèi)存 執(zhí)行上下文 作用域鏈 閉包.....等的深入理解

學(xué)習(xí)前端也有一段時間了,發(fā)現(xiàn)自己對 作用域鏈 閉包...等一些概念雖然貌似理解會用了,但是可謂知其然不知其所以然,總感覺不太靠譜,所以參考了一些前輩的博客和加上自己的實踐,寫下這篇文章,來加強(qiáng)對這些概念的理解 (暫不包括es6);

內(nèi)存(堆與棧)

由于JavaScript存在垃圾自動回收機(jī)制,所以我們在開發(fā)中并不用像C和C++之類語言一樣手動去跟蹤內(nèi)存使用情況,所以很多初學(xué)者就忽略了這個問題,但是我發(fā)現(xiàn)如果真的對內(nèi)存空間一無所知,對理解一些JavaScript中的概念比如基本類型引用數(shù)據(jù)類型的區(qū)別;比如淺拷貝深拷貝什么不同?還有閉包,原型等是很模糊的。

JavaScript中并沒有嚴(yán)格意義上區(qū)分棧內(nèi)存與堆內(nèi)存。因此我們可以粗淺的理解為JavaScript的所有數(shù)據(jù)都保存在堆內(nèi)存中。但是在某些場景,我們?nèi)匀恍枰诙褩?shù)據(jù)結(jié)構(gòu)的思路進(jìn)行處理,比如JavaScript的在邏輯上實現(xiàn)了堆棧。因此理解堆棧數(shù)據(jù)結(jié)構(gòu)的原理與特點任然十分重要。

  • 棧的存取方式先進(jìn)后出,后進(jìn)先出(JavaScript中有5種基礎(chǔ)數(shù)據(jù)類型,分別是Undefined、Null、Boolean、Number、String保存在棧內(nèi)存中)

  • 堆存取數(shù)據(jù)方式是無序的,但并不影響我們使用,就像JSON格式的數(shù)據(jù),我們知道key就能準(zhǔn)確拿到value
    引用類型值(對象、數(shù)組、函數(shù)、正則)保存在堆內(nèi)存中的對象,變量中保存的實際上只是一個指針,這個指針執(zhí)行內(nèi)存中的另一個位置,由該位置保存對象。)

                                                      結(jié)合圖實例理解
    
stack.PNG
       var num1 = 1;
       var num2= num1; //b賦值a,只是簡單的數(shù)值的拷貝,他們相互獨立,互不影響
       num1=3;
       console.log(num2); //1

   var obj1 = {name:'chris',age:'23'};
   var obj2 = obj1;                                            
   obj1.name = 'xxx';
    console.log(obj2); //  {name:'xxx',age:'23'}
    // obj1賦給obj2的是指針(指向內(nèi)存的地址),當(dāng)?shù)刂分羔樝嗤瑫r,盡管他   
    //們相互獨立,但是在變量對象中訪問到的具體對象實際上是同一個。如圖所示。  

執(zhí)行上下文(Execution Context)

執(zhí)行上下文可以理解為當(dāng)前代碼的執(zhí)行環(huán)境,它會形成一個作用域。JavaScript中的運行環(huán)境大概包括三種情況。

  • 全局環(huán)境:JavaScript代碼運行起來會首先進(jìn)入該環(huán)境
  • 函數(shù)環(huán)境:當(dāng)函數(shù)被調(diào)用執(zhí)行時,會進(jìn)入當(dāng)前函數(shù)中執(zhí)行代碼
  • eval(不常用)
    因此在一個JavaScript程序中,必定會產(chǎn)生多個執(zhí)行上下文,JavaScript引擎會以堆棧的方式來處理它們,這個堆棧,我們稱其為函數(shù)調(diào)用棧(call stack)。棧底永遠(yuǎn)都是全局上下文,而棧頂就是當(dāng)前正在執(zhí)行的上下文。
    結(jié)合圖實例
context.PNG

首先是全局上下文入棧,然后執(zhí)行代碼,直到遇到read(),激活read函數(shù)并且創(chuàng)建了它自己的執(zhí)行上下文
第二步read的執(zhí)行上下文入棧,執(zhí)行代碼,遇到say(),激活say函數(shù)并且創(chuàng)建了它自己的執(zhí)行上下
第三步say的執(zhí)行上下文入棧,執(zhí)行代碼
第四步在say的可執(zhí)行代碼中,再沒有遇到其他能生成執(zhí)行上下文的情況,因此這段代碼順利執(zhí)行完畢,say的上下文從棧中彈出。
第五步say的執(zhí)行上下文彈出之后,繼續(xù)執(zhí)行readr的可執(zhí)行代碼,也沒有再遇到其他執(zhí)行上下文,順利執(zhí)行完畢之后彈出。這樣就只身下全局上下文了(關(guān)閉瀏覽器出棧)

  function read() {
      console.log(xxx)
  function say() {
      console.log(xxx)
  }
    say();
}  
 read();

一、基礎(chǔ)概念回顧
函數(shù)在被調(diào)用執(zhí)行時,會創(chuàng)建一個當(dāng)前函數(shù)的執(zhí)行上下文。在該執(zhí)行上下文的創(chuàng)建階段,變量對象、作用域鏈、閉包、this指向會分別被確定。而一個JavaScript程序中一般來說會有多個函數(shù),JavaScript引擎使用函數(shù)調(diào)用棧來管理這些函數(shù)的調(diào)用順序。函數(shù)調(diào)用棧的調(diào)用順序與棧數(shù)據(jù)結(jié)構(gòu)一致。
二、認(rèn)識斷點調(diào)試工具
在盡量新版本的chrome瀏覽器中(不確定你用的老版本與我的一致),調(diào)出chrome瀏覽器的開發(fā)者工具。
瀏覽器右上角豎著的三點 -> 更多工具 -> 開發(fā)者工具 -> Sources

界面如圖。


斷點調(diào)試界面

在我的demo中,我把代碼放在app.js中,在index.html中引入。我們暫時只需要關(guān)注截圖中紅色箭頭的地方。在最右側(cè)上方,有一排圖標(biāo)。我們可以通過使用他們來控制函數(shù)的執(zhí)行順序。從左到右他們依次是:
resume/pause script execution恢復(fù)/暫停腳本執(zhí)行

step over next function call跨過,實際表現(xiàn)是不遇到函數(shù)時,執(zhí)行下一步。遇到函數(shù)時,不進(jìn)入函數(shù)直接執(zhí)行下一步。

step into next function call跨入,實際表現(xiàn)是不遇到函數(shù)時,執(zhí)行下一步。遇到到函數(shù)時,進(jìn)入函數(shù)執(zhí)行上下文。

step out of current function跳出當(dāng)前函數(shù)

deactivate breakpoints停用斷點

don‘t pause on exceptions不暫停異常捕獲

其中跨過,跨入,跳出是我使用最多的三個操作。
上圖右側(cè)第二個紅色箭頭指向的是函數(shù)調(diào)用棧(call Stack),這里會顯示代碼執(zhí)行過程中,調(diào)用棧的變化。
右側(cè)第三個紅色箭頭指向的是作用域鏈(Scope),這里會顯示當(dāng)前函數(shù)的作用域鏈。其中Local表示當(dāng)前的局部變量對象,Closure表示當(dāng)前作用域鏈中的閉包。借助此處的作用域鏈展示,我們可以很直觀的判斷出一個例子中,到底誰是閉包,對于閉包的深入了解具有非常重要的幫助作用。
三、斷點設(shè)置
在顯示代碼行數(shù)的地方點擊,即可設(shè)置一個斷點。斷點設(shè)置有以下幾個特點:
在單獨的變量聲明(如果沒有賦值),函數(shù)聲明的那一行,無法設(shè)置斷點。

設(shè)置斷點后刷新頁面,JavaScript代碼會執(zhí)行到斷點位置處暫停執(zhí)行,然后我們就可以使用上邊介紹過的幾個操作開始調(diào)試了。

當(dāng)你設(shè)置多個斷點時,chrome工具會自動判斷從最早執(zhí)行的那個斷點開始執(zhí)行,因此我一般都是設(shè)置一個斷點就行了。

四、實例
接下來,我們借助一些實例,來使用斷點調(diào)試工具,看一看,我們的demo函數(shù),在執(zhí)行過程中的具體表現(xiàn)。

     // demo01
 var fn;
  function foo() {
    var a = 2;
   function baz() { 
        console.log( a );
  }
  fn = baz; 
}
function bar() {
  fn(); 
}

foo();
bar(); // 2

在向下閱讀之前,我們可以停下來思考一下,這個例子中,誰是閉包?
這是來自《你不知道的js》中的一個例子。由于在使用斷點調(diào)試過程中,發(fā)現(xiàn)chrome瀏覽器理解的閉包與該例子中所理解的閉包不太一致,因此專門挑出來,供大家參考。我個人更加傾向于chrome中的理解。
第一步:設(shè)置斷點,然后刷新頁面。

設(shè)置斷點

第二步:點擊上圖紅色箭頭指向的按鈕(step into),該按鈕的作用會根據(jù)代碼執(zhí)行順序,一步一步向下執(zhí)行。在點擊的過程中,我們要注意觀察下方call stack 與 scope的變化,以及函數(shù)執(zhí)行位置的變化。

一步一步執(zhí)行,當(dāng)函數(shù)執(zhí)行到上例子中


baz函數(shù)被調(diào)用執(zhí)行,foo形成了閉包

我們可以看到,在chrome工具的理解中,由于在foo內(nèi)部聲明的baz函數(shù)在調(diào)用時訪問了它的變量a,因此foo成為了閉包。這好像和我們學(xué)習(xí)到的知識不太一樣。我們來看看在《你不知道的js》這本書中的例子中的理解。


你不知道的js中的例子

書中的注釋可以明顯的看出,作者認(rèn)為fn為閉包。即baz,這和chrome工具中明顯是不一樣的。
而在備受大家推崇的《JavaScript高級編程》一書中,是這樣定義閉包。


JavaScript高級編程中閉包的定義

書中作者將自己理解的閉包與包含函數(shù)所區(qū)分

這里chrome中理解的閉包,與我所閱讀的這幾本書中的理解的閉包不一樣。具體這里我先不下結(jié)論,但是我心中更加偏向于相信chrome瀏覽器。
我們修改一下demo01中的例子,來看看一個非常有意思的變化。

 / / demo02
  var fn;
  var m = 20;
function foo() {
    var a = 2;
function baz(a) { 
    console.log(a);
}
fn = baz; 
}
function bar() {
    fn(m); 
}

foo();
bar(); // 20

這個例子在demo01的基礎(chǔ)上,我在baz函數(shù)中傳入一個參數(shù),并打印出來。在調(diào)用時,我將全局的變量m傳入。輸出結(jié)果變?yōu)?0。在使用斷點調(diào)試看看作用域鏈。


閉包沒了,作用域鏈中沒有包含foo了。

是不是結(jié)果有點意外,閉包沒了,作用域鏈中沒有包含foo了。我靠,跟我們理解的好像又有點不一樣。所以通過這個對比,我們可以確定閉包的形成需要兩個條件。
在函數(shù)內(nèi)部創(chuàng)建新的函數(shù);
新的函數(shù)在執(zhí)行時,訪問了函數(shù)的變量對象;

還有更有意思的。
我們繼續(xù)來看看一個例子。

     // demo03
  function foo() {
     var a = 2;
     return function bar() {
    var b = 9;

    return function fn() {
        console.log(a);
      }
    }
}

var bar = foo();
var fn = bar();
fn();

在這個例子中,fn只訪問了foo中的a變量,因此它的閉包只有foo。


閉包只有foo

修改一下demo03,我們在fn中也訪問bar中b變量試試看。

  // demo04
function foo() {
   var a = 2;

return function bar() {
    var b = 9;

    return function fn() {
        console.log(a, b);
    }
 }
}

var bar = foo();
var fn = bar();
fn();

這個時候閉包變成了兩個

這個時候,閉包變成了兩個。分別是bar,foo。
我們知道,閉包在模塊中的應(yīng)用非常重要。因此,我們來一個模塊的例子,也用斷點工具來觀察一下。

 // demo05
 (function() {
var a = 10;
var b = 20;

var test = {
    m: 20,
    add: function(x) {
        return a + x;
    },
    sum: function() {
        return a + b + this.m;
    },
    mark: function(k, j) {
        return k + j;
    }
}

window.test = test;

})();

test.add(100);
test.sum();
test.mark();

var _mark = test.mark;
_mark();

add執(zhí)行時,閉包為外層的自執(zhí)行函數(shù),this指向test

sum執(zhí)行時,同上

mark執(zhí)行時,閉包為外層的自執(zhí)行函數(shù),this指向test

_mark執(zhí)行時,閉包為外層的自執(zhí)行函數(shù),this指向window

注意:這里的this指向顯示為Object或者Window,大寫開頭,他們表示的是實例的構(gòu)造函數(shù),實際上this是指向的具體實例
test.mark能形成閉包,跟下面的補(bǔ)充例子(demo07)情況是一樣的。

我們還可以結(jié)合點斷調(diào)試的方式,來理解那些困擾我們很久的this指向。隨時觀察this的指向,在實際開發(fā)調(diào)試中非常有用。

 var a = 10;
 var obj = {
  a: 20
}

function fn () {
    console.log(this.a);
  }

    fn.call(obj); // 20

this指向obj

補(bǔ)充一個例子

// demo07

   function foo() { 
      var a = 10; 
     function fn1() { 
         return a;
   }
      function fn2() {
            return 10;
         } 
       fn2();
 } 
      foo();

這個例子,和其他例子不太一樣。雖然fn2并沒有訪問到foo的變量,但是foo執(zhí)行時仍然變成了閉包。而當(dāng)我將fn1的聲明去掉時,閉包便不會出現(xiàn)了。我暫時也不知道應(yīng)該如何解釋這種情況。只能大概知道與fn1有關(guān),可能瀏覽器在實現(xiàn)時就認(rèn)為只要存在訪問上層作用域的可能性,就會被當(dāng)成一個閉包吧。所以暫時就只能將它作為一個特例記住。
更多的例子,大家可以自行嘗試,總之,學(xué)會了使用斷點調(diào)試之后,我們就能夠很輕松的了解一段代碼的執(zhí)行過程了。這對快速定位錯誤,快速了解他人的代碼都有非常巨大的幫助。大家一定要動手實踐,把它給學(xué)會。
最后,根據(jù)以上的摸索情況,再次總結(jié)一下閉包:
閉包是在函數(shù)被調(diào)用執(zhí)行的時候才被確認(rèn)創(chuàng)建的。

閉包的形成,與作用域鏈的訪問順序有直接關(guān)系。

只有內(nèi)部函數(shù)訪問了上層作用域鏈中的變量對象時,才會形成閉包,因此,我們可以利用閉包來訪問函數(shù)內(nèi)部的變量。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容