關於 Javascript 的函數作用域、呼叫物件和閉包之間的關係很微妙,關於它們的文章已經有很多,但不知道為什麼很多新手都難以理解。我就嘗試用比較通俗的語言來表達我自己的理解吧。
作用域 Scope
Javascript 中的函數屬於詞法作用域,也就是說函數在它被定義時的作用域中運作而不是在被執行時的作用域內運作。這是犀牛書上的說法。但"定義時"和"執行(被調用)時"這兩個東西有些人搞不清楚。簡單來說,一個函數A在"定義時"就是 function A(){} 這個語句執行的時候就是定義這個函數的時候,而A被呼叫的時候是 A() 這個語句執行的時候。這兩個概念一定要分清楚。
那詞法作用域(以下稱為"作用域",除非特別指明)到底是什麼呢?它是個抽象的概念,說白了它就是一個"範圍",scope 在英文裡就是範圍的意思。一個函數的作用域是它被定義時它所處的"範圍",也就是它外層的"範圍",這個"範圍"包含了外層的變數屬性,這個"範圍"被設定成這個函數的一個內部狀態。一個全域函數被定義的時候,全域(這個函數的外層)的"範圍"就被設定成這個全域函數的一個內部狀態。一個巢狀函數被定義的時候,被巢狀函數(外層函數)的"範圍"就被設定成這個巢狀函數的一個內部狀態。這個"內部狀態"其實可以理解成作用域鏈,見下文。
照以上說法,一個函數的作用域是它被定義的時候所處的"範圍",那麼Javascript 裡的函數作用域是在函數被定義的時候就確定了,所以它是靜態的作用域,詞法作用域又稱為靜態作用域。
呼叫物件 Call Object
一個函數的呼叫物件是動態的,它是在這個函數被呼叫時才被實例化的。我們已經知道,當一個函數被定義的時候,已經確定了它的作用域鏈。當 Javascript 解譯器呼叫一個函數的時候,它會加入一個新的物件(呼叫物件)到這個作用域鏈的前面。這個呼叫對象的一個屬性被初始化成一個名為 arguments 的屬性,它引用了這個函數的 Arguments 對象,Arguments 物件是函數的實際參數。所有用 var 語句宣告的本地變數也被定義在這個呼叫物件裡。這時候,呼叫物件處在作用域鏈的頭部,本地變數、函數形式參數和 Arguments 物件全部都在這個函數的範圍裡了。當然,這個時候本地變數、函數形式參數和 Arguments 物件就覆蓋了作用域鏈裡同名的屬性。
作用域、作用域鍊與呼叫物件之間的關係
我的理解是,作用域是是抽象的,而呼叫物件是實例化的。
在函數被定義的時候,實際上也是它外層函數執行的時候,它確定的作用域鏈其實是它外層函數的呼叫物件鏈;當函數被呼叫時,它的作用域鏈是根據定義的時候確定的作用域鏈(它外層函數的呼叫物件鏈)加上一個實例化的呼叫物件。所以函數的作用域鏈其實是呼叫物件鏈。在一個函數被呼叫的時候,它的作用域鏈(或稱為呼叫物件鏈)實際上是它在被定義的時候所確定的作用域鏈的一個超集。
它們之間的關係可以表示成:作用域?作用域鏈?呼叫物件。
太繞口了,舉例說明吧:
function f(x) {
var g = function () { return x; }
return g;
}
var g1 = f(1);
alert(g1()) ; //輸出1
假設我們把全域看成類似以下這樣的大匿名函數:
(function() {
//這裡是全域範圍
})();
那麼例子就可以看成是:
(function() {
function f(x) {
var g = function () { return x; }
return g;
}
var g1 = f(1);
alert(g1()); //輸出1
})();
全域的大匿名函數被定義的時候,它沒有外層,所以它的作用域鍊是空的。
全域的大匿名函數直接被執行,全域的作用域鏈裡只有一個 '全域呼叫物件'。
函數 f 被定義,此時函數 f 的作用域鍊是它外層的作用域鏈,即 '全域呼叫物件'。
函數f(1) 被執行,它的作用域鍊是新的f(1) 呼叫物件加上函數f 被定義的時候的作用域鏈,即'f(1) 呼叫物件->全域調用對象'。
函數g (它要被回傳給g1,就命名為g1吧)在f(1) 中被定義,它的作用域鍊是它外層的函數f(1) 的作用域鏈,即' f(1) 呼叫物件->全域呼叫物件'。
函數 f(1) 傳回函數 g 的定義給 g1。
函數g1 被執行,它的作用域鍊是新的g(1) 呼叫物件加上外層f(1) 的作用域鏈,即'g1 呼叫物件->f(1)呼叫物件->全域調用對象'。
這樣看就很清楚了吧。
閉包 Closuer 閉包的一個簡單的說法是,當巢狀函數在被巢狀函數之外呼叫的時候,就形成了閉包。
之前的這個例子其實就是一個閉包。 g1 是在 f(1) 內部定義的,卻在 f(1) 回傳後才執行。可以看出,閉包的一個效果就是被巢狀函數 f 回傳後,它內部的資源不會被釋放。在外部呼叫 g 函數時,g 可以存取 f 的內部變數。根據這個特性,可以寫出很多優雅的程式碼。
例如要在一個頁面上作一個統一的計數器,如果用閉包的寫法,可以這麼寫:
複製程式碼
程式碼如下:
var counter = (function() {
var i = 0;
var fns = {"get": function() {return i;},
"inc": function() {return i;}};
return fns;
})();
//do something
counter.inc();
//do something else
counter.inc();
var c_value = counter.get(); //now c_value is 2
複製程式碼
程式碼如下:
for(var i=0,delay=1000; setTimeout(function() {
console.log('i:' i " delay:" delay);
}, delay);
}
這樣,印出來的值都是
i:5 delay:6000
i:5 delay:6000
i:5 delay:6000
複製程式碼
程式碼如下:
for(var i=0, delay=1000; i (function(a, _delay) {
setTimeout(function() {
console.log('i:' a " delay:" _delay);
}, _delay);
})(i, delay);
}
輸出:
i:0 delay:1000
i:1 delay:2000
i:2 delay:3000
i:3 delay: 4000 i:4 delay:5000
閉包還有一個很常用的地方,就是在綁定事件的回呼函數的時候。也是同樣的道理,綁定的函數句柄不能做參數,但可以透過閉包的形式把參數綁定進去。
總結
函數的詞法作用域和作用域鍊是不同的東西,詞法作用域是抽象概念,作用域鍊是實例化的呼叫物件鏈。
函數在被定義的時候,同時也是它外層的函數在被執行的時候。 函數在被定義的時候它的詞法作用域就已經確定了,但它仍然是抽象的概念,沒有也不能被實例化。 函數在被定義的時候還確定了一個東西,就是它外層函數的作用域鏈,這個是實例化的東西。 函數在被多次呼叫的時候,它的作用域鏈都是不同的。 閉包很強。犀牛書說得對,理解了這些東西,你就可以自稱是高階 Javascript 程式設計師了。因為利用好這些概念,可以玩 Javascript 的許多設計模式。