學(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é)合圖實例理解
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é)合圖實例
首先是全局上下文入棧,然后執(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)部的變量。