【 js 基礎 】【讀書筆記】作用域和閉包
老生常談,我知道你已經煩了,但是這一篇不看,你會後悔的。
一、編譯過程
常見編譯性語言,在程序代碼執行之前會經歷三個步驟,稱為編譯。
步驟一:分詞或者詞法分析
將由字元組成的字元串分解成有意義的代碼塊,這些代碼塊被稱為詞法單元。
例子:
var a = 2;
這一句通常被分解成為下面這些詞法單元:var 、a 、 = 、2、; 。
步驟二:解析或者語法分析
將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程序語法結構的樹。這個樹被稱為「抽象語法樹」(Abstract Syntax Tree, AST)
例子:
var 、a 、 = 、2、; 會生成類似與下面的語法樹:
步驟三:代碼生成
將 抽象語法樹 (AST)轉換為可執行代碼。
然而對於解釋型語言(例如JavaScript)來說,通過詞法分析和語法分析得到語法樹,沒有生成可執行文件的這一過程,就可以開始解釋執行了。
對於 var a = 2; 進行處理的時候,會有 引擎、編譯器、還有作用域的參與。
引擎:從頭到尾負責整個 Javascript 程序的編譯及執行過程。編譯器:負責語法分析及代碼生成等。作用域:負責收集並維護由所有聲明的標識符(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符(變數)的訪問許可權。他們是這樣合作的:
首先編譯器會進行如下處理:1、var a,編譯器會從作用域中尋找是否已經有一個該名稱的變數存在於同一個作用域的集合中。如果是,編譯器會自動忽略該聲明,繼續進行編譯;否則它會要求作用域在當前作用域的集合中聲明一個新的變數,並命名為 a 。2、接下來編譯器會為引擎生成運行時所需的代碼,這些代碼用來處理 a = 2 這個賦值操作。引擎運行時會首先從作用域中查找 當前作用域集合中是否存在 變數 a。如果有,引擎就會使用這個變數。如果沒有,引擎就會繼續向上一級作用域集合中查找改變數。然後 如果引擎最終找到了 變數 a,就賦值 2 給它。如果沒有找到,就會拋出一個異常。
總結:
變數的賦值操作分兩步完成:第一步 由編譯器在作用域中聲明一個變數(前提是之前沒有聲明過),第二步 是在運行時引擎會在作用域中查找該變數,如果可以找到,就對其賦值。
二、作用域
1、RL 查詢在上一部分我們說到了,引擎會對變數 a 進行查找。而查找分為兩種,一是 LHS(Left-Hand-Side) 查詢,二是 RHS(Right-Hand-Side) 查詢。LHS 查詢:試圖找到變數的容器本身,從而可以對其賦值。也就是查找 變數 a 。RHS 查詢:查找某個變數的值。查找變數 a 的值,即 2。 例子:console.log(a); // 這裡對 a 是一個 RHS 查詢,找到 a 的值,並 console.log 出來。a = 2; // 這裡對 a 是一個 LHS 查詢,找到 變數 a,並對其賦值為 2 。function foo(a){ console.log(a); // 2}foo(2); // 這裡首先對 foo() 函數調用,執行 RHS 查詢,即找到 foo 函數,然後 執行了 a = 2 的傳參賦值,這裡首先執行 LHS 查詢 找到 a 並賦值為 2,然後 console.log(a) 執行了 RHS 查詢。
這裡還要說一下 作用域的嵌套:當一個塊或函數嵌套在另一個塊或函數中時,就發生了所用的嵌套。遍歷查找嵌套作用域,是首先從當前作用域中查找變數,如果找不到,就像上一級繼續查找,當抵達全局作用域時,無論找到還是沒有找到,查找都將結束。
如果 RHS 查找在所有嵌套作用域中都沒有找到所需變數,引擎就會拋出 ReferenceError。如果找到了所需變數,但你想要進行不合理的操作,比如對非函數類型的值進行調用等,引擎就會拋出 TypeError 。
如果 LHS 查找在頂層全局作用域中都沒有找到所需變數,如果是在非嚴格模式下,全局作用域會創建一個具有該名稱的變數,並將其返回給引擎,如果是在嚴格模式下,引擎就會拋出 ReferenceError。
ReferenceError 和 TypeError 是比較常見的異常,你需要知道它們的不同,對你排除程序問題有很大幫助。
2、詞法作用域
由你在寫代碼時將變數和塊作用域寫在哪裡來決定的。
例子:
function foo(a){ var b = a*2; function bar(c){ console.log(a,b,c); } bar(b*3);}foo(2); // 2,4,12
在這段代碼中有三層作用域,如圖所示嵌套:
作用域1:包含著全局作用域,其中有標識符:foo.
作用域2:包含著 foo 所創建的作用域,其中有三個標識符:a、 b、 bar。
作用域3:包含著 bar 所創建的作用域,其中有標識符:c。
在查找變數時,作用域查找會在找到第一個匹配的標識符時停止。而且它只查找一級標識符,比如a 、b、c,而對於 foo.bar.baz ,詞法作用域只會查找 foo 標識符,找到這個變數之後,對象屬性訪問規則會分別接管對 bar 和 baz 屬性的訪問。
這裡還要說一點,全局變數會自動成為全局對象的屬性,所以可以間接的通過全局對象屬性的引用來對其進行訪問。
window.a
3、提升
變數和函數在內的所有聲明都會在任何代碼被執行前首先被處理。
舉個例子:
當你看到 var a = 2; 時,可能會認為這是一個聲明,但實際上 Javascript 會將其看成兩個聲明:var a ; 和 a = 2;並且在不同階段執行。var a 是在編譯階段進行的,而 a = 2 會被留在原地等待執行階段。
這個過程就好像變數和函數聲明從它們在代碼中出現的位置被「移動」到了最上面,這個過程就叫做變數提升。
例子:
foo();function foo(){ console.log(a); var a = 2;}
學了上面的知識,你應該可以猜到 foo() 可以正常執行,而 console.log(a) 會打出 undefined; 原因是當把提升應用到上面代碼,代碼就相當於 下面的形式:
function foo() { var a; console.log(a); a = 2; }foo();
對於變數提升要注意另外兩個知識點:
1、函數聲明會被提升,而函數表達式卻不會被提升。
區分函數聲明和函數表達式最簡單的方式是看 function 關鍵字出現在聲明中的位置。如果 function 時聲明中的第一個詞,那麼就是函數聲明,否則就是一個函數表達式。
例子:
函數聲明:
function foo() { var a; console.log(a); a = 2; }
函數表達式:
var foo = function() { var a; console.log(a); a = 2; }(function foo(){ var a; console.log(a); a = 2; })();
那麼對於提升,來看個例子:
foo(); // 報TypeError錯誤var foo = function() { var a; console.log(a); a = 2; }
這段代碼相當於
var foo;foo(); // 此時 foo 為 undefined,而我們嘗試對它進行函數式調用,屬於不合理操作,報 TypeError 錯誤。foo = function() { var a; console.log(a); a = 2; }
2、函數會被優先提升,然後是變數。
例子:
foo(); // 1 var foo;function foo(){ console.log(1);}foo = function(){ console.log(2);}
會輸出 1 為不是 2,這段代碼提升之後相當於:
function foo(){ console.log(1);}foo(); foo = function(){ console.log(2);}
注意,var foo 儘管出現在 function foo() 之前,但它是重複的聲明,因為函數聲明會被提升到普通變數之前。重複的 var 聲明會被忽略,但出現在後面的函數聲明卻會覆蓋前面的。
例子:
foo(); // 3function foo(){ console.log(1);}var foo = function(){ console.log(2)}function foo(){ console.log(3)}
三、閉包
所謂 閉包:
當函數可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行。
例子:
function foo(){ var a = 2; function bar(){ console.log(a); } return bar;}var baz = foo();baz(); //2
例子中,通過調用 baz 來調用 foo 內部的 bar , bar 在自己定義的詞法作用域以外的地方執行,在 foo 執行之後,通常會期待 foo 的整個內部作用域被銷毀,因為引擎的垃圾回收器會釋放不再使用的內存空間。看上去 foo 不再被使用,所以很自然的考慮到對其進行回收,然而閉包就是阻止這樣的事情發生,事實上內部作用域依然存在,沒有被回收,因為 bar 依然在使用該作用域。
bar 擁有 涵蓋 foo 內部作用域的閉包,使得該作用域能夠一直存活,以供 bar 在之後任何時間進行引用。
bar 依然持有對該作用域的引用,而這個引用就叫作 閉包。
bar 在定義時的詞法作用域以外的地方被調用,閉包使得函數可以繼續訪問定義時的作用域。
在舉個例子:
function wait(message){ setTimeout(function timer(){ console.log(message) },1000)}wait("hi");
timer 具有 涵蓋 wait 作用域的閉包,保有對 message 的引用。
wait 執行 1s 後,它的內部作用域不會消失,timer 依然保有 wait 作用域的閉包,所以 可以獲得 message 並 console.log,這就是閉包。
也就是說,如果將函數作為第一級的值類型併到處傳遞,你就會看到閉包在這些函數中的應用。像定時器、事件監聽器、Ajax 請求、跨窗口通信或者任何非同步任務中,只要使用了回調函數,實際上就是在使用閉包。
例子:
function foo(){ var a = 2; function baz(){ console.log(a); // 2 } bar(baz);}function bar(fn){ fn(); //閉包}
內部函數 baz 傳遞給 bar,當在 bar 中調用 baz時,就可以訪問到他定義時所在的作用域中的變數,console.log 出 a。
例子:
for (var i=0;i<=5;i++){ setTimeout(function timer(){ console.log(i) },i*1000)}
我們預期輸出數次1-5,每秒一次,每次一個。
然而 真正的 輸出結果卻是 六個 6。
原因是 timer 在 循環結束之後即 i 等於 6 時, 才執行,就算你將 setTimeout 的時間 設為 0 ,回調函數也會在循環結束之後才執行。
那麼我們應該怎麼解決呢?
我們希望每次循環時,timer 都會給自己捕獲一個 i 的副本,然而根據作用域的原理,實際情況卻是 儘管 循環的 六 個函數是在各個迭代中被分別定義的,但是它們都封閉在一個共享的全局作用域中,因此實際上只有一個 i,所有函數共享一個 i 的引用。如果將延遲函數的回調重複定義六次,不使用循環,那它同這段代碼完全等價的。
所以解決辦法就是 循環的過程中每個迭代都需要一個閉包作用域。而 立即執行函數 正好可以做到這一點。
for (var i=0;i<=5;i++){ (function(i){ setTimeout(function timer(){ console.log(i) },i*1000) })(i)}
在循環中使用 立即執行函數會為每個迭代生成一個新的作用域,使得延遲函數的回調可以將新的作用域封閉在每個迭代的內部,每個迭代都會含有一個正確的 i 等待 console。
問題解決。
現在 你應該真正的明白 作用域和閉包了,找點題做吧,加深一下印象,不然你還會回來的。
補充知識:
評論區,有銀提到 es6 中的 let 和 var 的區別,這裡說一下:
let 關鍵字會將變數綁定到所在的任意作用域(通常是{.....}內部)。換句話說,let 為其聲明的變數隱式地劫持了所在的作用域。但這裡要注意的一點 使用 let 進行聲明不會在塊作用域中進行提升,聲明的代碼被運行之前,聲明並不存在。舉幾個例子,就很清楚了。
var a = true ;if(a){ let bar = 3; var test = 2; console.log(bar); // 3 console.log(test); // 2}console.log(bar); // ReferenceErrorconsole.log(test); // 2
var for 循環例子:
for(var i=0;i<2;i++){ console.log(i);//0,1,2}console.log(i); // 2
let for 循環例子:
for(let i=0;i<2;i++){ console.log(i);//0,1,2}console.log(i); // ReferenceError
聲明提升例子:
{ console.log(bar);//ReferenceError let bar = 3;}
所以針對 settimeout 的那道閉包題你知道另一種解決辦法了嗎?
for(let i=0;i<10;i++){ setTimeout(function(){ console.log(i); },1000)}
試一下。
公眾號:
佳怡所思(ljyFEer)
http://weixin.qq.com/r/uj9tdeDEPL1DraQW92qo (二維碼自動識別)
歡迎關注~
學習並感謝:
《你不知道的JavaScript》上卷 (炒雞推薦大家看)推薦閱讀:
※前端技術體系大局觀
※React Fiber是什麼
※setState:這個API設計到底怎麼樣
※從零學習前端開發·CSS
※1.1 React 介紹