如果你愛上了JavaScript這門詭異的語言,那我相信你一定在與其戀愛期間飽受了其變數作用域所引發的一系列問題的不少摧殘。對於任何一門程式語言,變數作用域都是一個關切的話題。正如David Herman在《Effective JavaScript》中的形象比喻,“Scope is like oxygen to a programmer”。當你“呼吸順暢”的時候,你並不會意識到變數作用域的重要性;然而當你“呼吸受阻”的時候,你便會體會到它的輕重高低。
全域性作用域
絕大多數程式語言都有全域性作用域的概念。全域性作用域是指常量、變數、函式等物件的作用範圍在整個應用程式中都是可見的。對於不同的程式語言,全域性作用域承擔著不同的角色,也因此遭受了不少的罵名。但對於JavaScript,我並不認為它一無是處。我們要做的便是理解它並正確地使用它。
考慮下這樣一個場景。Bill和Peter在同一家公司工作,他們的薪水由兩部分組成:a和b。以下是表示他們薪水組成的資料結構。
1 |
var emps = [{name:"Bill", parts:[{name:"a", salary:3000}, {name:"b", salary:2000}]}, {name:"Peter", parts:[{name:"a", salary:2500}, {name:"b", salary:2000}]}]; |
現在,我們希望能計算出Bill和Peter的平均薪水。以下是一段可能的程式片段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var i, n, sum; function salary(emp) { sum = 0;
for (i = 0, n = emp.parts.length; i < n; i++) { sum += emp.parts[i].salary; } return sum; } function averageSalary(emps) { sum = 0;
for (i = 0, n = emps.length; i < n; i++) { sum += salary(emps[i]); } return sum / n; } averageSalary(emps); |
輸出的結果並不是你口算的4750,而是2500。這是因為變數i、n和sum都是全域性變數,在執行salary(emps0)之後i的值變為了2,再回到averageSalary函式的迴圈體中時emps陣列已然越界,最終sum的值只計算了emps陣列中的第一個元素。
如果這樣的全域性作用域問題並不會困擾你,那下面的問題似乎應當引起你的一些警覺。因為與此相比,它有點意想不到。
1 2 3 4 5 |
function swap(a, i, j) { temp = a[i]; // global a[i] = a[j]; a[j] = temp; } |
問題並不是出在交換陣列元素上,而是我們無意間建立了一個全域性的變數temp。這要完全歸功於JavaScript的語言規範——JavaScript會將未使用var宣告的變數視為全域性變數。慶幸的是,我們可以藉助於類似Lint這樣的程式碼檢測工具幫我們儘早地發現這類問題。
雖然全域性變數有很多問題,然而它在支撐JavaScript模組之間資料共享、協同合作方面確實承擔了重要的角色。此外,程式設計師在某些不支援ECMAScript 5的環境中利用其特性檢查的功能來填補一些ES5特有的特性確實受益良多。
1 2 3 4 5 6 |
if (!this.JSON) { this.JSON = { parse: ..., stringify: ... }; } |
詞法作用域和動態作用域
在程式設計語言中,變數可分為自由變數與約束變數兩種。簡單來說,區域性變數和引數都被認為是約束變數;而不是約束變數的則是自由變數。 在馮·諾依曼計算機體系結構的記憶體中,變數的屬性可以視為一個六元組:(名字,地址,值,型別,生命期,作用域)。地址屬性具有明顯的馮·諾依曼體系結構的色彩,代表變數所關聯的儲存器地址。型別規定了變數的取值範圍和可能的操作。生命期表示變數與某個儲存區地址繫結的過程。根據生命期的不同,變數可以被分為四類:靜態、棧動態、顯式堆動態和隱式堆動態。作用域表徵變數在語句中的可見範圍,分為詞法作用域和動態作用域兩種。
在詞法作用域的環境中,變數的作用域與其在程式碼中所處的位置有關。由於程式碼可以靜態決定(執行前就可以決定),所以變數的作用域也可以被靜態決定,因此也將該作用域稱為靜態作用域。在動態作用域的環境中,變數的作用域與程式碼的執行順序有關。下面這段程式碼的輸出會是什麼?
1 2 3 4 5 6 7 8 9 10 11 |
x=1 function g () { echo $x ; x=2 ; } function f () { local x=3 ; g ; } f echo $x |
如果你的回答是1, 2或3, 1都沒有錯,因為這取決於該段程式碼所處的環境。如果處於詞法作用域中,答案便是1, 2;如果處於動態作用域中,答案便是3, 1。
詞法作用域允許程式設計師根據簡單的名稱替換就能推匯出物件引用,例如常量、引數、函式等。這使得程式設計師在編寫模組化的程式碼是多麼的得心應手。同時,這可能也是動態作用域令人感覺到晦澀的原因之一。詞法作用域最早可以追溯到ALGOL語言。儘管最早的Lisp直譯器和早期的Lisp變種都採用動態作用域,但隨後的動態作用域語言都支援了詞法作用域。Common Lisp和Perl的語言演化就是最好的證明。JavaScript和C都是詞法作用域語言。不過值得一提的是,不像JavaScript,深受ALGOL語言影響的C語言並不支援巢狀函式。這對後來的C族語言影響深遠。除了晦澀難懂之外,現代程式設計語言很少支援動態作用域的原因是動態作用域使得引用透明的所有好處蕩然無存。
臭名昭著的with語句
如果你還在使用類似下面的程式碼為with語句找藉口,那這正好是放棄它的真正原因。
1 2 3 4 5 6 7 8 |
function status(info) {
var widget = new Widget(); with (widget) { setFontSize(13);
setText("Status: " + info); show(); } } |
JavaScript會將with語句中的物件插入到詞法作用域的連結串列頭。這將使得status函式非常脆弱。例如,
1 2 3 |
status("connecting"); Widget.prototype.info = "[[widget info]]"; status("connected"); |
第二次status函式呼叫並不會得到預期的結果“Status:connected”而是“Status:widget info”。這是因為在第二次status函式呼叫之前,我們修改了widget的原型物件(增加了一個info屬性)。這將導致status函式的引數info會被處於詞法作用域連結串列頭的widget物件的原型物件中的info屬性所遮蔽。除此之外,with語句還會導致效能問題。這與在採用鏈地址法解決雜湊衝突的雜湊表中查詢關鍵字是異曲同工的。下面是修正的程式碼。
1 2 3 4 5 6 |
function status(info) {
var w = new Widget();
w.setFontSize(13);
w.setText("Status: " + info); w.show(); } |
變數宣告提升(hoisting)
JavaScript支援詞法作用域,但並不支援塊級作用域,即變數定義的作用域並不是離其最近的封閉語句或程式碼塊,而是包含它們的函式。下面的程式碼片段詮釋了這一特性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var emps = [{name:"Bill", salary: 5000}, {name:"Peter", salary: 3000}]; var ben = {name:"ben", salary: 6000}; function isHighestSalary(emp, others) {
var highest = 0;
for (var i = 0, n = others.length; i < n; i++) { var emp = others[i];
if (emp.salary > highest) { highest = emp.salary; } } return emp.salary > highest; } isHighestSalary(ben, emps); |
該程式碼段在for迴圈體內宣告瞭一個區域性變數emp。但是由於JavaScript中的變數是函式級作用域,而不是塊級作用域,所以在內部宣告的emp變數簡單地重宣告瞭一個已經在作用域內的變數(即引數emp)。該迴圈的每次迭代都會重寫這一變數。因此,return語句將emp視為others的最後一個元素,而不是此函式最初的emp引數。
可以將JavaScript的變數宣告行為看作由兩部分組成,即宣告和賦值。JavaScript隱式地提升(hoists)宣告部分到封閉函式的頂部,而將賦值留在原地。
閉包
可能有這樣一個需求,程式需要計算一個數的平方。你可能定義下面這樣一個函式。
1 2 3 |
function square(num) { return Math.pow(num, 2); } |
程式又需要計算一個數的立方。你可能又會定義下面這樣一個函式。
1 2 3 |
function cube(num) { return Math.pow(num, 3); } |
當你還在考慮是否為計算一個數的四次方建立一個函式的時候,可能有人在草稿紙上寫了這樣的程式碼。
1 2 3 4 5 |
function pow(power) { return function(num) { return Math.pow(num, power); }; } |
是的,這就是閉包。函式是一等公平,可以作為一個函式的返回物件。你可以像下面的程式碼一樣計算一個數的平方和立方。
1 2 3 4 |
var square = pow(2); var cube = pow(3); console.info(square(3)); console.info(cube(3)); |
掌握JavaScript的閉包,除了理解這樣一個事實(即使外部函式已經返回,當前函式仍然可以引用在外部函式所定義的變數)外,還需要理解閉包儲存的是外部變數的引用。我們來看這樣一個例子。
1 2 3 4 5 6 7 8 9 10 11 12 |
function doubleArray(a) { var result = []; for (var i = 0, n = a.length; i < n; i++) { (function(j) { result[i] = function() { return a[j] * 2; }; })(i); } return result; } doubleArray([1, 2, 3, 4, 5])[0](); |
程式期望輸出的結果是2,即給定陣列第一個元素的2倍。但結果並不是這樣。因為result陣列中儲存的所有閉包引用的都是同一個引用i。很容易想到的一個解決方法便是使用立即呼叫的函式表示式來提供類似塊作用域的功能。
1 2 3 4 5 6 7 |
function doubleArray(a) { var result = []; for (var i = 0, n = a.length; i < n; i++) { (function(j) { result[i] = function() { return a[j] * 2; }; })(i); } return result; } |
ES6塊作用域
在年底即將釋出的ES6標準中將會釋出一個新的關鍵字let。它在語法上與var相似,但不同的是,它將在當前塊中定義變數。
1 2 3 4 5 6 7 8 |
function log(msg) { ... } function f(x) { if (...) { let { log, sin, cos } = Math; ... log(x) ... } log("done computing f()"); } |
上面閉包引用外部變數問題,也可以通過它解決。
1 2 3 4 5 6 |
for (i = 0; i < n; i++) { let x = a[i]; element.onclick = function() { ... x ... }; } |