JavaScript中的作用域

sz_bdqn發表於2010-09-22

在網上看了一篇不錯的JavaScript基礎知識文章,感謝Realazy的辛苦翻譯,後來才得知原來Realazy就是我同事的朋友,暈,世界真小。(PS:CSDN上的名人孟子e章竟然也是我同事的哥們,ft。。。)

看了這篇文章才發現越是基礎的東西越能展示水平,這也就是為什麼很多朋友向我索取原始碼我都沒有回覆,並非不想分享,而是覺得自己的作品還不足以達到供別人學習的程度,還需努力。

引用:http://realazy.org/blog/2007/07/18/scope-in-javascript/

作用域(scope)是JavaScript語言的基石之一,在構建複雜程式時也可能是最令我頭痛的東西。記不清多少次在函式之間傳遞控制後忘記 this關鍵字引用的究竟是哪個物件,甚至,我經常以各種不同的混亂方式來曲線救國,試圖偽裝成正常的程式碼,以我自己的理解方式來找到所需要訪問的變數。

這篇文章將正面解決這個問題:簡述上下文(context)和作用域的定義,分析可以讓我們掌控上下文的兩種方法,最後深入一種高效的方案,它能有效解決我所碰到的90%的問題。

我在哪兒?你又是誰
JavaScript 程式的每一個位元組都是在這個或那個執行上下文(execution context)中執行的。你可以把這些上下文想象為程式碼的鄰居,它們可以給每一行程式碼指明:從何處來,朋友和鄰居又是誰。沒錯,這是很重要的資訊,因為 JavaScript社會有相當嚴格的規則,規定誰可以跟誰交往。執行上下文則是有大門把守的社群而非其內開放的小門。

我們通常可以把這些社會邊界稱為作用域,並且有充足的重要性在每一位鄰居的憲章裡立法,而這個憲章就是我們要說的上下文的作用域鏈(scope chain)。在特定的鄰里關係內,程式碼只能訪問它的作用域鏈內的變數。與超出它鄰里的變數比起來,程式碼更喜歡跟本地(local,即區域性)的打交道。

具體地說,執行一個函式會建立一個不同的執行上下文,它會將區域性作用域增加到它所定義的作用域鏈內。JavaScript通過作用域鏈的區域性向全域性攀升方式,在特定的上下文中解析識別符號。這表示,本級變數會優先於作用域鏈內上一級擁有相同名字的變數。顯而易見,當我的好友們一起談論”Mike West”(本文原作者)時,他們說的就是我,而非bluegrass singer 或是Duke professor, 儘管(按理說)後兩者著名多了。

讓我們看些例子來探索這些含義:

<script type="text/javascript">
var ima_celebrity = "Everyone can see me! I'm famous!",
the_president = "I'm the decider!";

function pleasantville() {
var the_mayor = "I rule Pleasantville with an iron fist!",
ima_celebrity = "All my neighbors know who I am!";

function lonely_house() {
var agoraphobic = "I fear the day star!",
a_cat = "Meow.";
}
}
</script>

我們的全明星,ima_celebrity, 家喻戶曉(所有人都認識她)。她在政治上積極活躍,敢於在一個相當頻繁的基層上叫囂總統(即the_president)。她會為碰到的每一個人簽名和回答問題。就是說,她不會跟她的粉絲有私下的聯絡。她相當清楚粉絲們的存在 並有他們自己某種程度上的個人生活,但也可以肯定的是,她並不知道粉絲們在幹嘛,甚至連粉絲的名字都不知道。

而在歡樂市(pleasantville)內,市長(the_mayor)是眾所周知的。她經常在她的城鎮內散步,跟她的選民聊天、握手並親吻小孩。因為歡樂市(pleasantville)還算比較大且重要的鄰居,市長在她辦公室內放置一臺紅色電話,它是一條可以直通總統的7×24熱線。她還可以看到市郊外山上的孤屋(lonely_house),但從不在意裡面住著的是誰。

而孤屋(lonely_house)是一個自我的世界。曠恐患者時常在裡面囔囔自語,玩紙牌和餵養一個小貓(a_cat)。他偶爾會給市長(the_mayor)打電話諮詢一些本地的噪音管制,甚至在本地新聞看到ima_celebrity後會寫些粉絲言語給她(當然,這是pleasantville內的ima_celebrity)。

this? 那是蝦米?
每一個執行上下文除了建立一個作用域鏈外,還提供一個名為this的關鍵字。它的普遍用法是,this作為一個獨特的功能,為鄰里們提供一個可訪問到它的途徑。但總是依賴於這個行為並不可靠:取決於我們如何進入一個特定鄰居的具體情況,this表示的完全可能是其他東西。事實上,我們如何進去鄰居家本身,通常恰恰就是this所指。有四種情形值得特別注意:

呼叫物件的方法
在經典的物件導向程式設計中,我們需要識別和引用當前物件。this極好地扮演了這個角色,為我們的物件提供了自我查詢的能力,並指向它們本身的屬性。

<script type="text/javascript">
var deep_thought = {
the_answer: 42,
ask_question: function () {
return this.the_answer;
}
};

