理解JavaScript中的作用域和上下文

景莊發表於2016-03-08
JavaScript對於作用域(Scope)和上下文(Context)的實現是這門語言的一個非常獨到的地方,部分歸功於其獨特的靈活性。
函式可以接收不同的的上下文和作用域。這些概念為JavaScript中的很多強大的設計模式提供了堅實的基礎。
然而這也概念也非常容易給開發人員帶來困惑。為此,本文將全面的剖析這些概念,並闡述不同的設計模式是如何利用它們的。

上下文(Context)和作用域(Scope)

首先需要知道的是,上下文和作用域是兩個完全不同的概念。多年來,我發現很多開發者會混淆這兩個概念(包括我自己),
錯誤的將兩個概念混淆了。平心而論,這些年來很多術語都被混亂的使用了。

函式的每次呼叫都有與之緊密相關的作用域和上下文。從根本上來說,作用域是基於函式的,而上下文是基於物件的。
換句話說,作用域涉及到所被呼叫函式中的變數訪問,並且不同的呼叫場景是不一樣的。上下文始終是this關鍵字的值,
它是擁有(控制)當前所執行程式碼的物件的引用。

變數作用域

一個變數可以被定義在區域性或者全域性作用域中,這建立了在執行時(runtime)期間變數的訪問性的不同作用域範圍。
任何被定義的全域性變數,意味著它需要在函式體的外部被宣告,並且存活於整個執行時(runtime),並且在任何作用域中都可以被訪問到。
在ES6之前,區域性變數只能存在於函式體中,並且函式的每次呼叫它們都擁有不同的作用域範圍。
區域性變數只能在其被呼叫期的作用域範圍內被賦值、檢索、操縱。

需要注意,在ES6之前,JavaScript不支援塊級作用域,這意味著在if語句、switch語句、for迴圈、while迴圈中無法支援塊級作用域。
也就是說,ES6之前的JavaScript並不能構建類似於Java中的那樣的塊級作用域(變數不能在語句塊外被訪問到)。但是,
從ES6開始,你可以通過let關鍵字來定義變數,它修正了var關鍵字的缺點,能夠讓你像Java語言那樣定義變數,並且支援塊級作用域。看兩個例子:

ES6之前,我們使用var關鍵字定義變數:

之所以能夠訪問,是因為var關鍵字宣告的變數有一個變數提升的過程。而在ES6場景,推薦使用let關鍵字定義變數:

這種方式,能夠避免很多錯誤。

什麼是this上下文

上下文通常取決於函式是如何被呼叫的。當一個函式被作為物件中的一個方法被呼叫的時候,this被設定為呼叫該方法的物件上:

這個準則也適用於當呼叫函式時使用new操作符來建立物件的例項的情況下。在這種情況下,在函式的作用域內部this的值被設定為新建立的例項:

當呼叫一個為繫結函式時,this預設情況下是全域性上下文,在瀏覽器中它指向window物件。需要注意的是,ES5引入了嚴格模式的概念,
如果啟用了嚴格模式,此時上下文預設為undefined

執行環境(execution context)

JavaScript是一個單執行緒語言,意味著同一時間只能執行一個任務。當JavaScript直譯器初始化執行程式碼時,
它首先預設進入全域性執行環境(execution context),從此刻開始,函式的每次呼叫都會建立一個新的執行環境。

這裡會經常引起新手的困惑,這裡提到了一個新的術語——執行環境(execution context),它定義了變數或函式有權訪問的其他資料,決定了它們各自的行為。
它更偏向於作用域的作用,而不是我們前面討論的上下文(Context)。請務必仔細的區分執行環境和上下文這兩個概念(注:英文容易造成混淆)。
說實話,這是個非常糟糕的命名約定,但是它是ECMAScript規範制定的,你還是遵守吧。

每個函式都有自己的執行環境。當執行流進入一個函式時,函式的環境就會被推入一個環境棧中(execution stack)。在函式執行完後,棧將其環境彈出,
把控制權返回給之前的執行環境。ECMAScript程式中的執行流正是由這個便利的機制控制著。

執行環境可以分為建立和執行兩個階段。在建立階段,解析器首先會建立一個變數物件(variable object,也稱為活動物件 activation object),
它由定義在執行環境中的變數、函式宣告、和引數組成。在這個階段,作用域鏈會被初始化,this的值也會被最終確定。
在執行階段,程式碼被解釋執行。

每個執行環境都有一個與之關聯的變數物件(variable object),環境中定義的所有變數和函式都儲存在這個物件中。
需要知道,我們無法手動訪問這個物件,只有解析器才能訪問它。

作用域鏈(The Scope Chain)

當程式碼在一個環境中執行時,會建立變數物件的一個作用域鏈(scope chain)。作用域鏈的用途是保證對執行環境有權訪問的所有變數和函式的有序訪問。
作用域鏈包含了在環境棧中的每個執行環境對應的變數物件。通過作用域鏈,可以決定變數的訪問和識別符號的解析。
注意,全域性執行環境的變數物件始終都是作用域鏈的最後一個物件。我們來看一個例子:

上述程式碼一共包括三個執行環境:全域性環境、changeColor()的區域性環境、swapColors()的區域性環境。
上述程式的作用域鏈如下圖所示:

scope chain example

從上圖發現。內部環境可以通過作用域鏈訪問所有的外部環境,但是外部環境不能訪問內部環境中的任何變數和函式。
這些環境之間的聯絡是線性的、有次序的。

