從 JavaScript 做用域說開去

目錄

  • 1.靜態做用域與動態做用域
  • 2.變量的做用域
  • 3.JavaScript 中變量的做用域
  • 4.JavaScript 欺騙做用域
  • 5.JavaScript 執行上下文
  • 6.JavaScript 中的做用域鏈
  • 7.JavaScript 中的閉包
  • 8.JavaScript 中的模塊

一. 靜態做用域與動態做用域

在電腦程序設計中,做用域(scope,或譯做有效範圍)是名字(name)與實體(entity)的綁定(binding)保持有效的那部分計算機程序。不一樣的編程語言可能有不一樣的做用域和名字解析。而同一語言內也可能存在多種做用域,隨實體的類型變化而不一樣。做用域類別影響變量的綁定方式,根據語言使用靜態做用域仍是動態做用域變量的取值可能會有不一樣的結果。javascript

  • 包含標識符的宣告或定義;
  • 包含語句和/或表達式,定義或部分關於可運行的算法;
  • 嵌套嵌套或被嵌套嵌套。

名字空間是一種做用域,使用做用域的封裝性質去邏輯上組羣起關相的衆識別子於單一識別子之下。所以,做用域能夠影響這些內容的名字解析
程序員常會縮進他們的源代碼中的做用域,改善可讀性。php

做用域又分爲兩種,靜態做用域和動態做用域。html

靜態做用域又叫作詞法做用域,採用詞法做用域的變量叫詞法變量。詞法變量有一個在編譯時靜態肯定的做用域。詞法變量的做用域能夠是一個函數或一段代碼,該變量在這段代碼區域內可見(visibility);在這段區域之外該變量不可見(或沒法訪問)。詞法做用域裏,取變量的值時,會檢查函數定義時的文本環境,捕捉函數定義時對該變量的綁定。前端

function f() {
    function g() {
  }
}複製代碼

靜態(詞法)做用域,就是能夠無須執行程序而只從程序源碼的角度,就能夠看出程序是如何工做的。從上面的例子中能夠確定,函數 g 是被函數 f 包圍在內部。java

大多數如今程序設計語言都是採用靜態做用域規則,如C/C++、C#、Python、Java、JavaScript……python

相反,採用動態做用域的變量叫作動態變量。只要程序正在執行定義了動態變量的代碼段,那麼在這段時間內,該變量一直存在;代碼段執行結束,該變量便消失。這意味着若是有個函數f,裏面調用了函數g,那麼在執行g的時候,f裏的全部局部變量都會被g訪問到。而在靜態做用域的狀況下,g不能訪問f的變量。動態做用域裏,取變量的值時,會由內向外逐層檢查函數的調用鏈,並打印第一次遇到的那個綁定的值。顯然,最外層的綁定便是全局狀態下的那個值。git

function g() {
}

function f() {
   g();
}複製代碼

當咱們調用f(),它會調用g()。在執行期間,g被f調用表明了一種動態的關係。程序員

採用動態做用域的語言有Pascal、Emacs Lisp、Common Lisp(兼有靜態做用域)、Perl(兼有靜態做用域)。C/C++是靜態做用域語言,但在宏中用到的名字,也是動態做用域。github

二. 變量的做用域

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 是腳本語言,它只有 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種:

  1. 全局做用域(Global Scope)JavaScript 代碼開始運行的默認環境
  2. 局部做用域(Local Scpoe)代碼進入一個 JavaScript 函數
  3. Eval 做用域 使用 eval() 執行代碼

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

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

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

對於每一個執行上下文都有三個重要的屬性,變量對象(Variable object,VO),做用域鏈(Scope chain)和this。這三個屬性跟代碼運行的行爲有很重要的關係。

變量對象 VO 是與執行上下文相關的數據做用域。它是一個與上下文相關的特殊對象,其中存儲了在上下文中定義的變量和函數聲明。也就是說,通常 VO 中會包含如下信息:

  1. 建立 arguments object
  2. 查找函數聲明(Function declaration)
  3. 查找變量聲明(Variable declaration)

上圖也解釋了,爲什麼函數提高優先級會在變量提高前面。

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

Arguments Objects 是函數上下文裏的激活對象 AO 中的內部對象,它包括下列屬性:

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

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

  1. 建立階段(當函數被調用,可是開始執行函數內部代碼以前)
    建立 Scope chain,建立 VO/AO(variables, functions and arguments),設置 this 的值。
  2. 激活 / 代碼執行階段
    設置變量的值,函數的引用,而後解釋/執行代碼。

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 成員。

模塊有兩個主要特徵:

  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]

相關文章
相關標籤/搜索