[TOC]
# 理解 JavaScript 作用域
ECMAScript 規范描述了所有JavaScript 代碼都運行在一個執行上下文(execution context)中。
執行上下文在 JavaScript 中不是可訪問的實體,但是了解它們對于全面理解函數和閉包的工作原理是至關重要的。
規范中說:“當控制權(control)轉移至 ECMAScript 的可執行代碼時,控制權進入一個執行上下文。活動的執行上下文邏輯上形成一個棧。棧頂的執行上下文是當前正在運行的執行上下文。”
在運行任何代碼之前,JavaScript 引擎會創建一個全局對象,其初始化屬性除了用戶定義的屬性外,還包含 ECMAScript 定義的內建對象(builts-ins),比如 Object、String、Array 和其他。瀏覽器的 JavaScript 實現提供了全局對象的一個屬性,其本身也是全局對象,即 `window === window.window`。
每當一個函數被調用時,控制權會進入一個新的執行上下文。即使對一個函數的遞歸調用也是這樣。
# 介紹
在解釋過程中,JavaScript 引擎是嚴格按著作用域機制(scope)來執行的。JavaScript語法采用的是詞法作用域(lexcical scope),也就是說 **JavaScript 的變量和函數作用域是在定義時決定的,而不是執行時決定的**,**由于詞法作用域取決于源代碼結構,所以 JavaScript解釋器只需要通過靜態分析就能確定每個變量、函數的作用域,這種作用域也稱為靜態作用域**(static scope)。補充:但需要注意,`with` 和 `eval` 的語義無法僅通過靜態技術實現,實際上,只能說JS的作用域機制非常接近詞法作用域。
# 靜態作用域與動態作用域
作用域是指程序源代碼中定義變量的區域。
作用域規定了如何查找變量,也就是確定當前執行代碼對變量的訪問權限。
**JavaScript 采用詞法作用域(lexical scoping),也就是靜態作用域**。
## 詞法作用域
因為 JavaScript 采用的是詞法作用域,函數的作用域在函數定義的時候就決定了。
而與詞法作用域相對的是動態作用域,函數的作用域是在函數調用的時候才決定的。
讓我們認真看個例子就能明白之間的區別:
```
var value = 1;
function foo() {
console.log(value); // 在該作用域中使用的變量 value,沒有在該作用域中聲明(即在其他作用域中聲明的),對于該作用域來說,value 就是一個**自由變量**。
}
function bar() {
var value = 2;
foo();
}
bar();
// 結果是 ???
```
假設JavaScript采用靜態作用域,讓我們分析下執行過程:
執行 foo 函數,先從 foo 函數內部查找是否有局部變量 value,如果沒有,就根據書寫的位置,查找上面一層的代碼 ,(沿著作用域鏈到全局作用域中查找),也就是 value 等于 1,所以結果會打印 1。
假設JavaScript采用動態作用域,讓我們分析下執行過程:
執行 foo 函數,依然是從 foo 函數內部查找是否有局部變量 value。如果沒有,就從調用函數的位置所處作用域,也就是 bar 函數內部查找 value 變量,所以結果會打印 2。
前面我們已經說了,**JavaScript采用的是靜態作用域,所以這個例子的結果是 1。**
## 動態作用域
也許你會好奇什么語言是動態作用域?
bash就是動態作用域,不信的話,把下面的腳本存成例如 `scope.bash`,然后進入相應的目錄,用命令行執行 `bash ./scope.bash`,看看打印的值是多少
```
value=1
function foo () {
echo $value;
}
function bar () {
local value=2;
foo;
}
bar
```
這個文件也可以在 `demos/scope/` 中找到。
最后,讓我們看一個《JavaScript權威指南》中的例子:
```js
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
============================
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();
```
猜猜兩段代碼各自的執行結果是多少?
答:兩段代碼都會打印 `local scope`。
# 閉包
**閉包包含了在創建函數時的作用域里面的所有變量**。任何時候你聲明一個新的函數并將它賦值給一個變量,你存儲了函數定義,同時存儲了閉包。閉包它就像一個背包。一個函數定義時附帶了一個小背包。在這個包里保存了 **所有在這個函數定義創建時的作用域中的擁有的變量**。
要記住的關鍵點就是當 **一個函數被聲明時,它會同時包含一個函數定義和一個閉包**。
這個閉包是指在**這個函數創建出來時的作用域中的所有變量的集合**。
你可能會問,任何函數都有閉包嗎,甚至在全局作用域創建的函數?
答案是有。在全局作用域創建的函數也會創建一個閉包。但是因為這些函數是在全局作用域被創建的,它們擁有所有全局作用域的變量的訪問權限。這種情況下閉包的概念并沒有什么意義。
當一個函數返回一個函數時,這才是讓閉包概念變得有意義的時候。這個返回的函數擁有并不在全局作用域中的變量的訪問權限,但他們是完全存在于閉包內的。
## 閉包示例
有時候閉包在你完全沒有注意到它的情況下出現。你可能在偏函數應用中已經看到過例子。就像下面的代碼。
```js
let c = 4
const addX = x => n => n + x
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)
```
如果箭頭函數讓你難以理解,下面是等價的代碼。
```js
let c = 4
function addX(x) {
return function(n) {
return n + x
}
}
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)
```
我們聲明了一個常規的加法函數addX,它包含一個參數(x)并返回另一個函數。
返回的函數仍然有一個參數,并且將它加到變量x上。
變量x是閉包的一部分。當變量addTree在本地上下文中聲明時,它被賦值了一個函數定義和一個閉包。這個閉包中含有變量x。
于是現在當addThree被調用并執行,它擁有對它的閉包中變量x的訪問權限,并且將變量n作為參數傳遞進去給予它返回和值的能力。
在這個例子中,控制臺會打印出數字7。
## 結語
讓我一直記住閉包的方式是通過把它比喻成**背包**。當一個函數創建、傳遞或從其他函數返回時。它會隨身攜帶一個背包。所有在這個函數聲明時的作用域中的變量都在這個背包里面。
# 作用域鏈(Scope Chain)
在JavaScript中,函數也是對象,實際上,JavaScript里一切都是對象。函數對象和其它對象一樣,擁有可以通過代碼訪問的屬性和一系列僅供JavaScript引擎訪問的內部屬性。其中一個內部屬性是[[Scope]],由ECMA-262標準第三版定義,該內部屬性包含了函數被創建的作用域中對象的集合,這個集合被稱為函數的作用域鏈,它決定了哪些數據能被函數訪問。
當一個函數創建后,它的作用域鏈會被創建此函數的作用域中可訪問的數據對象填充。例如定義下面這樣一個函數:
~~~
function add(num1,num2) {
var sum = num1 + num2;
return sum;
}
~~~
在函數`add`創建時,它的作用域鏈中會填入一個全局對象,該全局對象包含了所有全局變量,如下圖所示(注意:圖片只例舉了全部變量中的一部分):

