從 JavaScript 作用域說開去

一縷殤流化隱半邊冰霜發表於2017-05-30

目錄

  • 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 以後支援塊級作用域的有以下幾個:

  1. with 語句
    用 with 從物件中建立出的作用域僅在 with 宣告中而非外 部作用域中有效。
  2. try/catch 語句
    JavaScript 的 ES3 規範中規定 try/catch 的 catch 分句會建立一個塊作用域,其中宣告的變數僅在 catch 內部有效。
  3. let 關鍵字
    let關鍵字可以將變數繫結到所在的任意作用域中(通常是{ .. }內部)。換句話說,let 為其宣告的變數隱式地了所在的塊作用域。
  4. 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 ,我們可以

  1. 避免全域性變數,隱藏全域性作用域的變數。
  2. 建立新的環境,避免共享。
  3. 保持全域性的資料對於構造器的資料相對獨立。
  4. 將全域性的資料附加到單例物件上。
  5. 將全域性資料附加到方法中。

四. 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個問題:

  1. 效能問題
    變數查詢會變慢,因為物件是臨時性的插入到作用域鏈中的。

  2. 程式碼不確定性
    @Brendan Eich 解釋,廢棄 with 的根本原因不是因為效能問題,原因是因為“with 可能會違背當前的程式碼上下文,使得程式的解析(例如安全性)變得困難而繁瑣”。

  3. 程式碼壓縮工具不會壓縮 with 語句中的變數名

所以在嚴格模式下,已經嚴格禁止使用 with 語句。


Uncaught SyntaxError: Strict mode code may not include a with statement複製程式碼

如果還是想避免使用 with 語句,有兩種方法:

  1. 用一個臨時變數替代傳進 with 語句的物件。
  2. 如果不想引入臨時變數,可以使用 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 函式帶來的問題總結如下:

  1. 函式變成了字串,可讀性差,存在安全隱患。
  2. 函式需要執行編譯器,即使只是為了執行一個微不足道的賦值語句。這使得執行速度變慢。
  3. 讓 JSLint 失效,讓它檢測問題的能力大打折扣。

五. JavaScript 執行上下文

從 JavaScript 作用域說開去

這個事情要從 JavaScript 原始碼如何被執行開始說起。

我們都知道 JavaScript 是指令碼語言,它只有 runtime,沒有編譯型語言的 buildTime,那它是如何被各大瀏覽器執行起來的呢?

JavaScript 程式碼是被各個瀏覽器引擎編譯和執行起來的。JavaScript 引擎的程式碼解析和執行過程的目標就是在最短時間內編譯出最優化的程式碼。JavaScript 引擎還需要負責管理記憶體,負責垃圾回收,與宿主語言的互動等。流行的引擎有以下幾種:
蘋果公司的 JavaScriptCore (JSC) 引擎,Mozilla 公司的 SpiderMonkey,微軟 Internet Explorer 的 Chakra (JScript引擎),Microsoft Edge 的 Chakra (JavaScript引擎) ,谷歌 Chrome 的 V8。

從 JavaScript 作用域說開去

其中 V8 引擎是最著名的開源的引擎,它和前面那幾個引擎有一個最大的區別是:主流引擎都是基於位元組碼的實現,V8 的做法非常極致,直接跳過了位元組碼這一層,直接把 JS 編譯成機器碼。所以 V8 是沒有直譯器的。(但是這都是歷史,V8 現在最新版是有直譯器的)

從 JavaScript 作用域說開去

在2017年5月1號之後, Chrome 的 V8 引擎的v8 5.9 釋出了,其中的 Ignition 位元組碼直譯器將預設啟動 :V8 Release 5.9 。v8 自此回到了位元組碼的懷抱。

V8 在有了位元組碼以後,消除 Cranshaft 這個舊的編譯器,並讓新的 Turbofan 直接從位元組碼來優化程式碼,並當需要進行反優化的時候直接反優化到位元組碼,而不需要再考慮 JS 原始碼。去掉 Cranshaft 以後,就成了 Turbofan + Ignition 的組合了。

從 JavaScript 作用域說開去

Ignition + TurboFan 的組合,就是位元組碼直譯器 + JIT 編譯器的黃金組合。這一黃金組合在很多 JS 引擎中都有所使用,例如微軟的 Chakra,它首先解釋執行位元組碼,然後觀察執行情況,如果發現熱點程式碼,那麼後臺的 JIT 就把位元組碼編譯成高效程式碼,之後便只執行高效程式碼而不再解釋執行位元組碼。蘋果公司的 SquirrelFish Extreme 也引入了 JIT。SpiderMonkey 更是如此,所有 JS 程式碼最初都是被直譯器解釋執行的,直譯器同時收集執行資訊,當它發現程式碼變熱了之後,JaegerMonkey、IonMonkey 等 JIT 便登場,來編譯生成高效的機器碼。

總結一下:

JavaScript 程式碼會先被引擎編譯,轉化成能被直譯器識別的位元組碼。

從 JavaScript 作用域說開去

原始碼會被詞法分析,語法分析,生成 AST 抽象語法樹。

