搞清楚Javascript的變數作用範圍

 Thu, 04 Oct 2007 17:05:33 +0800

變數作用範圍是一般程式語言初學就會碰到的基本觀念。理論上Javascript也是這樣,但是實際上恐怕有不少人並不知道自己不清楚Javascript的scope概念!

其實這些原本在ECMA-262就定義得很清楚,但是因為很基本所以反而容易被忽視,有時候變數運作範圍會跟我們想像的不一樣。大部分狀況下程式運作也不會出問題,但是有可能會讓你寫好的物件封裝不完整,或是不小心就改寫了整體變數,造成程式運作的問題。

先看一個簡單的例子:

1
2
3
4
5
6
7
8
9
<script>
function test () {
hey = function () {alert('hey, you!!');};
}
try{
test();
window.hey();
} catch (e) {alert(e);}
</script>

這應該在預期之外吧?我在test函數裡面忘了用var保留字就assign一個函數給hey變數。結果.....hey變數變成了window物件的函數????這是怎麼一回事呢?

關於變數作用範圍這一方面,ECMA-262定義在identifier、scope chain、execution context等主題裡面。搞清楚這幾個方面的主題,才算真正理解Javascript變數作用範圍的定義。

簡單地說,在Javascript中,凡不是屬於保留字及運算符號等的名稱,只要符合規則,就會把他當作一個identifier,然後依照scope chain規則來解析這個identifier的型別跟值。scope chain則是依照execution context建立的。那這個規則到底是怎樣的呢?

javascript/ECMAScript是定為成一種依附於某種應用環境的程式語言,不是獨立自主運作的。所以通常會包含三種物件類型,一個是javascript/ECMAScript內建物件(Object、Function、Array、String、Boolean、Number、Math、Date、RegExp、Error等,還會有一個Global物件,通常跟host環境有關),一個是host物件,是由host環境建立的,像是瀏覽器裡面的DOM相關物件等等。還有一個就是使用者程式裡面定義的物件。

execution contexts可以說是程式執行的單位。ECMA-262定義了三種execution contexts,分別是global context、eval context與function context。凡是不在任何函數內的程式都屬於global context;使用eval來執行的程式碼時,不在任何函數內的程式都屬於eval context;所有位於函數內的程式碼,都屬於function context。程式進入不同的execution context時,執行環境會根據execution context的類型為他建立一個scope chain。scope chain是一個在邏輯上類似堆疊的結構,程式會透過這個scope chain依序來解析identifier。

在瀏覽器的執行環境中global物件就是window,global的identifier就會變成window的子物件或是屬性、函數等等(其實就是一個identifier,assign給它什麼型別的物件他就是什麼)。

回頭來看上面的例子,發生了什麼事情呢?執行test()的時候,在第三行碰到hey這個identifier,在test()這個scope找不到他的定義,所以到scope chain的下一個,就是global scope來找,也沒找到。在沒找到的情況下,系統就把這個identifier定義在global scope,先把他的值設定成undefined,接著透過=符號assign一個函數給它。由於在瀏覽器函數中,global物件就是window,所以無意中為window物件加了一個hey函數了。

在提到另一個例子前,先看一下幾個ECMA-262對於這個程式語言的定位:「ECMAScript is an object-oriented programming language for performing computations and manipulating computational objects within a host environment.」(第4章 "Overview")利用這個語言的特性,可以做到封裝、繼承與多型等物件導向語言該具備的能力,不過這裡只會提到封裝。

其實封裝的原則很簡單,因為Javascript利用scope chain規則來解析變數,這個規則不會處理到子函數裡面的identifier,所以在函數裡面用var定義的變數,或是用function定義的函數,就會成為這個函數私有的。使用this.identifier定義的則會成為public,可以在函數外面access到。

接下來看另一個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script>
function test () {
var priv = "test private";
this.pub = "test public";
var ref = this;
function privShowPriv () {
alert(priv);
}
function privShowPub () {
alert(ref.pub);
}
this.pubShowPriv = function () {
alert(priv);
}
this.pubShowPub = function () {
alert(this.pub);
}
this.protectShowPub = function () {
privShowPub();
}
this.protectShowPriv = function () {
privShowPriv();
}
}
var a = new test();
a.pubShowPriv();
a.pubShowPub();
a.protectShowPriv();
a.protectShowPub();
</script>

在上面的例子裡,可以透過public的函數來存取public跟private的變數,或是利用public函數操作private函數來顯示private變數,但是無法在test函數物件之外使用他的private變數或函數。另外,在test()裡面用this定義的public變數,在test()裡面用function定義的private函數無法用this來存取,但是如果我們在test()裡面定義一個private變數然後把this assign給這個變數,在這些private函數中就可以透過這個變數來取得。(如第5行與第10行)

我們可以用不同方法為一個物件加入函數,但是這樣就無法存取這裡定義的私有變數:

1
2
3
4
5
6
7
8
9
10
11
<script>
function test () {
var priv = "test private";
}
//test.prototype.pubShowPriv = new function () {
test.prototype.pubShowPriv = function () {
alert(priv);
}
var a = new test();
a.pubShowPriv();
</script>

以上這個例子就會出錯。pubShowPriv函數無法存取priv變數。

再看另一個例子:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
function test () {
var priv = "test private";
function privShowPriv () {
alert(priv);
}
try {
window.setTimeout(privShowPriv,500);
} catch (e) {alert(e);}
}
test();
</script>

setTimeout是在global scope執行的,但是你可以直接assign "privShowPriv"函數給它執行。而且他還是可以存取到在test()函數中定義的priv變數。

換一種方式來做做看:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
function test () {
var priv = "test private";
var ref = this;
this.pubShowPriv = function () {
alert(priv);
}
try {
window.setTimeout(ref.pubShowPriv,500);
} catch (e) {alert(e);}
}
test();
</script>

因為setTimeout跟test()是在不同的execution context,所以透過this是沒有辦法把pubShowPriv函數傳給他,但是可以利用一個變數ref間接傳給setTimeout。

從以上幾個例子看起來,scope chain是根據identifier定義的位置來做解析的(究竟是直譯的script?)。定義在outer function裡面的inner function,可以存取outer function裡面定義的變數,但是透過prototype等方法在outer function之外assign一個function給它,這個function就沒有辦法存取定義在這個outer function裡面的變數。

看一個更極端的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
function test1 () {
this.inherit = function (obj) {
for (var i in obj) {
this[i] = obj[i];
}
} 
}
function test () {
var priv = "test private";
this.pubShowPriv = function () {
alert(priv);
}
}
var a = new test;
var b = new test1;
b.inherit(a);
b.pubShowPriv();
for (j in b) {
alert(j);
}
</script>

利用b的inherit方法把a的pubShowPriv拷貝給b,在執行b.pubShowPriv()時,仍然可以存取定義在a裡的priv變數。透過最後的for迴圈可以看出,inherit並無法拷貝priv變數給b,列舉出來的幾個identifier只有"inherit"以及"pubShowPriv"。

大致總結一下,其實private、public並不是ECMA-262定義好的東西,只是依照以上的方法,就可以做出資料封裝的效果。變數範圍是在程式寫好時他根據剖析出來的上下文決定的,而不是根據你動態為物件加入方法、屬性的方式決定的,所以動態加入的函數會無法存取定義在物件裡面的變數。另外,我在我的例子裡面用到closure時並沒有考慮到memory leak的問題,這方面在使用closure時要自己注意。