var the_meaning = deep_thought.ask_question();
</script>

這個例子建立了一個名為deep_thought的物件,設定其屬性 the_answer為42,並建立了一個名為ask_question 的方法(method)。當deep_thought.ask_question()執行時, JavaScript為函式的呼叫建立了一個執行上下文,通過”.“運算子把this指向被引用的物件,在此是deep_thought這個物件。之後這個方法就可以通過this在鏡子中找到它自身的屬性,返回儲存在 this.the_answer中的值:42。

建構函式
類似地,當定義一個作為構造器的使用new關鍵字的函式時,this可以用來引用剛建立的物件。讓我們重寫一個能反映這個情形的例子:

<script type="text/javascript">
function BigComputer(answer) {
this.the_answer = answer;
this.ask_question = function () {
return this.the_answer;
}
}

var deep_thought = new BigComputer(42);
var the_meaning = deep_thought.ask_question();
</script>

我們編寫一個函式來建立BigComputer物件,而不是直白地建立 deep_thought物件,並通過new關鍵字例項化deep_thought為一個例項變數。當new BigComputer()被執行,後臺透明地建立了一個嶄新的物件。呼叫BigComputer後,它的this關鍵字被設定為指向新物件的引用。這個函式可以在this上設定屬性和方法,最終它會在BigComputer執行後透明地返回。

儘管如此,需要注意的是,那個deep_thought.the_question()依然可以像從前一樣執行。那這裡發生了什麼事?為何this在the_question內與BigComputer內會有所不同?簡單地說,我們是通過new進入BigComputer的,所以this表示“新(new)的物件”。在另一方面,我們通過 deep_thought進入the_question,所以當我們執行該方法時,this表示 “deep_thought所引用的物件”。this並不像其他的變數一樣從作用域鏈中讀取,而是在上下文的基礎上,在上下文中重置。

函式呼叫
假如沒有任何相關物件的奇幻東西,我們只是呼叫一個普通的、常見的函式,在這種情形下this表示的又是什麼呢?

<script type="text/javascript">
function test_this() {
return this;
}
var i_w
onder_what_this_is = test_this();
</script>

在這樣的場合,我們並不通過new來提供上下文,也不會以某種物件形式在背後偷偷提供上下文。在此, this預設下儘可能引用最全域性的東西:對於網頁來說,這就是 window物件。

事件處理函式
比普通函式的呼叫更復雜的狀況,先假設我們使用函式去處理的是一個onclick事件。當事件觸發我們的函式執行,此處的this表示的是什麼呢?不湊巧,這個問題不會有簡單的答案。

如果我們寫的是行內(inline)事件處理函式,this引用的是全域性window物件:

<script type="text/javascript">
function click_handler() {
alert(this); // 彈出 window 物件
}
</script>

<button id='thebutton' onclick='click_handler()'>Click me!</button>

但是,如果我們通過JavaScript來新增事件處理函式,this引用的是生成該事件的DOM元素。(注意:此處的事件處理非常簡潔和易於閱讀,但其他的就別有洞天了。請使用真正的addEvent函式取而代之):

<script type="text/javascript">
function click_handler() {
alert(this); // 彈出按鈕的DOM節點
}

function addhandler() {
document.getElementById('thebutton').onclick = click_handler;
}

window.onload = addhandler;
</script>

<button id='thebutton'>Click me!</button>

複雜情況
讓我們來短暫地執行一下這個最後的例子。我們需要詢問deep_thought一個問題,如果不是直接執行click_handler而是通過點選按鈕的話,那會發生什麼事情?解決此問題的程式碼貌似十分直接,我們可能會這樣做:

<script type="text/javascript">
function BigComputer(answer) {
this.the_answer = answer;
this.ask_question = function () {
alert(this.the_answer);
}
}

function addhandler() {
var deep_thought = new BigComputer(42),
the_button = document.getElementById('thebutton');

the_button.onclick = deep_thought.ask_question;
}

window.onload = addhandler;
</script>

很完美吧?想象一下,我們點選按鈕,deep_thought.ask_question被執行,我們也得到了“42”。但是為什麼瀏覽器卻給我們一個undefined? 我們錯在何處?

其實問題顯而易見:我們給ask_question傳遞一個引用,它作為一個事件處理函式來執行,與作為物件方法來執行的上下文並不一樣。簡而言之,ask_question中的 this關鍵字指向了產生事件的DOM元素,而不是在BigComputer的物件中。DOM元素並不存在一個the_answer屬性,所以我們得到的是 undefined而不是”42″. setTimeout也有類似的行為,它在延遲函式執行的同時跑到了一個全域性的上下文中去了。

這個問題會在程式的所有角落時不時突然冒出,如果不細緻地追蹤程式的每一個角落的話,還是一個非常難以排錯的問題,尤其在你的物件有跟DOM元素或者window物件同名屬性的時候。

使用.apply()和.call()掌控上下文
在點選按鈕的時候,我們真正需要的是能夠諮詢deep_thought一個問題,更進一步說,我們真正需要的是,在應答事件和setTimeout的呼叫時,能夠在自身的本原上下文中呼叫物件的方法。有兩個鮮為人知的JavaScript方法,apply和call,在我們執行函式呼叫時,可以曲線救國幫我們達到目的,允許我們手工覆蓋this的預設值。我們先來看call:

