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對象(如果在瀏覽器中)。然而如果函數在strict 模式下被執行("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
}
}
}