從 JavaScript 作用域說開去

AST 抽象語法樹又會被位元組碼生成器進行多次優化,最終生成了中間態的位元組碼。這時的位元組碼就可以被直譯器執行了。

這樣,JavaScript 程式碼就可以被引擎跑起來了。

JavaScript 在執行過程中涉及到的作用域有3種:

  1. 全域性作用域(Global Scope)JavaScript 程式碼開始執行的預設環境
  2. 區域性作用域(Local Scpoe)程式碼進入一個 JavaScript 函式
  3. Eval 作用域 使用 eval() 執行程式碼

當 JavaScript 程式碼執行的時候,引擎會建立不同的執行上下文,這些執行上下文就構成了一個執行上下文棧(Execution context stack,ECS)。

全域性執行上下文永遠都在棧底,當前正在執行的函式在棧頂。

從 JavaScript 作用域說開去

當 JavaScript 引擎遇到一個函式執行的時候,就會建立一個執行上下文,並且壓入執行上下文棧,當函式執行完畢的時候,就會將函式的執行上下文從棧中彈出。

對於每個執行上下文都有三個重要的屬性,變數物件(Variable object,VO),作用域鏈(Scope chain)和this。這三個屬性跟程式碼執行的行為有很重要的關係。

變數物件 VO 是與執行上下文相關的資料作用域。它是一個與上下文相關的特殊物件,其中儲存了在上下文中定義的變數和函式宣告。也就是說,一般 VO 中會包含以下資訊:

  1. 建立 arguments object
  2. 查詢函式宣告(Function declaration)
  3. 查詢變數宣告(Variable declaration)

從 JavaScript 作用域說開去

上圖也解釋了,為何函式提升優先順序會在變數提升前面。

這裡還會牽扯到活動物件(Activation object):
只有全域性上下文的變數物件允許通過 VO 的屬性名稱間接訪問。在函式執行上下文中,VO 是不能直接訪問的,此時由活動物件(Activation Object, 縮寫為AO)扮演 VO 的角色。活動物件是在進入函式上下文時刻被建立的,它通過函式的 arguments 屬性初始化。

從 JavaScript 作用域說開去

Arguments Objects 是函式上下文裡的啟用物件 AO 中的內部物件,它包括下列屬性:

  1. callee:指向當前函式的引用
  2. length: 真正傳遞的引數的個數
  3. properties-indexes:就是函式的引數值(按引數列表從左到右排列)

JavaScript 直譯器建立執行上下文的時候,會經歷兩個階段:

  1. 建立階段(當函式被呼叫,但是開始執行函式內部程式碼之前)
    建立 Scope chain,建立 VO/AO(variables, functions and arguments),設定 this 的值。
  2. 啟用 / 程式碼執行階段
    設定變數的值,函式的引用,然後解釋/執行程式碼。

VO 和 AO 的區別就在執行上下文的這兩個生命週期裡面。

從 JavaScript 作用域說開去

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 的時候:

從 JavaScript 作用域說開去

函式 myFunc 通過 [[ Scope]] 連線著它的作用域,全域性作用域。

當程式執行到標誌 2 的時候,JavaScript 會建立一個新的作用域用來管理引數和本地變數。

從 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。全域性執行上下文壓入執行上下文棧。

從 JavaScript 作用域說開去

Global Variable Object 初始化會建立 createInc ,並指向一個函式物件,初始化 inc ,此時還是 undefined。

接著程式碼執行到 createInc(5),會建立 Function Execution Context,並壓入執行上下文棧。會建立 createInc Activation Object。

從 JavaScript 作用域說開去

由於還沒有執行這個函式,所以 startValue 的值還是 undefined。接下來就要執行 createInc 函式了。

從 JavaScript 作用域說開去

當 createInc 函式執行的最後,並退出的時候,Global VO中的 inc 就會被設定;這裡需要注意的是,雖然 create Execution Context 退出了執行上下文棧,但是因為 inc 中的成員仍然引用 createInc AO(因為 createInc AO 是 function(step) 函式的 parent scope ),所以 createInc AO 依然在 Scope 中。

接著再開始執行 inc(3)。

從 JavaScript 作用域說開去

當執行 inc(3) 程式碼的時候,程式碼將進入 inc Execution Context,併為該執行上下文建立 VO/AO,scope chain 和設定 this;這時,inc AO將指向 createInc AO。

從 JavaScript 作用域說開去

最後,inc Execution Context 退出了執行上下文棧,但是 createInc AO 沒有銷燬,可以繼續訪問。

八. JavaScript 中的模組

由作用域又可以引申出模組的概念。

在 ES6 中會大量用到模組,通過模組系統進行載入時,ES6 會將檔案當作獨立的模組來處理。每個模組都可以匯入其他模組或特定的 API 成員,同樣也可以匯出自己的 API 成員。

模組有兩個主要特徵:

  1. 為建立內部作用域而呼叫了一個包裝函式;
  2. 包裝函式的返回值必須至少包括一個對內部函式的引用,這樣就會建立涵蓋整個包裝函式內部作用域的閉包。

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]

相關文章