對於識別符號解析(變數名或函式名搜尋)是沿著作用域鏈一級一級地搜尋識別符號的過程。搜尋過程始終從作用域鏈的前端開始,
然後逐級地向後(全域性執行環境)回溯,直到找到識別符號為止。

閉包

閉包是指有權訪問另一函式作用域中的變數的函式。換句話說,在函式內定義一個巢狀的函式時,就構成了一個閉包,
它允許巢狀函式訪問外層函式的變數。通過返回巢狀函式,允許你維護對外部函式中區域性變數、引數、和內函式宣告的訪問。
這種封裝允許你在外部作用域中隱藏和保護執行環境,並且暴露公共介面,進而通過公共介面執行進一步的操作。可以看個簡單的例子:

模組模式最流行的閉包型別之一,它允許你模擬公共的、私有的、和特權成員:

模組類似於一個單例物件。由於在上面的程式碼中我們利用了(function() { ... })();的匿名函式形式,因此當編譯器解析它的時候會立即執行。
在閉包的執行上下文的外部唯一可以訪問的物件是位於返回物件中的公共方法和屬性。然而,因為執行上下文被儲存的緣故,
所有的私有屬性和方法將一直存在於應用的整個生命週期,這意味著我們只有通過公共方法才可以與它們互動。

另一種型別的閉包被稱為立即執行的函式表示式(IIFE)。其實它很簡單,只不過是一個在全域性環境中自執行的匿名函式而已:

對於保護全域性名稱空間免受變數汙染而言,這種表示式非常有用,它通過構建函式作用域的形式將變數與全域性名稱空間隔離,
並通過閉包的形式讓它們存在於整個執行時(runtime)。在很多的應用和框架中,這種封裝原始碼的方式用處非常的流行,
通常都是通過暴露一個單一的全域性介面的方式與外部進行互動。

Call和Apply

這兩個方法內建在所有的函式中(它們是Function物件的原型方法),允許你在自定義上下文中執行函式。
不同點在於,call函式需要引數列表,而apply函式需要你提供一個引數陣列。如下:

兩個結果是相同的,函式f在物件o的上下文中被呼叫,並提供了兩個相同的引數12

在ES5中引入了Function.prototype.bind方法,用於控制函式的執行上下文,它會返回一個新的函式,
並且這個新函式會被永久的繫結到bind方法的第一個引數所指定的物件上,無論該函式被如何使用。
它通過閉包將函式引導到正確的上下文中。對於低版本瀏覽器,我們可以簡單的對它進行實現如下(polyfill):

bind()方法通常被用在上下文丟失的場景下,例如物件導向和事件處理。之所以要這麼做,
是因為節點的addEventListener方法總是為事件處理器所繫結的節點的上下文中執行回撥函式,
這就是它應該表現的那樣。但是,如果你想要使用高階的物件導向技術,或需要你的回撥函式成為某個方法的例項,
你將需要手動調整上下文。這就是bind方法所帶來的便利之處:

回顧上面bind方法的原始碼,你可能會注意到有兩次呼叫涉及到了Arrayslice方法:

我們知道,arguments物件並不是一個真正的陣列,而是一個類陣列物件,雖然具有length屬性,並且值也能夠被索引,
但是它們不支援原生的陣列方法,例如slicepush。但是,由於它們具有和陣列類似的行為,陣列的方法能夠被呼叫和劫持,
因此我們可以通過類似於上面程式碼的方式達到這個目的,其核心是利用call方法。

這種呼叫其他物件方法的技術也可以被應用到物件導向中,我們可以在JavaScript中模擬經典的繼承方式:

也就是利用callapply在子類(MyClass)的例項中呼叫超類(MySuperClass)的方法。

ES6中的箭頭函式

ES6中的箭頭函式可以作為Function.prototype.bind()的替代品。和普通函式不同,箭頭函式沒有它自己的this值,
它的this值繼承自外圍作用域。

對於普通函式而言,它總會自動接收一個this值,this的指向取決於它呼叫的方式。我們來看一個例子:

在上面的例子中,最直接的想法是直接使用this.add(piece),但不幸的是,在JavaScript中你不能這麼做,
因為each的回撥函式並未從外層繼承this值。在該回撥函式中,this的值為windowundefined
因此,我們使用臨時變數self來將外部的this值匯入內部。我們還有兩種方法解決這個問題:

使用ES5中的bind()方法

使用ES6中的箭頭函式

在ES6版本中,addAll方法從它的呼叫者處獲得了this值,內部函式是一個箭頭函式,所以它整合了外部作用域的this值。

注意:對回撥函式而言,在瀏覽器中,回撥函式中的thiswindowundefined(嚴格模式),而在Node.js中,
回撥函式的thisglobal。例項程式碼如下:

小結

在你學習高階的設計模式之前,理解這些概念非常的重要,因為作用域和上下文在現代JavaScript中扮演著的最基本的角色。
無論我們談論的是閉包、物件導向、繼承、或者是各種原生實現,上下文和作用域都在其中扮演著至關重要的角色。
如果你的目標是精通JavaScript語言,並且深入的理解它的各個組成,那麼作用域和上下文便是你的起點。

參考資料

  1. Understanding Scope and Context in JavaScript
  2. JavaScript高階程式設計,section 4.2
  3. Arrow functions vs. bind()
  4. 理解與使用Javascript中的回撥函式

相關文章