這些值按照它們出現在函數中的順序被復制到運行期上下文的作用域鏈中。它們共同組成了一個新的對象,叫“活動對象(activation object)”,該對象包含了函數的所有局部變量、命名參數、參數集合以及this,**然后此對象會被推入作用域鏈的前端,當運行期上下文被銷毀,活動對象也隨之銷毀**。新的作用域鏈如下圖所示:

**函數執行過程中,每個標識符都要經歷這樣的搜索過程。過程從作用域鏈頭部,也就是從活動對象開始搜索,查找同名的標識符。**
## 作用域鏈和代碼優化
如上圖所示,因為全局變量總是存在于運行期上下文作用域鏈的最末端,因此在標識符解析的時候,查找全局變量是最慢的。所以,在編寫代碼的時候應盡量少使用全局變量,盡可能使用局部變量。一個好的經驗法則是:如果一個跨作用域的對象被引用了一次以上,則先把它存儲到局部變量里再使用。
例如下面的代碼,這個函數引用了兩次全局變量`document`,查找該變量必須遍歷整個作用域鏈,直到最后在全局對象中才能找到。這段代碼可以重寫如下:
~~~
function changeColor(){
var doc=document;
doc.getElementById("btnChange").onclick=function(){
doc.getElementById("targetCanvas").style.backgroundColor="red";
};
}
~~~
這段代碼比較簡單,重寫后不會顯示出巨大的性能提升,但是如果程序中有大量的全局變量被從反復訪問,那么重寫后的代碼性能會有顯著改善。
## 改變作用域鏈
函數每次執行時對應的運行期上下文都是獨一無二的,所以多次調用同一個函數就會導致創建多個運行期上下文,當函數執行完畢,執行上下文會被銷毀。每一個運行期上下文都和一個作用域鏈關聯。**一般情況下,在運行期上下文運行的過程中,其作用域鏈只會被 with 語句和 catch 語句影響。**
`with`語句是對象的快捷應用方式,用來避免書寫重復代碼。例如:
~~~
function initUI(){
with(document){
var bd=body,
links=getElementsByTagName("a"),
i=0,
len=links.length;
while(i < len){
update(links[i++]);
}
getElementById("btnInit").onclick=function(){
doSomething();
};
}
}
~~~
這里使用 `width` 語句來避免多次書寫`document`,看上去更高效,實際上產生了性能問題。
當代碼運行到`with`語句時,運行期上下文的作用域鏈臨時被改變了。一個新的可變對象被創建,它包含了參數指定的對象的所有屬性。這個對象將被推入作用域鏈的頭部,這意味著函數的所有局部變量現在處于第二個作用域鏈對象中,因此訪問代價更高了。如下圖所示:

