目錄
- 1.靜態作用域與動態作用域
- 2.變量的作用域
- 3.JavaScript 中變量的作用域
- 4.JavaScript 欺騙作用域
- 5.JavaScript 執行上下文
- 6.JavaScript 中的作用域鏈
- 7.JavaScript 中的閉包
- 8.JavaScript 中的模塊
一. 靜態作用域與動態作用域
在電腦程序設計中,作用域(scope,或譯作有效范圍)是名字(name)與實體(entity)的綁定(binding)保持有效的那部分計算機程序。不同的編程語言可能有不同的作用域和名字解析。而同一語言內也可能存在多種作用域,隨實體的類型變化而不同。作用域類別影響變量的綁定方式,根據語言使用靜態作用域還是動態作用域變量的取值可能會有不同的結果。
- 包含標識符的宣告或定義;
- 包含語句和/或表達式,定義或部分關于可運行的算法;
- 嵌套嵌套或被嵌套嵌套。
名字空間是一種作用域,使用作用域的封裝性質去邏輯上組群起關相的眾識別子于單一識別子之下。因此,作用域可以影響這些內容的名字解析。
程序員常會縮進他們的源代碼中的作用域,改善可讀性。
作用域又分為兩種,靜態作用域和動態作用域。
靜態作用域又叫做詞法作用域,采用詞法作用域的變量叫詞法變量。詞法變量有一個在編譯時靜態確定的作用域。詞法變量的作用域可以是一個函數或一段代碼,該變量在這段代碼區域內可見(visibility);在這段區域以外該變量不可見(或無法訪問)。詞法作用域里,取變量的值時,會檢查函數定義時的文本環境,捕捉函數定義時對該變量的綁定。
function f() {
function g() {
}
}
靜態(詞法)作用域,就是可以無須執行程序而只從程序源碼的角度,就可以看出程序是如何工作的。從上面的例子中可以肯定,函數 g 是被函數 f 包圍在內部。
大多數現在程序設計語言都是采用靜態作用域規則,如C/C++、C#、Python、Java、JavaScript……
相反,采用動態作用域的變量叫做動態變量。只要程序正在執行定義了動態變量的代碼段,那么在這段時間內,該變量一直存在;代碼段執行結束,該變量便消失。這意味著如果有個函數f,里面調用了函數g,那么在執行g的時候,f里的所有局部變量都會被g訪問到。而在靜態作用域的情況下,g不能訪問f的變量。動態作用域里,取變量的值時,會由內向外逐層檢查函數的調用鏈,并打印第一次遇到的那個綁定的值。顯然,最外層的綁定即是全局狀態下的那個值。
function g() {
}
function f() {
g();
}
當我們調用f(),它會調用g()。在執行期間,g被f調用代表了一種動態的關系。
采用動態作用域的語言有Pascal、Emacs Lisp、Common Lisp(兼有靜態作用域)、Perl(兼有靜態作用域)。C/C++是靜態作用域語言,但在宏中用到的名字,也是動態作用域。
二. 變量的作用域
1. 變量的作用域
變量的作用域是指變量在何處可以被訪問到。比如:
function foo(){
var bar;
}
這里的 bar 的直接作用域是函數作用域foo();
2. 詞法作用域
JavaScript 中的變量都是有靜態(詞法)作用域的,因此一個程序的靜態結構就決定了一個變量的作用域,這個作用域不會被函數的位置改變而改變。
3. 嵌套作用域
如果一個變量的直接作用域中嵌套了多個作用域,那么這個變量在所有的這些作用域中都可以被訪問:
function foo (arg) {
function bar() {
console.log( 'arg:' + arg );
}
bar();
}
console.log(foo('hello')); // arg:hello
arg的直接作用域是foo(),但是它同樣可以在嵌套的作用域bar()中被訪問,foo()是外部的作用域,bar()是內部作用域。
4. 覆蓋的作用域
如果在一個作用域中聲明了一個與外層作用域同名的變量,那么這個內部作用域以及內部的所有作用域中將會訪問不到外面的變量。并且內部的變量的變化也不會影響到外面的變量,當變量離開內部的作用域以后,外部變量又可以被訪問了。
var x = "global";
function f() {
var x = "local";
console.log(x); // local
}
f();
console.log(x); // global
這就是覆蓋的作用域。
三. JavaScript 中變量的作用域
大多數的主流語言都是有塊級作用域的,變量在最近的代碼塊中,Objective-C 和 Swift 都是塊級作用域的。但是在 JavaScript 中的變量是函數級作用域的。不過在最新的 ES6 中加入了 let 和 const 關鍵字以后,就變相支持了塊級作用域。到了 ES6 以后支持塊級作用域的有以下幾個:
-
with 語句
用 with 從對象中創建出的作用域僅在 with 聲明中而非外 部作用域中有效。 -
try/catch 語句
JavaScript 的 ES3 規范中規定 try/catch 的 catch 分句會創建一個塊作用域,其中聲明的變量僅在 catch 內部有效。 -
let 關鍵字
let關鍵字可以將變量綁定到所在的任意作用域中(通常是{ .. }內部)。換句話說,let 為其聲明的變量隱式地了所在的塊作用域。 -
const 關鍵字
除了 let 以外,ES6 還引入了 const,同樣可以用來創建塊作用域變量,但其值是固定的 (常量)。之后任何試圖修改值的操作都會引起錯誤。
這里就需要注意變量和函數提升的問題了,這個問題在前一篇文章里面詳細的說過了,這里不再贅述了。
不過這里還有一個坑,如果賦值給了一個未定義的變量,會產生一個全局變量。
在非嚴格模式下,不通過 var 關鍵字直接給一個變量賦值,會產生一個全局的變量
function func() { x = 123; }
func();
x
<123
不過在嚴格模式下,這里會直接報錯。
function func() { 'use strict'; x = 123; }
func();
<ReferenceError: x is not defined
在 ES5 中,經常會通過引入一個新的作用域來限制變量的生命周期,通過 IIFE(Immediately-invoked function expression,立即執行的函數表達式)來引入新的作用域。
通過 IIFE ,我們可以
- 避免全局變量,隱藏全局作用域的變量。
- 創建新的環境,避免共享。
- 保持全局的數據對于構造器的數據相對獨立。
- 將全局的數據附加到單例對象上。
- 將全局數據附加到方法中。
四. JavaScript 欺騙作用域
(1). with 語句
with 語句被很多人都認為是 JavaScript 里面的糟粕( Bad Parts )。起初它被設計出來的目的是好的,但是它導致的問題多于它解決的問題。
with 起初設計出來是為了避免冗余的對象調用。
舉個例子:
foo.a.b.c = 888;
foo.a.b.d = 'halfrost';
這時候用 with 語句就可以縮短調用:
with (foo.a.b) {
c = 888;
d = 'halfrost';
}
但是這種特性卻帶來了很多問題:
function myLog( errorMsg , parameters) {
with (parameters) {
console.log('errorMsg:' + errorMsg);
}
}
myLog('error',{});
<errorMsg:error
myLog('error',{ errorMsg:'stackoverflow' });
<errorMsg:stackoverflow
可以看到輸出就出現問題了,由于 with 語句,覆蓋掉了第一個入參。通過閱讀代碼,有時候是不能分辨出這些問題,它也會隨著程序的運行,導致發生不多的變化,這種對未來的不確定性就很容易出現
bug。
with 會導致3個問題:
性能問題
變量查找會變慢,因為對象是臨時性的插入到作用域鏈中的。代碼不確定性
@Brendan Eich 解釋,廢棄 with 的根本原因不是因為性能問題,原因是因為“with 可能會違背當前的代碼上下文,使得程序的解析(例如安全性)變得困難而繁瑣”。代碼壓縮工具不會壓縮 with 語句中的變量名
所以在嚴格模式下,已經嚴格禁止使用 with 語句。
Uncaught SyntaxError: Strict mode code may not include a with statement
如果還是想避免使用 with 語句,有兩種方法:
- 用一個臨時變量替代傳進 with 語句的對象。
- 如果不想引入臨時變量,可以使用 IIFE 。
(function () {
var a = foo.a.b;
console.log('Hello' + a.c + a.d);
}());
或者
(function (bar) {
console.log('Hello' + bar.c + bar.d);
}(foo.a.b));
(2). eval 函數
eval 函數傳遞一個字符串給 JavaScript 編譯器,并且執行其結果。
eval(str)
它是 JavaScript 中被濫用的最多的特性之一。
var a = 12;
eval('a + 5')
<17
eval 函數以及它的親戚( Function 、setTimeout、setInterval)都提供了訪問 JavaScript 編譯器的機會。
Function() 構造函數的形式比 eval() 函數好一點的地方在于,它令入參更加清晰。
new Function( param1, ...... , paramN, funcBody )
var f = new Function( 'x', 'y' , 'return x + y' );
f(3,4)
<7
用 Function() 的方式至少不用使用間接的 eval() 調用來確保所執行的代碼除了其自己的作用域只能訪問全局的變量。
在 Weex 的代碼中,就還存在著 eval() 的代碼,不過 Weex 團隊在注釋里面承諾會改掉。總的來說,最好應該避免使用 eval() 和 new Function() 這些動態執行代碼的方法。動態執行代碼相對會比較慢,并且還存在安全隱患。
再說說另外兩個親戚,setTimeout、setInterval 函數,它們也能接受字符串參數或者函數參數。當傳遞的是字符串參數時,setTimeout、setInterval 會像 eval 那樣去處理。同樣也需要避免使用這兩個函數的時候使用字符串傳參數。
eval 函數帶來的問題總結如下:
- 函數變成了字符串,可讀性差,存在安全隱患。
- 函數需要運行編譯器,即使只是為了執行一個微不足道的賦值語句。這使得執行速度變慢。
- 讓 JSLint 失效,讓它檢測問題的能力大打折扣。
五. JavaScript 執行上下文
這個事情要從 JavaScript 源代碼如何被運行開始說起。
我們都知道 JavaScript 是腳本語言,它只有 runtime,沒有編譯型語言的 buildTime,那它是如何被各大瀏覽器運行起來的呢?
JavaScript 代碼是被各個瀏覽器引擎編譯和運行起來的。JavaScript 引擎的代碼解析和執行過程的目標就是在最短時間內編譯出最優化的代碼。JavaScript 引擎還需要負責管理內存,負責垃圾回收,與宿主語言的交互等。流行的引擎有以下幾種:
蘋果公司的 JavaScriptCore (JSC) 引擎,Mozilla 公司的 SpiderMonkey,微軟 Internet Explorer 的 Chakra (JScript引擎),Microsoft Edge 的 Chakra (JavaScript引擎) ,谷歌 Chrome 的 V8。
其中 V8 引擎是最著名的開源的引擎,它和前面那幾個引擎有一個最大的區別是:主流引擎都是基于字節碼的實現,V8 的做法非常極致,直接跳過了字節碼這一層,直接把 JS 編譯成機器碼。所以 V8 是沒有解釋器的。(但是這都是歷史,V8 現在最新版是有解釋器的)
在2017年5月1號之后, Chrome 的 V8 引擎的v8 5.9 發布了,其中的 Ignition 字節碼解釋器將默認啟動 :V8 Release 5.9 。v8 自此回到了字節碼的懷抱。
V8 在有了字節碼以后,消除 Cranshaft 這個舊的編譯器,并讓新的 Turbofan 直接從字節碼來優化代碼,并當需要進行反優化的時候直接反優化到字節碼,而不需要再考慮 JS 源代碼。去掉 Cranshaft 以后,就成了 Turbofan + Ignition 的組合了。
Ignition + TurboFan 的組合,就是字節碼解釋器 + JIT 編譯器的黃金組合。這一黃金組合在很多 JS 引擎中都有所使用,例如微軟的 Chakra,它首先解釋執行字節碼,然后觀察執行情況,如果發現熱點代碼,那么后臺的 JIT 就把字節碼編譯成高效代碼,之后便只執行高效代碼而不再解釋執行字節碼。蘋果公司的 SquirrelFish Extreme 也引入了 JIT。SpiderMonkey 更是如此,所有 JS 代碼最初都是被解釋器解釋執行的,解釋器同時收集執行信息,當它發現代碼變熱了之后,JaegerMonkey、IonMonkey 等 JIT 便登場,來編譯生成高效的機器碼。
總結一下:
JavaScript 代碼會先被引擎編譯,轉化成能被解釋器識別的字節碼。
源碼會被詞法分析,語法分析,生成 AST 抽象語法樹。
AST 抽象語法樹又會被字節碼生成器進行多次優化,最終生成了中間態的字節碼。這時的字節碼就可以被解釋器執行了。
這樣,JavaScript 代碼就可以被引擎跑起來了。
JavaScript 在運行過程中涉及到的作用域有3種:
- 全局作用域(Global Scope)JavaScript 代碼開始運行的默認環境
- 局部作用域(Local Scpoe)代碼進入一個 JavaScript 函數
- Eval 作用域 使用 eval() 執行代碼
當 JavaScript 代碼執行的時候,引擎會創建不同的執行上下文,這些執行上下文就構成了一個執行上下文棧(Execution context stack,ECS)。
全局執行上下文永遠都在棧底,當前正在執行的函數在棧頂。
當 JavaScript 引擎遇到一個函數執行的時候,就會創建一個執行上下文,并且壓入執行上下文棧,當函數執行完畢的時候,就會將函數的執行上下文從棧中彈出。
對于每個執行上下文都有三個重要的屬性,變量對象(Variable object,VO),作用域鏈(Scope chain)和this。這三個屬性跟代碼運行的行為有很重要的關系。
變量對象 VO 是與執行上下文相關的數據作用域。它是一個與上下文相關的特殊對象,其中存儲了在上下文中定義的變量和函數聲明。也就是說,一般 VO 中會包含以下信息:
- 創建 arguments object
- 查找函數聲明(Function declaration)
- 查找變量聲明(Variable declaration)
上圖也解釋了,為何函數提升優先級會在變量提升前面。
這里還會牽扯到活動對象(Activation object):
只有全局上下文的變量對象允許通過 VO 的屬性名稱間接訪問。在函數執行上下文中,VO 是不能直接訪問的,此時由活動對象(Activation Object, 縮寫為AO)扮演 VO 的角色。活動對象是在進入函數上下文時刻被創建的,它通過函數的 arguments 屬性初始化。
Arguments Objects 是函數上下文里的激活對象 AO 中的內部對象,它包括下列屬性:
- callee:指向當前函數的引用
- length: 真正傳遞的參數的個數
- properties-indexes:就是函數的參數值(按參數列表從左到右排列)
JavaScript 解釋器創建執行上下文的時候,會經歷兩個階段:
- 創建階段(當函數被調用,但是開始執行函數內部代碼之前)
創建 Scope chain,創建 VO/AO(variables, functions and arguments),設置 this 的值。 - 激活 / 代碼執行階段
設置變量的值,函數的引用,然后解釋/執行代碼。
VO 和 AO 的區別就在執行上下文的這兩個生命周期里面。
VO 和 AO 的關系可以理解為,VO 在不同的 Execution Context 中會有不同的表現:當在 Global Execution Context 中,直接使用的 VO;但是,在函數 Execution Context 中,AO 就會被創建。
六. JavaScript 中的作用域鏈
在 JavaScript 中有兩種變量傳遞的方式
1. 通過調用函數,執行上下文的棧傳遞變量。
函數每調用一次,就需要給它的參數和變量準備新的存儲空間,就會創建一個新的環境將(變量和參數的)標識符合變量做映射。對于遞歸的情況,執行上下文,即通過環境的引用是在棧中進行管理的。這里的棧對應了調用棧。
JavaScript 引擎會以堆棧的方式來處理它們,這個堆棧,我們稱其為函數調用棧(call stack)。棧底永遠都是全局上下文,而棧頂就是當前正在執行的上下文。
這里舉個例子:比如用遞歸的方式計算n的階乘。
2. 作用域鏈
在 JavaScript 中有一個內部屬性 [[ Scope ]] 來記錄函數的作用域。在函數調用的時候,JavaScript 會為這個函數所在的新作用域創建一個環境,這個環境有一個外層域,它通過 [[ Scope ]] 創建并指向了外部作用域的環境。因此在 JavaScript 中存在一個作用域鏈,它以當前作用域為起點,連接了外部的作用域,每個作用域鏈最終會在全局環境里終結。全局作用域的外部作用域指向了null。
作用域鏈,是由當前環境與上層環境的一系列變量對象組成,它保證了當前執行環境對符合訪問權限的變量和函數的有序訪問。
作用域是一套規則,是在 JavaScript 引擎編譯的時候確定的。
作用域鏈是在執行上下文的創建階段創建的,這是在 JavaScript 引擎解釋執行階段確定的。
function myFunc( myParam ) {
var myVar = 123;
return myFloat;
}
var myFloat = 2.0; // 1
myFunc('ab'); // 2
當程序運行到標志 1 的時候:
函數 myFunc 通過 [[ Scope]] 連接著它的作用域,全局作用域。
當程序運行到標志 2 的時候,JavaScript 會創建一個新的作用域用來管理參數和本地變量。
由于外層作用域鏈,使得 myFunC 可以訪問到外層的 myFloat 。
這就是 Javascript 語言特有的"作用域鏈"結構(chain scope),子對象會一級一級地向上尋找所有父對象的變量。所以,父對象的所有變量,對子對象都是可見的,反之則不成立。
作用域鏈是保證對執行環境有權訪問的所有變量和函數的有序訪問。作用域鏈的前端始終是當前執行的代碼所在環境的變量對象。而前面我們已經講了變量對象的創建過程。作用域鏈的下一個變量對象來自包含環境即外部環境,這樣,一直延續到全局執行環境;全局執行環境的變量對象始終都是作用域鏈中的最后一個對象。
七. JavaScript 中的閉包
當函數可以記住并訪問所在的詞法作用域,即使函數是在當前詞法作用域之外執行,這時就產生了閉包。
接下來看看大家對閉包的定義是什么樣的:
MDN 對閉包的定義:
閉包是指那些能夠訪問獨立(自由)變量的函數(變量在本地使用,但定義在一個封閉的作用域中)。換句話說,這些函數可以「記憶」它被創建時候的環境。
《JavaScript 權威指南(第6版)》對閉包的定義:
函數對象可以通過作用域鏈相互關聯起來,函數體內部的變量都可以保存在函數作用域內,這種特性在計算機科學文獻中稱為閉包。
《JavaScript 高級程序設計(第3版)》對閉包的定義:
閉包是指有權訪問另一個函數作用域中的變量的函數。
最后是阮一峰老師對閉包的解釋:
由于在 Javascript 語言中,只有函數內部的子函數才能讀取局部變量,因此可以把閉包簡單理解成定義在一個函數內部的函數。它的最大用處有兩個,一個是前面提到的可以讀取函數內部的變量,另一個就是讓這些變量的值始終保持在內存中。
再來對比看看 OC,Swift,JS,Python 4種語言的閉包寫法有何不同:
void test() {
int value = 10;
void(^block)() = ^{ NSLog(@"%d", value); };
value++;
block();
}
// 輸出10
func test() {
var value = 10
let closure = { print(value) }
value += 1
closure()
}
// 輸出11
function test() {
var value = 10;
var closure = function () {
console.log(value);
}
value++;
closure();
}
// 輸出11
def test():
value = 10
def closure():
print(value)
value = value + 1
closure()
// 輸出11
可以看出 OC 的寫法默認是和其他三種語言不同的。關于 OC 的閉包原理,iOS 開發的同學應該都很清楚了,這里不再贅述。當然,想要第一種 OC 的寫法輸出11,也很好改,只要把外部需要捕獲進去的變量前面加上 __block 關鍵字就可以了。
最后結合作用域鏈和閉包舉一個例子:
function createInc(startValue) {
return function (step) {
startValue += step;
return startValue;
}
}
var inc = createInc(5);
inc(3);
當代碼進入到 Global Execution Context 之后,會創建 Global Variable Object。全局執行上下文壓入執行上下文棧。
Global Variable Object 初始化會創建 createInc ,并指向一個函數對象,初始化 inc ,此時還是 undefined。
接著代碼執行到 createInc(5),會創建 Function Execution Context,并壓入執行上下文棧。會創建 createInc Activation Object。
由于還沒有執行這個函數,所以 startValue 的值還是 undefined。接下來就要執行 createInc 函數了。
當 createInc 函數執行的最后,并退出的時候,Global VO中的 inc 就會被設置;這里需要注意的是,雖然 create Execution Context 退出了執行上下文棧,但是因為 inc 中的成員仍然引用 createInc AO(因為 createInc AO 是 function(step) 函數的 parent scope ),所以 createInc AO 依然在 Scope 中。
接著再開始執行 inc(3)。
當執行 inc(3) 代碼的時候,代碼將進入 inc Execution Context,并為該執行上下文創建 VO/AO,scope chain 和設置 this;這時,inc AO將指向 createInc AO。
最后,inc Execution Context 退出了執行上下文棧,但是 createInc AO 沒有銷毀,可以繼續訪問。
八. JavaScript 中的模塊
由作用域又可以引申出模塊的概念。
在 ES6 中會大量用到模塊,通過模塊系統進行加載時,ES6 會將文件當作獨立的模塊來處理。每個模塊都可以導入其他模塊或特定的 API 成員,同樣也可以導出自己的 API 成員。
模塊有兩個主要特征:
- 為創建內部作用域而調用了一個包裝函數;
- 包裝函數的返回值必須至少包括一個對內部函數的引用,這樣就會創建涵蓋整個包裝函數內部作用域的閉包。
JavaScript 最主要的有 CommonJS 和 AMD 兩種,前者用于服務器,后者用于瀏覽器。在 ES6 中的 Module 使得編譯時就能確定模塊的依賴關系,以及輸入輸出的變量。CommonJS 和 AMD 模塊都只能運行時確定這些東西。
CommonJS 模塊就是對象,輸入時必須查找對象屬性。屬于運行時加載。CommonJS 輸入的是被輸出值的拷貝,并不是引用。
ES6 的 Module 在編譯時就完成模塊編譯,屬于編譯時加載,效率要比 CommonJS 模塊的加載方式高。ES6 模塊的運行機制與 CommonJS 不一樣,它遇到模塊加載命令 import 時不會去執行模塊,只會生成一個動態的只讀引用。等到真正需要的時候,再去模塊中取值。ES6 模塊加載的變量是動態引用,原始值變了,輸入的值也會跟著變,并且不會緩存值,模塊里面的變量綁定其所在的模塊。
Reference:
學習Javascript閉包(Closure)
JavaScript的執行上下文
V8
V8 JavaScript Engine
V8 Ignition:JS 引擎與字節碼的不解之緣
Ignition: An Interpreter for V8 [BlinkOn]