## 徹底搞懂JavaScript作用域
> 摘自 https://github.com/prettyEcho/deep-js/issues/3
> 作者:張建成,有修改
我們常說,**萬物都有其存在的價值**,這話的確不錯,但是深思一下,是不是需要有個前提,**萬物都在某些領域或多或少的存在某些價值**。
舉個例子,**汽車**,絕對是個非常有價值的stuff,它給我們的日常出行,貨物運輸等帶來了極大的便利;**筷子**,同樣也是個非常有價值的stuff,它給我們吃飯帶來了極大的方便。但是,**汽車**能幫我們把菜送到嘴里嗎?**筷子**能載著我們出行嗎?
那么,我上面所說的**某些領域**,我們是不是可以稱其為**作用域**,我想是可以的。
說到這,那么我就想問了:在JS里,**作用域**是不是也是類似的概念呢?
**首先,我可以肯定的說這是一個在 JavaScript 中灰常灰常重要的概念,關系著 JS 里很多核心的機制,理解它,很多問題都迎刃而解了。**
那么,問問自己,在JS里,作用域是什么?
心里大概知道是什么,但是細細一想又好像說不太清。
沒關系,下面我們就細細品味這個有意思的東東。
先throw概念吧:
作用域負責收集并維護由所有聲明的標識符(變量)組成的一系列查詢,并實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符的訪問權限。
通俗來說,作用域相當于一個管理員(有自己的一套規則),他負責管理所有聲明的標識符的有序查詢。
我們來講個故事,說說作用域到底干了啥。
### 三兄弟齊上陣
long long ago,有3個關系很好的基友,老大叫引擎,老二叫編譯器,老三叫作用域。三兄弟眼看年歲已長,可手上還是沒有幾個銀子。個個都很著急,于是三兄弟謀劃一同做個事。
求職過程:此粗略去數萬個字。。。
最終他們做的工作是:負責JS的編譯和運行。
他們的工作內容是這樣的:
老板甩給他們一項任務編譯并執行下面代碼:
```
var a = 1;
console.log( a );
```
開始工作:
- 編譯器:作用域,幫我看看你那有沒有儲存變量a。
- 作用域:二哥,還沒有。
- 編譯器:那好,幫我儲存一個。
- 引擎: 老三,你那有沒有一個叫做a的變量。
- 作用域:大哥,還真有,剛二哥讓我存儲了一個。
- 引擎: 真是太好了,幫我拿出來,它的值是幾,我需要給它復制。
- 作用域:大哥,它的值是2。
- 引擎: 謝謝你,三弟,這樣我就能打印它的值了。
上面講了一個不恰當的小故事,但是三者之間的關系大概就是這樣。
### 詞法作用域 VS 動態作用域
- 詞法作用域
[徹底搞懂JavaScript作用域](https://github.com/prettyEcho/deep-js/issues/3) 里介紹過,大部分標準語言編譯器的第一個工作階段叫作詞法化(也叫單詞化)。回憶一下,詞法化的過程會對源代碼中的字符進行檢查,如果是有狀態的解析過程,還會賦予單詞語義。
在JS里,使用的作用域就是**詞法作用域**。
> 簡單地說,**詞法作用域就是定義在詞法階段的作用域**。換句話說,**詞法作用域是由你在寫代碼時將變量和塊作用域寫在哪里來決定的**,因此當詞法分析器處理代碼時會保持作用域不變(大部分情況下是這樣的)。
- 動態作用域
在JS里,動態作用域和this機制息息相關。**它的作用域詩是在運行的過程中確定的**
```
var a = 1;
function foo() {
var a = 2;
console.log( this.a );
}
foo(); // 1
```
從上面的代碼,我們可以看出:foo中打印a的值不是由寫代碼的位置確定的,而是取決于foo執行的位置。
- 區別
- 詞法作用域是在寫代碼或者說定義時確定的,而動態作用域是在運行時確定的。(this 也是!)
- 詞法作用域關注函數在何處聲明,而動態作用域關注函數從何處調用。
### 函數作用域
JS里,生成作用域的方式:
- 函數
- with、eval (不建議使用,影響性能)
由此,我們知道JS里,**絕大多數的作用域都是基于函數生成的**。
每個函數都會為自身生成一個作用域氣泡。這個氣泡內所有的標識符都可以在這個氣泡中使用。
```
function bar() {
var a = 1;
function foo() {
var b = 2;
console.log(b);
}
foo();
console.log(a);
}
bar();
```
上面代碼,bar氣泡有標識符a、foo,因此在bar氣泡中可以訪問到a、foo; foo氣泡有標識符b,因此在foo氣泡中可以訪問到b; 當然還有一個全局氣泡,全局氣泡中有bar標識符,因此在全局氣泡中可以訪問到bar。
#### 最小授權原則
> 最小授權原則是指在軟件設計中,應該最小限度地暴露必要內容,而將其他內容都“隱藏”起來,比如某個模塊或對象的 API 設計。
這個原則可以延伸到如何選擇作用域來包含變量和函數。如果所有變量和函數都在全局作 用域中,當然可以在所有的內部嵌套作用域中訪問到它們。但這樣會破壞前面提到的最小 特權原則,因為可能會暴漏過多的變量或函數,而這些變量或函數本應該是私有的,正確 的代碼應該是可以阻止對這些變量或函數進行訪問的。
例如:
```
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
```
在這個代碼片段中,變量 b 和函數 doSomethingElse(..) 應該是 doSomething(..) 內部具體 實現的“私有”內容。給予外部作用域對 b 和 doSomethingElse(..) 的“訪問權限”不僅 沒有必要,而且可能是“危險”的,因為它們可能被有意或無意地以非預期的方式使用, 從而導致超出了 doSomething(..) 的適用條件。更“合理”的設計會將這些私有的具體內容隱藏在 doSomething(..) 內部,
例如:
```
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
```
現在,b 和 doSomethingElse(..) 都無法從外部被訪問,而只能被 doSomething(..) 所控制。 功能性和最終效果都沒有受影響,但是設計上將具體內容私有化了,設計良好的軟件都會 依此進行實現。
#### 規避沖突
當我們的程序代碼逐漸多起來,難免會出現變量沖突。那么如何規避沖突就顯得額外重要。
函數可以把標識符嚴謹的"隱藏"起來,外部無法訪問到,利用這個特性我們可以很好的規避沖突。
```
function foo() {
var a = 1;
}
function bar() {
var a = 2;
}
```
foo和bar中定義了相同的變量a,但是卻不會相互造成影響。因為函數可以很好的把標識符"隱藏"起來。
- 全局命名空間
變量沖突的一個典型例子存在于全局作用域中。當程序中加載了多個第三方庫時,如果它 們沒有妥善地將內部私有的函數或變量隱藏起來,就會很容易引發沖突。
這些庫通常會在全局作用域中聲明一個名字足夠獨特的變量,通常是一個對象。這個對象 被用作庫的命名空間,所有需要暴露給外界的功能都會成為這個對象(命名空間)的屬 性,而不是將自己的標識符暴漏在頂級的詞法作用域中。
例如:
```
var myLibrary = {
name: 'echo',
getName: function() {
console.log( this.name );
}
}
```
#### 函數聲明 VS 函數表達式
函數聲明和函數表達式判別的依據是:**函數的聲明是否以function關鍵詞開始**。
以關鍵詞function 開始的聲明是函數聲明,其余的函數聲明全部是函數表達式。
```
//函數聲明
function foo() {
}
//函數表達式
var foo = function () {
};
(function() {
})();
```
#### 具名函數 VS 匿名函數
- 具名函數
擁有名字的函數
```
function foo() {
}
var foo = function bar() {
}
setTimeout( function foo() {
} )
+function foo() {
}();
```
需要注意:**函數聲明一定要是具名函數**。
- 匿名函數
沒有名字的函數
```
var foo = function () {
}
setTimeout( function foo() {
} )
-function foo() {
}();
```
#### 立即執行函數(IIFE)
```
var a=2;
(function foo() {
var a=3;
console.log( a ); // 3
})();
console.log( a ); // 2
```
該函數是以()開始,不是以關鍵詞function開始,因此IIFE是函數表達式
函數名對 IIFE 當然不是必須的,IIFE 最常見的用法是使用一個匿名函數表達式。雖然使 用具名函數的 IIFE 并不常見,但它具有以下優勢:
1. 匿名函數在棧追蹤中不會顯示出有意義的函數名,使得調試很困難。
2. 如果沒有函數名,當函數需要引用自身時只能使用已經過期的arguments.callee引用, 比如在遞歸中。另一個函數需要引用自身的例子,是在事件觸發后事件監聽器需要解綁 自身。
3. 匿名函數省略了對于代碼可讀性/可理解性很重要的函數名。一個描述性的名稱可以讓 代碼不言自明。
因此具名函數的 IIFE 也是一個值得推廣的實踐。
- 另一種表達形式
```
(function() {
}())
```
這也是IIFE的一種表達方式,功能上和上面那種方式是一致的。**選擇哪種全憑個人愛好**。
- 參數傳遞
IIFE 也可以和其他形式的函數一樣實現參數的傳遞(多說一句:參數傳遞是按值傳遞)。
```
(function foo(a) {
console.log(a);
})(3);
```
這個模式的另外一個應用場景是解決 undefined 標識符的默認值被錯誤覆蓋導致的異常(雖 然不常見)。將一個參數命名為 undefined,但是在對應的位置不傳入任何值,這樣就可以 保證在代碼塊中 undefined 標識符的值真的是 undefined:
```
undefined = true; // 給其他代碼挖了一個大坑!絕對不要這樣做!
(function IIFE( undefined ) {
var a;
if (a === undefined) {
console.log( "Undefined is safe here!" );
}
})();
```
- UMD (Universal Module Definition)
IIFE 還有一種變化的用途是倒置代碼的運行順序,將需要運行的函數放在第二位,在 IIFE 執行之后當作參數傳遞進去。盡管這種模式略顯冗長,但有些人認為它更易理解。
```
var a=2;
(function IIFE( def ) {
//參數的處理
def( window );
})(function def( global ) {
//邏輯運算
var a=3;
console.log( a ); // 3
console.log( global.a ); // 2
});
```
### 塊作用域
盡管函數作用域是最常見的作用域單元,當然也是現行大多數 JavaScript 中最普遍的設計 方法,但其他類型的作用域單元也是存在的,并且通過使用其他類型的作用域單元甚至可 以實現維護起來更加優秀、簡潔的代碼。
- try...catch
非常少有人會注意到 JavaScript 的 ES3 規范中規定 try/catch 的 catch 分句會創建一個塊作用域, catch 的參數變量僅在 catch 內部有效。
```
try{
throw undefined;
}catch(a){
a = 2;
console.log(a); // 2
}
console.log(a); // ReferenceError
```
- let
ES6的標準使我們能夠簡單的創建塊作用域,其中一個變量定義方式是let關鍵詞定義。
let定義的變量具有以下的特點:
1. let隱形的創建塊作用域({...})
2. let聲明的變量不能進行變量提升,因此只能先定義,后使用
```
{
let a = 1;
console.log(a); // 1
}
console.log(a); // ReferenceError
```
let一個典型的應用就是在for循環里
我們看下面兩個例子:
```
// 每秒輸出一個5
for( var i = 0; i < 5 ; i++ ) {
setTimeout(() => {
console.log( i );
}, i *1000)
}
// 依次輸出0,1,2,3,4,時間間隔位1秒
for( let i = 0; i < 5 ; i++ ) {
setTimeout(() => {
console.log( i );
}, i *1000)
}
```
其原因就是let形成了5個塊作用域,使每次輸出的變量都從本次循環的塊作用域中獲取。
當然我們還可以有其他方式做到第二種效果,我們將在 [閉包,是真的美](https://github.com/prettyEcho/deep-js/issues/4)中說道。
- const
除了 let 以外,ES6 還引入了 const,同樣可以用來創建塊作用域變量,但其值是固定的 (常量)。之后任何試圖修改值的操作都會引起錯誤。
```
var foo = true;
if (foo) {
var a=2;
const b = 3; // 包含在 if 中的塊作用域常量
a=3;//正常!
b=4;//錯誤!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
```
### 作用域鏈
> 作用域鏈是由當前作用域與上層一系列父級作用域組成,作用域的頭部永遠是當前作用域,尾部永遠是全局作用域。作用域鏈保證了當前上下文對其有權訪問的變量的有序訪問。
```
var a = 2;
function bar() {
function foo() {
console.log(a);
}
foo();
}
bar(); // 2
```
上面代碼是由3層作用域氣泡組成,foo氣泡中試圖打印變量a,引擎在foo氣泡中未找到a變量,于是去其父作用域氣泡bar中尋找...以此類推直到找到全局作用域氣泡,發現有變量a,將其值打印出來。如若沒找到,報ReferenceError錯誤。