javascript中的作用域(scope)和上下文(context)是這門語言的獨特之處,這部分歸功於他們帶來的靈活性。每個函數有不同的變數上下文和作用域。這些概念是javascript中一些強大的設計模式的後盾。然而這也給開發人員帶來很大困惑。以下全面揭示了javascript中的上下文和作用域的不同,以及各種設計模式如何使用他們。
上下文vs 作用域
首先需要澄清的問題是上下文和作用域是不同的概念。多年來我注意到許多開發者經常將這兩個術語混淆,錯誤的將一個描述為另一個。平心而論,這些術語變得非常混亂不堪。
每個函數呼叫都有與之相關的作用域和上下文。從根本上說,範圍是基於函數(function-based)而上下文是基於物件(object-based)。換句話說,作用域是和每次函數呼叫時變數的存取有關,並且每次呼叫都是獨立的。上下文總是關鍵字this 的值,是呼叫目前可執行程式碼的物件的參考。
變數作用域
變數能夠被定義在局部或全域作用域,這導致運行時變數的存取來自不同的作用域。全域變數需被宣告在函數體外,在整個運行過程中都存在,能在任何作用域中存取和修改。局部變數僅在函數體內定義,且每次函數呼叫都有不同的作用域。這個主題是僅在呼叫中的賦值,求值和對值的操作,不能存取作用域之外的值。
目前javascript不支援區塊級作用域,區塊級作用域指在if語句,switch語句,循環語句等語句區塊中定義變量,這表示變數不能在語句區塊之外被存取。當前任何在語句塊中定義的變數都能在語句塊之外存取。然而,這種情況很快就會改變,let 關鍵字已經正式加入ES6規格。用它來取代var關鍵字可以將局部變數聲明為區塊級作用域。
"this" 上下文
上下文通常是取決於一個函數如何被呼叫。當函數作為物件的方法被呼叫時,this 被設定為呼叫方法的物件:
複製代碼代碼如下:
var object = {
foo: function(){
alert(this === object);
}
};
object.foo(); // true
同樣的原理也適用於當呼叫一個函數時透過new的操作符建立一個物件的實例。以這種方式呼叫時,this 的值將被設定為新建立的實例:
複製代碼代碼如下:
function foo(){
alert(this);
}
foo() // window
new foo() // foo
當呼叫一個未綁定函數,this 將被預設為全域上下文(global context) 或window物件(如果在瀏覽器中)。然而如果函數在嚴格模式下被執行("use strict"),this的值將被預設為undefined。
執行上下文和作用域鏈
javascript是一個單線程語言,這意味著在瀏覽器中同時只能做一件事情。當javascript解釋器初始執行程式碼,它首先預設竟如全域上下文。每次呼叫一個函數將會建立一個新的執行上下文。
這裡經常發生混淆,這術語」執行上下文(execution context)「在這裡的所要表達的意思是作用域,不是前面討論的上下文。這是槽糕的命名,然而這術語ECMAScript規範所定義的,無奈的遵守吧。
每次新建立一個執行上下文,會被加入到作用域鏈的頂部,又是也成為執行或呼叫堆疊。瀏覽器總是運行在位於作用域鏈頂部目前執行上下文。一旦完成,它(當前執行上下文)將從棧頂被移除並且將控制權歸還給先前的執行上下文。例如:
複製代碼代碼如下:
function first(){
second();
function second(){
third();
function third(){
fourth();
function fourth(){
// do something
}
}
}
}
first();
運行前面的程式碼將會導致嵌套的函數從上倒下來執行直到fourth 函數,此時作用域鏈從上到下為: fourth, third, second, first, global。 fourth 函數能夠存取全域變數和任何在first,second和third函數中定義的變量,就如同存取自己的變數一樣。一旦fourth函數執行完成,fourth暈高興上下文將被從作用域鏈頂端移除並且執行將返回到thrid函數。這一過程持續進行直到所有程式碼已完成執行。
不同執行上下文之間的變數命名衝突透過攀爬作用域鏈解決,從局部直到全域。這意味著具有相同名稱的局部變數在作用域鏈中有更高的優先權。
簡單的說,每次你試圖存取函數執行上下文中的變數時,查找進程總是從自己的變數物件開始。如果在自己的變數物件中沒發現要找的變量,繼續搜尋作用域鏈。它將攀爬作用域鏈檢查每一個執行上下文的變數物件去尋找和變數名稱相符的值。
閉包
當一個巢狀的函數在定義(作用域)的外面被訪問,以至它可以在外部函數返回後被執行,此時一個閉包形成。它(閉包)維護(在內部函數中)對外部函數中局部變量,arguments和函數聲明的存取。封裝允許我們從外部作用域中隱藏和保護執行上下文,而暴露公共接口,透過接口進一步操作。一個簡單的例子看起來如下:
複製代碼代碼如下:
function foo(){
var local = 'private variable';
return function bar(){
return local;
}
}
var getLocalVariable = foo();
getLocalVariable() // private variable
其中最受歡迎的閉包類型是廣為人知的模組模式。它允許你模擬公共的,私有的和特權成員:
複製代碼代碼如下:
var Module = (function(){
var privateProperty = 'foo';
function privateMethod(args){
//do something
}
return {
publicProperty: "",
publicMethod: function(args){
//do something
},
privilegedMethod: function(args){
privateMethod(args);
}
}
})();
模組其實有些類似單例,在最後加上一對括號,當解譯器解釋完後立即執行(立即執行函數)。閉包執行上下位的外部唯一可用的成員是傳回物件中公用的方法和屬性(例如Module.publicMethod)。然而,所有的私有屬性和方法在整個程式的生命週期中都將存在,由於(閉包)使執行上下文收到保護,和變數的互動要透過公用的方法。
另一種類型的閉包叫做立即呼叫函數表達式(immediately-invoked function expression IIFE),無非是一個在window上下文中的自調用匿名函數(self-invoked anonymous function)。
複製代碼代碼如下:
function(window){
var a = 'foo', b = 'bar';
function private(){
// do something
}
window.Module = {
public: function(){
// do something
}
};
})(this);
對保護全域命名空間,這種表達式非常有用,所有在函數體內聲明的變數都是局部變量,並透過閉包在整個運行環境保持存在。這種封裝原始碼的方式對程式和框架都是非常流行的,通常暴露單一全域介面與外界互動。
Call 和Apply
這兩個簡單的方法,內建在所有的函數中,允許在自訂上下文中執行函數。 call 函數需要參數列表而apply 函數允許你傳遞參數為陣列:
複製代碼代碼如下:
function user(first, last, age){
// do something
}
user.call(window, 'John', 'Doe', 30);
user.apply(window, ['John', 'Doe', 30]);
執行的結果是相同的,user 函數在window上下文上被調用,並提供了相同的三個參數。
ECMAScript 5 (ES5)引入了Function.prototype.bind方法來控制上下文,它傳回一個新函數,這函數(的上下文)被永久綁定到bind方法的第一個參數,無論函數被如何呼叫。它透過閉包修正函數的上下文,下面是為不支援的瀏覽器提供的方案:
複製代碼代碼如下:
if(!('bind' in Function.prototype)){
Function.prototype.bind = function(){
var fn = this, context = arguments[0], args = Array.prototype.slice.call(arguments, 1);
return function(){
return fn.apply(context, args);
}
}
}
它常用在上下文中遺失:物件導向和事件處理。這點有必要的因為節點的addEventListener 方法總是保持函數執行的上下文為事件處理被綁定的節點,這點很重要。然而如果你使用高階物件導向技術並且需要維護回呼函數的上下文是方法的實例,你必須手動調整上下文。這就是bind 帶來的方便:
複製代碼代碼如下:
function MyClass(){
this.element = document.createElement('div');
this.element.addEventListener('click', this.onClick.bind(this), false);
}
MyClass.prototype.onClick = function(e){
// do something
};
當回顧bind函數的原始程式碼,你可能注意到下面這一行相對簡單的程式碼,呼叫Array的一個方法:
複製代碼代碼如下:
Array.prototype.slice.call(arguments, 1);
有趣的是,這裡需要注意的是arguments物件實際上並不是一個數組,然而它經常被描述為類別數組(array-like)對象,很向nodelist(document.getElementsByTagName()方法返回的結果)。他們包含lenght屬性,值能夠被索引,但他們仍然不是數組,由於他們不支援原生的數組方法,例如slice和push。然而,由於他們有和數組類似的行為,數組的方法能被呼叫和劫持。如果你想這樣,在類別數組的上下文中執行數組方法,可參考上面的例子。
這種呼叫其他物件方法的技術也被應用到物件導向中,當在javascript中模仿經典繼承(類別繼承):
複製代碼代碼如下:
MyClass.prototype.init = function(){
// call the superclass init method in the context of the "MyClass" instance
MySuperClass.prototype.init.apply(this, arguments);
}
透過在子類別(MyClass)的實例中呼叫超類別(MySuperClass)的方法,我們能重現這種強大的設計模式。
結論
在你開始學習高階設計模式之前理解這些概念是非常重要的,由於作用域和上下文在現代javascript中扮演重要的和根本的角色。無論我們談論閉包,面向對象,和繼承或各種原生實現,上下文和作用域都扮演重要角色。如果你的目標是掌握javascript語言並深入了解它的組成,作用域和上下文應該是你的起點。
譯者補充
作者實現的bind函數是不完全的,呼叫bind回傳的函數時不能傳遞參數,下面的程式碼修復了這個問題:
複製代碼代碼如下:
if(!('bind' in Function.prototype)){
Function.prototype.bind = function(){
var fn = this, context = arguments[0], args = Array.prototype.slice.call(arguments, 1);
return function(){
return fn.apply(context, args.concat(arguments));//fixed
}
}
}