因此在程序中應避免使用`with`語句,在這個例子中,只要簡單的把`document`存儲在一個局部變量中就可以提升性能。
另外一個會改變作用域鏈的是`try-catch`語句中的`catch`語句。當`try`代碼塊中發生錯誤時,執行過程會跳轉到`catch`語句,然后把異常對象推入一個可變對象并置于作用域的頭部。在`catch`代碼塊內部,函數的所有局部變量將會被放在第二個作用域鏈對象中。示例代碼:
~~~
try{
doSomething();
}catch(ex){
alert(ex.message); //作用域鏈在此處改變
}
~~~
請注意,一旦`catch`語句執行完畢,作用域鏈機會返回到之前的狀態。`try-catch`語句在代碼調試和異常處理中非常有用,因此不建議完全避免。你可以通過優化代碼來減少`catch`語句對性能的影響。一個很好的模式是將錯誤委托給一個函數處理,例如:
~~~
try{
doSomething();
}catch(ex){
handleError(ex); //委托給處理器方法
}
~~~
優化后的代碼,`handleError`方法是`catch`子句中唯一執行的代碼。該函數接收異常對象作為參數,這樣你可以更加靈活和統一的處理錯誤。由于只執行一條語句,且沒有局部變量的訪問,作用域鏈的臨時改變就不會影響代碼性能了。
# 參考
https://github.com/mqyqingfeng/Blog/issues/17
[深入理解javascript作用域第二篇之詞法作用域和動態作用域](https://www.jb51.net/article/89146.htm)
- 步入JavaScript的世界
- 二進制運算
- JavaScript 的版本是怎么回事?
- JavaScript和DOM的產生與發展
- DOM事件處理
- js的并行加載與順序執行
- 正則表達式
- 當遇上this時
- Javascript中apply、call、bind
- JavaScript的編譯過程與運行機制
- 執行上下文(Execution Context)
- javascript 作用域
- 分組中的函數表達式
- JS之constructor屬性
- Javascript 按位取反運算符 (~)
- EvenLoop 事件循環
- 異步編程
- JavaScript的九個思維導圖
- JavaScript奇淫技巧
- JavaScript:shim和polyfill
- ===值得關注的庫===
- ==文章==
- JavaScript框架
- Angular 1.x
- 啟動引導過程
- $scope作用域
- $q與promise
- ngRoute 和 ui-router
- 雙向數據綁定
- 規范和性能優化
- 自定義指令
- Angular 事件
- lodash
- Test