本章節首先說明如何使用變數,然後詳細說明它們的工作原理(環境、封閉等)。
在 JavaScript 中,您在使用變數之前透過 var
陳述式宣告變數:
var
foo
;
foo
=
3
;
// OK, has been declared
bar
=
5
;
// not OK, an undeclared variable
您也可以結合宣告和指定,立即初始化變數
var
foo
=
3
;
> var x; > x undefined
您可以從兩個角度檢查程式的工作原理:
您在程式碼中檢查程式,而不會執行它。給定以下程式碼,我們可以做出靜態斷言,函式 g
嵌套在函式 f
中
function
f
()
{
function
g
()
{
}
}
形容詞 詞彙 與 靜態 同義,因為兩者都與程式的 詞彙(字詞、來源)有關。
您檢查執行程式時發生的情況(「在執行階段」)。給定以下程式碼
function
g
()
{
}
function
f
()
{
g
();
}
當我們呼叫 f()
時,它會呼叫 g()
。在執行階段,g
被 f
呼叫表示動態關係。
變數的範圍是它可以存取的位置。例如
function
foo
()
{
var
x
;
}
在此,x
的 直接範圍 是函式 foo()
。
如果範圍嵌套在變數的直接範圍中,則變數在所有這些範圍中都可以存取
function
foo
(
arg
)
{
function
bar
()
{
console
.
log
(
'arg: '
+
arg
);
}
bar
();
}
console
.
log
(
foo
(
'hello'
));
// arg: hello
arg
的直接範圍是 foo()
,但它也可以在巢狀範圍 bar()
中存取。關於巢狀,foo()
是 外部範圍,而 bar()
是 內部範圍。
如果範圍宣告一個與周圍範圍中變數同名的變數,則在內部範圍和所有嵌套在其中的範圍中,將會封鎖對外部變數的存取。對內部變數的變更不會影響外部變數,而外部變數在離開內部範圍後又會再次可以存取:
var
x
=
"global"
;
function
f
()
{
var
x
=
"local"
;
console
.
log
(
x
);
// local
}
f
();
console
.
log
(
x
);
// global
在函式 f()
內,全域的 x
被區域的 x
遮蔽。
大多數主流 語言都是 區塊範圍的:變數「存活於」最內層的周圍程式碼區塊中。以下是 Java 的範例:
public
static
void
main
(
String
[]
args
)
{
{
// block starts
int
foo
=
4
;
}
// block ends
System
.
out
.
println
(
foo
);
// Error: cannot find symbol
}
在前面的程式碼中,變數 foo
只能在直接圍繞它的區塊中存取。如果我們嘗試在區塊結束後存取它,我們會得到一個編譯錯誤。
相反地,JavaScript 的變數是 函式範圍的:只有函式會引入新的範圍;在範圍方面,區塊會被忽略。例如:
function
main
()
{
{
// block starts
var
foo
=
4
;
}
// block ends
console
.
log
(
foo
);
// 4
}
換句話說,foo
可以存取 main()
中的所有內容,而不仅仅是區塊內部。
JavaScript 會 提升所有變數宣告,將它們移到直接範圍的開頭。這可以清楚說明如果在宣告變數之前存取它會發生什麼事:
function
f
()
{
console
.
log
(
bar
);
// undefined
var
bar
=
'abc'
;
console
.
log
(
bar
);
// abc
}
我們可以看到變數 bar
已經存在於 f()
的第一行,但它還沒有值;也就是說,宣告已經提升,但賦值還沒有。JavaScript 會執行 f()
,就像它的程式碼是
function
f
()
{
var
bar
;
console
.
log
(
bar
);
// undefined
bar
=
'abc'
;
console
.
log
(
bar
);
// abc
}
如果您宣告一個已經宣告過的變數,什麼事都不會發生(變數的值不變)
> var x = 123; > var x; > x 123
每個函式宣告也會提升,但方式略有不同。提升的是完整的函式,不只是儲存函式的變數的建立(請參閱 提升)。
有些 JavaScript 風格指南建議您只在函式的開頭放置變數宣告,以避免被提升所欺騙。如果您的函式相對較小(無論如何都應該是這樣),那麼您可以放寬這個規則,並在變數使用的地方附近宣告變數(例如,在 for
迴圈內)。這樣可以更好地封裝程式碼片段。顯然,您應該知道這種封裝只是概念性的,因為函式範圍提升仍然會發生。
您通常會引入新的範圍來限制變數的生命週期。 您可能想要這樣做的範例之一是 if
陳述式的「then」部分:它僅在條件成立時執行;如果它只使用輔助變數,我們不希望它們「洩漏」到周圍的範圍:
function
f
()
{
if
(
condition
)
{
var
tmp
=
...;
...
}
// tmp still exists here
// => not what we want
}
如果您想為 then
區塊引入新的範圍,您可以定義一個函式並立即呼叫它。這是一個解決方法,一個 區塊範圍模擬:
function
f
()
{
if
(
condition
)
{
(
function
()
{
// open block
var
tmp
=
...;
...
}());
// close block
}
}
這是 JavaScript 中常見的模式。Ben Alman 建議將其稱為 立即呼叫的函式表達式 (IIFE,發音為「iffy」)。一般來說,IIFE 如下所示
(
function
()
{
// open IIFE
// inside IIFE
}());
// close IIFE
以下是關於 IIFE 的一些注意事項
function
開頭,解析器會預期它是一個函式宣告(請參閱 Expressions Versus Statements)。但函式宣告無法立即呼叫。因此,我們透過以開啟括號開始陳述句,告訴解析器關鍵字 function
是函式運算式的開頭。在括號內,只能有運算式。 如果您忘記在兩個 IIFE 之間加上分號,您的程式碼將無法再運作:
(
function
()
{
...
}())
// no semicolon
(
function
()
{
...
}());
前述程式碼會被解釋為函式呼叫,第一個 IIFE(包括括號)是要呼叫的函式,而第二個 IIFE 是參數。
IIFE 會產生成本(認知和效能方面),因此在 if
陳述句中使用它幾乎沒有意義。前述範例是基於教學目的而選擇的。
您也可以透過前置運算子來強制執行運算式內容。 例如,您可以透過邏輯 Not 運算子來執行:
!
function
()
{
// open IIFE
// inside IIFE
}();
// close IIFE
或透過 void
運算子(請參閱 The void Operator)
void
function
()
{
// open IIFE
// inside IIFE
}();
// close IIFE
使用前置運算子的優點是,忘記終止分號不會造成問題。
請注意,如果您已在運算式內容中,則不需要強制執行 IIFE 的運算式內容。這樣您不需要括號或前置運算子。例如:
var
File
=
function
()
{
// open IIFE
var
UNTITLED
=
'Untitled'
;
function
File
(
name
)
{
this
.
name
=
name
||
UNTITLED
;
}
return
File
;
}();
// close IIFE
在前述範例中,有兩個不同的變數具有名稱 File
。一方面,有僅可在 IIFE 內直接存取的函式。另一方面,有在第一行中宣告的變數。它會指派在 IIFE 中傳回的值。
你可以使用參數來定義 IIFE 內部的變數:
var
x
=
23
;
(
function
(
twice
)
{
console
.
log
(
twice
);
}(
x
*
2
));
這類似於
var
x
=
23
;
(
function
()
{
var
twice
=
x
*
2
;
console
.
log
(
twice
);
}());
IIFE 讓你能夠將私有資料附加到函式。然後你就不必宣告全域變數,並能緊密封裝函式及其狀態。你避免了汙染全域命名空間:
var
setValue
=
function
()
{
var
prevValue
;
return
function
(
value
)
{
// define setValue
if
(
value
!==
prevValue
)
{
console
.
log
(
'Changed: '
+
value
);
prevValue
=
value
;
}
};
}();
IIFE 的其他應用在本書的其他地方有提到
包含所有程式碼的範圍稱為全域範圍或程式碼範圍。這是你在進入腳本時所在的範圍(無論是網頁中的<script>
標籤,還是.js檔案)。在全域範圍內,你可以透過定義函式來建立巢狀範圍。在這樣的函式內,你可以再次巢狀範圍。每個範圍都可以存取自己的變數,以及周圍範圍內的變數。由於全域範圍包含所有其他範圍,因此其變數可以在任何地方存取:
// here we are in global scope
var
globalVariable
=
'xyz'
;
function
f
()
{
var
localVariable
=
true
;
function
g
()
{
var
anotherLocalVariable
=
123
;
// All variables of surround scopes are accessible
localVariable
=
false
;
globalVariable
=
'abc'
;
}
}
// here we are again in global scope
全域變數有兩個缺點。首先,依賴全域變數的軟體會受到副作用的影響;它們的健壯性較差,行為較難預測,而且較難重複使用。
其次,網頁上的所有 JavaScript 共享相同的全域變數:您的程式碼、內建函式、分析程式碼、社群媒體按鈕等等。這表示名稱衝突可能會成為問題。因此,最好盡可能隱藏全域範圍內的許多變數。例如,請勿執行此操作
<!-- Don’t do this -->
<script>
// Global scope
var
tmp
=
generateData
();
processData
(
tmp
);
persistData
(
tmp
);
</script>
變數 tmp
會變成全域變數,因為其宣告是在全域範圍內執行的。但它只會在區域內使用。因此,我們可以使用 IIFE(請參閱 透過 IIFE 介紹新的範圍)將其隱藏在巢狀範圍內
<script>
(
function
()
{
// open IIFE
// Local scope
var
tmp
=
generateData
();
processData
(
tmp
);
persistData
(
tmp
);
}());
// close IIFE
</script>
值得慶幸的是,模組系統(請參閱 模組系統)幾乎消除了全域變數的問題,因為模組不會透過全域範圍進行介面,而且每個模組都有自己的範圍來處理模組全域變數。
ECMAScript 規範使用內部資料結構 環境 來儲存變數(請參閱 環境:管理變數)。此語言具有相當不尋常的功能,可透過物件存取全域變數的環境,也就是所謂的 全域物件。 全域物件可 used 用於建立、讀取和變更全域變數。在全域範圍內,this
指向它:
> var foo = 'hello'; > this.foo // read global variable 'hello' > this.bar = 'world'; // create global variable > bar 'world'
請注意,全域物件有原型。如果您要列出其所有(自有和繼承的)屬性,您需要一個函式,例如 列出所有屬性金鑰 中的 getAllPropertyNames()
> getAllPropertyNames(window).sort().slice(0, 5) [ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ]
JavaScript 建立者 Brendan Eich 認為全域物件是他 「最大的遺憾」 之一。它會對效能產生負面影響,讓變數範圍的實作變得更複雜,而且會導致模組化程式碼減少。
瀏覽器和 Node.js 有全域變數來參照全域物件。很不幸的是,它們不同:
window
,它 已標準化為文件物件模型 (DOM) 的一部分,而不是 ECMAScript 5 的一部分。每個框架或視窗只有一個全域物件。global
,這是 Node.js 特有的變數。每個模組都有自己的範圍,其中 this
指向具有該範圍變數的物件。因此,this
和 global
在模組內部是不同的。在兩個平台上,this
指的是全域物件,但僅限於在全域範圍內。這在 Node.js 中幾乎從未發生。如果你想要跨平台存取全域物件,你可以使用以下模式
(
function
(
glob
)
{
// glob points to global object
}(
typeof
window
!==
'undefined'
?
window
:
global
));
從現在開始,我使用 window
來指稱全域物件,但在跨平台程式碼中,你應該使用前述模式和 glob
。
本節說明透過 window
存取全域變數的使用案例。但一般規則是:盡可能避免這麼做。
前綴 window
是個視覺線索,表示程式碼指涉的是全域變數,而不是區域變數:
var
foo
=
123
;
(
function
()
{
console
.
log
(
window
.
foo
);
// 123
}());
然而,這會讓你的程式碼變得脆弱。一旦你將 foo
從全域範圍移到另一個周圍範圍,它就會停止運作
(
function
()
{
var
foo
=
123
;
console
.
log
(
window
.
foo
);
// undefined
}());
因此,最好將 foo
視為變數,而不是 window
的屬性。如果你想要明確表示 foo
是全域或類似全域的變數,你可以新增名稱前綴,例如 g_
var
g_foo
=
123
;
(
function
()
{
console
.
log
(
g_foo
);
}());
我比較不喜歡透過 window
來指涉內建全域變數。它們是眾所周知的名稱,因此你從表示它們是全域的指標中獲得的幫助很小。而且加上前綴的 window
會增加雜訊:
window
.
isNaN
(...)
// no
isNaN
(...)
// yes
當你使用 JSLint 和 JSHint 等樣式檢查工具時,使用 window
表示你指涉未在目前檔案中宣告的全域變數時,不會收到錯誤訊息。然而,這兩個工具都提供方法讓你可以告知它們這些變數並防止此類錯誤(在它們的文件中搜尋「全域變數」)。
這不是一個常見的使用案例,但特別是 shim 和 polyfill(請參閱 Shims Versus Polyfills)需要檢查全域變數 someVariable
是否存在。在這種情況下,window
有幫助:
if
(
window
.
someVariable
)
{
...
}
這是執行此檢查的安全方法。如果 someVariable
尚未宣告,下列陳述式會擲回例外
// Don’t do this
if
(
someVariable
)
{
...
}
你可以透過 window
檢查的另外兩種方法;它們大致相當,但更明確一點
if
(
window
.
someVariable
!==
undefined
)
{
...
}
if
(
'someVariable'
in
window
)
{
...
}
檢查變數是否存在(且有值)的常見方式是透過 typeof
(請參閱 typeof:分類基本型別)
if
(
typeof
someVariable
!==
'undefined'
)
{
...
}
window
讓您可以在 全域範圍新增物件(即使您在巢狀範圍中),而且讓您有條件地執行此動作:
if
(
!
window
.
someApiFunction
)
{
window
.
someApiFunction
=
...;
}
通常最好在全域範圍中透過 var
新增物件,但 window
提供一種乾淨的方式,讓您可以有條件地新增物件。
當程式執行進入變數的範圍時,變數就會產生。然後,它們需要儲存空間。在 JavaScript 中,提供該儲存空間的資料結構稱為 環境。它會將變數名稱對應到值。它的結構與 JavaScript 物件非常類似。環境有時會在您離開其範圍後繼續存在。因此,它們會儲存在堆疊中,而不是佇列中。
變數有兩種傳遞方式。如果您願意,可以將它們視為兩個面向
每次 呼叫函式時,函式都需要為其參數和變數建立新的儲存空間。函式執行完畢後,通常可以回收該儲存空間。舉例來說,以下是如何實作階乘函式。它會遞迴呼叫函式本身好幾次,每次都需要為 n
建立新的儲存空間:
function
fac
(
n
)
{
if
(
n
<=
1
)
{
return
1
;
}
return
n
*
fac
(
n
-
1
);
}
不論函式呼叫的次數為何,它總是需要存取它自己的(新鮮的)區域變數和周圍範圍的變數。例如,以下函式 doNTimes
,在其內部有一個輔助函式 doNTimesRec
。當 doNTimesRec
多次呼叫它自己時,每次都會建立一個新的環境。然而,在那些呼叫期間,doNTimesRec
也會持續連接到 doNTimes
的單一環境(類似於所有函式共用一個單一全域環境)。doNTimesRec
需要該連線才能在第 (1) 行存取 action
:
function
doNTimes
(
n
,
action
)
{
function
doNTimesRec
(
x
)
{
if
(
x
>=
1
)
{
action
();
// (1)
doNTimesRec
(
x
-
1
);
}
}
doNTimesRec
(
n
);
}
這兩個面向的處理方式如下
為了解決識別碼,會從活動環境開始,遍歷完整的環境鏈。
我們來看一個範例
function
myFunction
(
myParam
)
{
var
myVar
=
123
;
return
myFloat
;
}
var
myFloat
=
1.3
;
// Step 1
myFunction
(
'abc'
);
// Step 2
圖 16-1 說明執行前述程式碼時會發生什麼事
myFunction
和 myFloat
已儲存在全域環境 (#0) 中。請注意 myFunction
參照的 function
物件會透過內部屬性 [[Scope]]
指向其範圍 (全域範圍)。
myFunction('abc')
,會建立一個新的環境 (#1) 來存放參數和區域變數。它會透過 outer
(已從 myFunction.[[Scope]]
初始化) 參照其外部環境。多虧了外部環境,myFunction
可以存取 myFloat
。
如果函式離開建立它的範圍,它會保持與該範圍 (和周圍範圍) 的變數連線。例如:
function
createInc
(
startValue
)
{
return
function
(
step
)
{
startValue
+=
step
;
return
startValue
;
};
}
createInc()
傳回的函式不會失去與 startValue
的連線,這個變數會提供函式一個在函式呼叫間持續存在的狀態
> var inc = createInc(5); > inc(1) 6 > inc(2) 8
封閉 是函式加上與建立函式的範圍的連線。這個名稱源自封閉會「封閉」函式的自由變數。如果變數未在函式中宣告,就是自由變數,也就是說它「來自外部」。
這是一個進階區段,會深入探討封閉的運作方式。您應該熟悉環境 (檢閱 環境:管理變數)。
封閉是環境在執行離開其範圍後仍存在的範例。為了說明封閉的運作方式,讓我們檢視與 createInc()
的前一次互動,並將其分成四個步驟 (在每個步驟中,目前活動的執行內容及其環境已標示出來;如果函式處於活動狀態,也會標示出來)
這個步驟發生在互動之前,以及 createInc
的函式宣告評估之後。createInc
的項目已新增至全域環境 (#0) 並指向函式物件。
這個步驟發生在執行函式呼叫 createInc(5)
期間。createInc
的新環境 (#1) 已建立並推入堆疊中。其外部環境是全域環境 (與 createInc.[[Scope]]
相同)。環境會存放參數 startValue
。
此步驟發生在指派給 inc
之後。在我們從 createInc
回傳後,指向其環境的執行內容已從堆疊中移除,但環境仍存在於堆積區中,因為 inc.[[Scope]]
參照它。 inc
是閉包(函數加上誕生環境)。
此步驟發生在執行 inc(1)
期間。已建立新的環境 (#1),並已將指向它的執行內容推入堆疊中。它的外部環境是 inc
的 [[Scope]]
。外部環境讓 inc
存取 startValue
。
此步驟發生在執行 inc(1)
之後。沒有參照(執行內容、outer
欄位或 [[Scope]]
)再指向 inc
的環境。因此,它不再需要,可以從堆積區中移除。
有時,您建立的函數行為會受到目前範圍中變數的影響。在 JavaScript 中,這可能會造成問題,因為每個函數都應使用函數建立時變數具有的值。然而,由於函數是閉包,函數將永遠使用變數的 目前 值。在 for
迴圈中,這可能會妨礙正常運作。範例將讓事情更清楚:
function
f
()
{
var
result
=
[];
for
(
var
i
=
0
;
i
<
3
;
i
++
)
{
var
func
=
function
()
{
return
i
;
};
result
.
push
(
func
);
}
return
result
;
}
console
.
log
(
f
()[
1
]());
// 3
f
回傳包含三個函數的陣列。所有這些函數仍可存取 f
的環境,因此可存取 i
。事實上,它們共用相同的環境。唉,在迴圈結束後,i
在該環境中的值為 3。因此,所有函數都回傳 3
。
這不是我們想要的。若要修正問題,我們需要在建立使用 i
的函數之前,建立索引 i
的快照。換句話說,我們希望將每個函數封裝在函數建立時 i
所具有的值中。因此,我們採取下列步驟
只有函式會建立環境,所以我們使用 IIFE(請參閱 透過 IIFE 介紹新的範圍)來達成步驟 1
function
f
()
{
var
result
=
[];
for
(
var
i
=
0
;
i
<
3
;
i
++
)
{
(
function
()
{
// step 1: IIFE
var
pos
=
i
;
// step 2: copy
var
func
=
function
()
{
return
pos
;
};
result
.
push
(
func
);
}());
}
return
result
;
}
console
.
log
(
f
()[
1
]());
// 1