<script type="text/javascript">
var first_object = {
num: 42
};
var second_object = {
num: 24
};

function multiply(mult) {
return this.num * mult;
}

multiply.call(first_object, 5); // 返回 42 * 5
multiply.call(second_object, 5); // 返回 24 * 5
</script>

在這個例子中,我們首先定義了兩個物件,first_object和second_object,它們分別有自己的num屬性。然後定義了一個multiply函式,它只接受一個引數,並返回該引數與this所指物件的num屬性的乘積。如果我們呼叫函式自身,返回的答案極大可能是undefined,因為全域性window物件並沒有一個num屬性除非有明確的指定。我們需要一些途徑來告訴multiply裡面的this關鍵字應該引用什麼。而multiply的call方法正是我們所需要的。

call的第一個引數定義了在業已執行的函式內this的所指物件。其餘的引數則傳入業已執行的函式內,如同函式的自身呼叫一般。所以,當執行multiply.call(first_object, 5)時,multiply被呼叫,5傳入作為第一個引數,而this關鍵字被設定為first_object的引用。同樣,當執行multiply.call(second_object, 5)時,5傳入作為第一個引數,而this關鍵字被設定為second_object的引用。

apply以call一樣的方式工作,但可以讓你把引數包裹進一個陣列再傳遞給呼叫函式,在程式性生成函式呼叫時尤為有用。使用apply重現上一段程式碼,其實區別並不大:

<script type="text/javascript">

multiply.apply(first_object, [5]); // 返回 42 * 5
multiply.apply(second_object, [5]); // 返回 24 * 5
</script>

apply和call本身都非常有用,並值得貯藏於你的工具箱內,但對於事件處理函式所改變的上下文問題,也只是送佛到西天的中途而已,剩下的還是得我們來解決。在搭建處理函式時,我們自然而然地認為,只需簡單地通過使用call來改變this的含義即可:

function addhandler() {
var deep_thought = new BigComputer(42),
the_button = document.getElementById('thebutton');

the_button.onclick = deep_thought.ask_question.call(deep_thought);
}

程式碼之所以有問題的理由很簡單:call立即執行了函式(譯註:其實可以用一個匿名函式封裝,例如the_button.onclick = function(){deep_thought.ask_question.call(deep_thought);},但比起即將討論的bind來,依然不夠優雅)。我們給onclcik處理函式一個函式執行後的結果而非函式的引用。所以我們需要利用另一個JavaScript特色,以解決這個問題。

.bind()之美
我並不是 Prototype JavaScript framework的忠實粉絲,但我對它的總體程式碼質量印象深刻。具體而言,它為Function物件增加一個簡潔的補充,對我管理函式呼叫執行後的上下文產生了極大的正面影響:bind跟call一樣執行相同的常見任務,改變函式執行的上下文。不同之處在於bind返回的是函式引用可以備用,而不是call的立即執行而產生的最終結果。

如果需要簡化一下bind函式以抓住概念的重點,我們可以先把它插進前面討論的乘積例子中去,看它究竟是如何工作的。這是一個相當優雅的解決方案:

<script type="text/javascript">
var first_object = {
num: 42
};
var second_object = {
num: 24
};

function multiply(mult) {
return this.num * mult;
}

Function.prototype.bind = function(obj) {
var method = this,
temp = function() {
return method.apply(obj, arguments);
};

return temp;
}

var first_multiply = multiply.bind(first_object);
first_multiply(5); // 返回 42 * 5

var second_multiply = multiply.bind(second_object);
second_multiply(5); // 返回 24 * 5
</script>

首先,我們定義了first_object, second_object和multiply函式,一如既往。細心處理這些後,我們繼續為Function物件的prototype定義一個bind方法,這樣的話,我們程式裡的函式都有一個bind方法可用。當執行multiply.bind(first_object)時,JavaScript為bind方法建立一個執行上下文,把this置為multiply函式的引用,並把第一個引數obj置為first_object的引用。目前為止,一切皆順。

這個解決方案的真正天才之處在於method的建立,置為this的引用所指(即multiply函式自身)。當下一行的匿名函式被建立,method通過它的作用域鏈訪問,obj亦然(不要在此使用this, 因為新建立的函式執行後,this會被新的、區域性的上下文覆蓋)。這個this的別名讓apply執行multiply函式成為可能,而傳遞obj則確保上下文的正確。用電腦科學的話說,temp是一個閉包(closure),它可以保證,需要在first_object的上下文中執行multiply,bind呼叫的最終返回可以用在任何的上下文中。

這才是前面說到的事件處理函式和setTimeout情形所真正需要的。以下程式碼完全解決了這些問題,繫結deep_thought.ask_question方法到deep_thought的上下文中,因此能在任何事件觸發時都能正確執行:

function addhandler() {
var deep_thought = new BigComputer(42),
the_button = document.getElementById('thebutton');

the_button.onclick = deep_thought.ask_question.bind(deep_thought);
}

漂亮。

相關文章