<! TOC "作用域與閉包" "什麼是作用域" "編譯器" "理解作用域" "嵌套的作用域" "詞法作用域" "詞法分析時" "欺騙詞法作用域" "函數與塊作用域" "函數中的作用域" "隱藏標識符於普通作用域" "函數作為作用域" "塊作為作用域" "提升" "先有雞還是先有蛋?" "編譯器再次 ...
什麼是作用域
作用域是一組定義在何處儲存變數以及如何訪問變數的規則。
編譯器
javascript 是編譯型語言。但是與傳統編譯型語言不同,它是邊編譯邊執行的。編譯型語言一般從源碼到執行會經歷三個步驟:
- 分詞/詞法分析
將一連串字元串打斷成有意義的片段,成為 token(記號)。
- 解析
將一個 token 流(數組)轉化為一個嵌套元素的樹,即抽象語法樹(AST)。
- 代碼生成
將抽象語法樹轉化為可執行的代碼。其實是轉化成機器指令。
比如var a = 1
的編譯過程:
- 分詞/詞法分析:
var a = 1
這段程式可能會被打斷成如下 token:var
、a
、=
、1
,空格保留與否得看其是否具有意義。 - 解析:將第一步的 token 形成抽象樹:大致如下:
變數聲明: { 標識符: a 賦值表達式: { 數字字面量: 1 } }
- 代碼生成: 轉化成機器命令:創建一個稱為 a 的變數,並分配記憶體,存入一個值為數字 1。
理解作用域
作用域就是通過標識符名稱查詢變數的一組規則。
代碼解析運行中的角色:
- 引擎
負責代碼的編譯和程式的執行。
- 編譯器
協助引擎,主要負責解析和代碼生成。
- 作用域
協助引擎,收集並維護一張所有被聲明的標識符(變數)的列表,並對當前執行的代碼如何訪問這些變數強制實施一組嚴格的規則。
比如var a = 1
的運行:
- 編譯器遇到
var a
,會首先讓作用域去查詢 a 是否已經存在,存在則忽略,不存在,則讓作用域創建它; - 編譯器遇到
a = 1
,會編譯成引擎稍後需要運行的代碼; - 引擎執行編譯後的代碼,會讓當前查看是否存在變數
a
可以訪問,存在則引用這個變數,不存在則查看其他其他。
上面過程中,引擎會對變數進行查詢,而查詢分為 RHS(right-hand Side)查詢 和 LHS(left-hand Side)查詢,它們根據變數出現在賦值操作的左手邊還是右手邊來判斷查詢方式。
- RHS
變數在賦值的右手邊時採用這種方式查詢,查不到會拋出錯誤 referenceError
- LHS
變數在賦值的左手邊時採用這種方式查詢,在非嚴格模式下,查不到會再頂層作用域創建這個變數
嵌套的作用域
實際工作中,通常會有多於一個的作用域需要考慮,會存在作用域嵌套在其他作用域中的情況。
嵌套作用域的規則:
從當前作用域開始查找,如果沒有,則向上走一級繼續查找,以此類推,直至到了最外層全局作用域,無論找到與否,都會停止。
詞法作用域
作用域的工作方式一般有倆種模型:詞法作用域和動態作用域。javascript 所採用的是詞法作用域。
詞法分析時
詞法作用域是在詞法分析時被定義的作用域。
上述定義的潛在含義即:詞法作用域是基於寫程式時變數和作用域的塊兒在何處被編寫所決定的。公認的最佳實踐是將詞法作用域看作是僅僅依靠詞法的。
查詢變數:
引擎查找標識符時會在當前作用域開始一直向最外層作用域查找,一旦匹配到第一個,作用域查詢便停止。
相同名稱的標識符可以在嵌套作用域的多個層中被指定,這成為“遮蔽”。
不管函數是從哪裡被調用、如何調用,它的詞法作用域是由這個函數被聲明的位置唯一定義的。
欺騙詞法作用域
javascript 提供了在運行時修改詞法作用域的機制——with 和 eval,它們會欺騙詞法作用域。實際工作中,這種做法並不被推薦,應當儘量避免使用。
欺騙詞法作用域會導致更低下的性能。
引擎在編譯階段會對代碼做許多優化工作,比如靜態地分析代碼。但如果代碼存在 eval 和 with,導致詞法作用域的不固定行為,這一切的優化都有可能毫無意義,所以引擎就會簡單地不做任何優化。
- eval
eval函數
接收一個字元串作為參數,併在運行時將該字元串的內容在當前位置運行。
function foo(str, a) {
eval(str); // 作弊!
console.log(a, b);
}
var b = 2;
foo("var b = 3", 1); //1,3
上面的代碼,var b = 3
會再 eval 位置運行,從而在 foo 作用域內創建了變數b
。當console.log(a,b)
調用發生時,引擎會直接訪問 foo 作用域內的b
,而不會再訪問外部的b
變數。
註意:使用嚴格模式,在 eval 中作出的聲明不會實際上修改包圍他的作用域
- with
我們通常使用 with 來引用一個對象的多個屬性。
var obj = {
a: 1,
b: 2,
c: 3
};
with (obj) {
a = 3;
b = 4;
c = 5;
}
console.log(obj); //{a: 3, b: 4, c: 5}
但是,with 會做的事,比這要多得多。
var o1 = { a: 3 };
var o2 = { b: 3 };
function foo(obj) {
with (obj) {
a = 2;
}
}
foo(o1);
console.log(o1.a); //2
foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 全局作用域泄漏
with 語句接受一個對象,並將這個對象視為一個完全隔離的詞法作用域。
但是 with 塊內部的一個普通的var
聲明並不會歸於這個with
塊兒的作用域,而是歸於包含它的函數作用域。
所以,上面代碼執行foo(o2)
時,在執行到 a = 2
時,引擎會進行 LHS查找
,但是一直到最外層都沒有找到 a 變數,所以會在最外層創建這個變數,這裡就造成了作用域泄漏。
函數與塊作用域
javascript 中是不是只能通過函數創建新的作用域,有沒有其他方式/結構創建作用域?
函數中的作用域
javascript 擁有基於函數的作用域
函數作用域支持著這樣的想法:所有變數都屬於函數,而去貫穿整個函數都可以使用或重用(包括嵌套的作用域中)。
這樣以來,一個聲明出現在作用域何處是無關緊要的。
隱藏標識符於普通作用域
我們可以通過將變數和函數圍在一個函數的作用域中來“隱藏”它們。
為什麼需要“隱藏”變數和函數?
如果允許外圍的作用域訪問一個工作的私有細節,不僅沒必要,而且可能是危險的。所以軟體設計中有一個最低許可權原則原則:
最低許可權原則:也稱“最低授權”/“最少曝光”,在軟體設計中,比如一個模塊/對象的 API,你應當只暴露所需要的最低限度的東西,而隱藏其他一切。
將變數和函數隱藏可以避免多個同名但用處不同的標識符之間發生無意的衝突,從而導致值被意外的覆蓋。
實際可操作的方式:
- 全局命名空間
在引用多個庫時,如果他們沒有隱藏內部/私有函數和變數,那麼它們十分容易出現相互衝突。所以,這些庫通常會在全局作用域中使用一個特殊的名稱來創建一個單讀的變數聲明。它經常是一個對象,然後這個對象被用作這個庫一個命名空間
,所有要暴露出來的功能都會作為屬性掛載在這個對象上。
比如,Jquery 的對象就是 jquery/$;
- 模塊管理
實現命名衝突的另一種方式是模塊管理。
函數作為作用域
聲明一個函數,可以拿來隱藏函數和變數,但這種方式同時也存在著問題:
- 不得不聲明一個命名函數,這個函數的標識符名稱本身就污染了外圍作用域
- 不得不通過名稱明確地調用這個函數
不需要名稱,又能自動執行的,js 恰好提供了這樣一種方式。
(function(){
...
})()
上面的代碼使用了匿名函數和立即調用函數表達式:
- 匿名函數
函數表達式可以匿名,函數聲明不能匿名。
匿名函數的缺點:
- 在棧中沒有有用的名稱可以表示,調試困難;
- 想要遞歸自己(arguments.callee)或者解綁事件處理器變得麻煩
- 更不易代碼閱讀
最佳的方式總是命名你的函數表達式。
- 立即調用函數表達式
通過一個()
,我們可以將函數作為表達式。末尾再加一個括弧可以執行這個函數表達式。這種模式被成為 IIFE(立即調用函數表達式;Immediately Invoked Function Expression)
塊作為作用域
大部門語言都支持塊級作用域,從而將信息隱藏到我們的代碼塊中,塊級作用域是一種擴展了最低許可權原則
的工具。
但是,錶面上看來 javascript 沒有塊級作用域。
for (var i = 0; i < 10; i++) {
console.log(i);
}
console.log(i); // 10 變數i被劃入了外圍作用域中
if (true) {
var bar = 9;
console.log(bar); //9
}
console.log(bar); //9 // 變數bar被劃入了外圍作用域中
但也有特殊情況:
- with
它從對象中創建的作用域僅存在於這個 with 語句的生命周期中。
- try/catch
ES3 明確指出 try/catch 中的 cathc 子語句中聲明的變數,是屬於 catch 塊的塊級作用域。
js try { var a = 1; } catch (e) { var c = 2; } console.log(a); //1 console.log(c); //undefined
- let/const
let 將變數聲明依附在它所在的塊兒(通常是{...})作用域中。
- 隱含使用現存得塊兒
js if (true) { let bar = 1; console.log(bar); //1 } console.log(bar); // ReferenceError
- 創建明確塊兒
js if (true) { { // 明確的塊兒 let bar = 1; console.log(bar); //1 } } console.log(bar); // ReferenceError
const 也創建一個塊級作用域,但是它的值是固定的(常量)。
註意: let/const 聲明不進行變數提升。
塊級作用域的用處:
- 垃圾回收
可以處理閉包和釋放記憶體的垃圾回收。
js function process() { // do something } var bigData = {...}; // 大體量數據 process(bigData); var btn = document.getElementById('btn'); btn.addEventListener("click",function(e){ console.log('btn click'); })
點擊事件的回調函數根本不需要 bigData 這個大體量數據。理論上講,在執行完 process 函數後,這個消耗巨大記憶體的數據結構應該被作為垃圾而回收。然而因為 click 函數在整個函數作用域上擁有一個閉包,bigData 將會仍然保持一段事件。
塊級作用域可以解決這個問題:
js function process() { // do something } { let bigData = {...}; // 大體量數據 process(bigData); } var btn = document.getElementById('btn'); btn.addEventListener("click",function(e){ console.log('btn click'); })
- 迴圈
對每一次迴圈的迭代重新綁定。
js for (let i = 0; i < 10; i++) { console.log(i); } console.log(i); // ReferenceError
也可以這樣:
js { let j; for (j = 0; i < 10; i++) { let i = j; // 每次迭代重新綁定 console.log(i); } }
提升
函數作用域還是塊級作用域的行為都依賴於一個相同的規則: 在一個作用域中聲明的任何變數都附著在這個作用域上。
但是出現一個作用域內各種位置的聲明如何依附作用域?
先有雞還是先有蛋?
我們傾向於認為代碼是自上而下地被解釋執行的。這大致上是對的,但也有一部分並非如此。
a = 2;
var a;
console.log(a); // 2
如果代碼自上而下的解釋運行,預期應該輸出 undefined
,因為 var a
在 a = 2
之後,應該重新定義了變數 a。顯然,結果並不是如此。
console.log(a); // undefined
var a = 2;
從上面的例子上,你也許會猜測這裡會輸出 2,或者認為這裡會導致一個 ReferenceError 被拋出。不幸的是,結果卻是 undefined。
代碼究竟如何執行,是先有聲明還是賦值?
編譯器再次襲來
我們知道,引擎在 javascript 執行代碼之前會先對代碼進行編譯,編譯的其中一個工作就是找到所有的聲明,並將它關聯在合適的作用域上。
所以,在我們的代碼被執行前,所有的聲明,包括變數和函數,都會被首先處理。
對於var a = 2
,我們認為是一個語句,但 javascript 實際上認為這是倆個語句:var a
和 a = 2
。第一句(聲明)會在編譯階段處理,第二句(賦值)會在執行階段處理。
知道了這些,我想對於上一節的疑惑也就迎刃而解了:先有聲明,後有賦值。
註意:提升是以作用域為單位的
函數聲明會被提升,但是表達式不會。
foo(); // 1
goo(); // TypeError
function foo() {
console.log(1);
}
var goo = function() {
console.log(2);
};
變數 goo 被提升了,但表達式沒有,所以調用 goo 時,goo 的值為 undefined。所以會報 TypeError。
函數優先
函數聲明和變數都會提升。但是函數享有更高的優先順序。
console.log(typeof foo); // function
var foo = 2;
function foo() {
console.log(1);
}
從上面代碼可以看出,結果輸出 function 而不是 undefined 。說明函數聲明優先於變數。
重覆聲明,後面的會覆蓋前面的。
作用域閉包
必須要對作用域有健全和堅實的理解才能理解閉包。
啟蒙
在 javascript 中閉包無處不在,你只是必須認出它並接納它。它是依賴於詞法作用域編寫代碼而產生的結果。
事實真相
閉包就是函數能夠記住並訪問它的詞法作用域,即使當這個函數在他的詞法作用域之外執行時
function foo() {
var a = 2;
function bar() {
console.log(2);
}
bar();
}
這種形式算閉包嗎?技術上算,它實現了閉包,函數 bar 在函數 foo 的作用域上有一個閉包,即 bar 閉住了 foo 的作用域。但是在上面代碼中並不是可以嚴格地觀察到。
function foo() {
var a = 2;
function bar() {
console.log(2);
}
return bar;
}
var baz = foo();
baz(); //2 這樣使用才算真正意義上的閉包
bar 對於 foo 內的作用域擁有此法作用域訪問權,當我們調用 foo 之後返回 bar 的引用。按理來說,foo 執行過後,我們一般會期望 foo 的整個內部作用域消失,因為垃圾回收機制會自動回收不再使用的記憶體。但 bar 擁有一個詞法作用域的閉包,覆蓋著 foo 的內部作用域,閉包為了能使 bar 在以後的任意時刻可以引用這個作用域而保持的它的存在。
所以,bar 在詞法作用域之外依然擁有對那個作用域的引用,這個引用稱為閉包。
閉包使一個函數可以繼續訪問它在編寫時被定義的詞法作用域。
var a = 2;
function bar() {
console.log(a);
}
function foo(fn) {
fn(); // 發現閉包!
}
foo(bar);
上面的代碼,函數作為參數被傳遞,實際上這也是一種觀察/使用閉包的例子。
無論我們使用什麼方法將一個函數傳送到它的詞法作用域之外,它都將維護一個指向它被聲明時的作用域的引用。
迴圈 + 閉包
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i); // 5
}, i * 1000);
}
這段代碼的預期是每隔一秒分別列印數字:1,2,3,4,5。但是我們執行後發現結果一共輸出了 5 次 6。
為什麼達不到預期的效果?
定時器的回調函數會在迴圈完成之後執行(詳見事件迴圈機制)。而 for 不是塊級作用域,所以每次執行 timer 函數的時候,它們的閉包都在全局作用域上。而此時全局作用域環境中的變數 i 的值為 6。
我們的代碼缺少了什麼?
因為每一個 timer 函數執行的時候都是使用全局作用域,所以訪問的變數必然是一致的,所以想要達到預期的結果,我們必須為每一個 timer 函數創建一個私有作用域,併在這個私有作用域記憶體在一個可供回調函數訪問的變數。現在我們來改寫一下:
for (var i = 1; i <= 5; i++) {
(function() {
let j = i;
setTimeout(function() {
console.log(j); // 1,2,3,4,5
}, i * 1000);
})();
}
我們使用 IIFE 為每次迭代創建新的作用域,並且保存每次迭代需要的值。
其實這裡主要用到的原理是使用塊級作用域,所以,理論上還有其他方式可以實現,比如:with,try/catch,let/const,大家都可以嘗試下哦。
模塊
模塊也利用了閉包的力量。
function coolModule() {
var something = "cool";
function doSomething() {
console.log(something);
}
return {
doSomething: doSomething
};
}
var foo = coolModule()
foo.doSomething() // cool