JS 基礎之全域性變數,區域性變數
原文連結:https://github.com/TooBug/javascript.patterns/blob/master/chapter2.markdown
第二章 概要
本章將概要介紹一些編寫高質量JavaScript的最佳實踐、模式和習慣,比如避免全域性變數、使用單var
宣告、預快取迴圈中的length
、遵守編碼約定等等。本章還包括一些程式設計習慣,這些習慣跟具體的程式碼關係不大,而是更多關注程式碼建立的總體過程,包括撰寫API文件、同事評審以及使用JSLint。這些習慣和最佳實踐可以幫助你寫出更好更易讀和可維護性更好的程式碼,當幾個月或數年後你再重讀你的程式碼時,就會深有體會了。
編寫可維護的程式碼
修復軟體bug成本很高,而且隨著時間的推移,修復這些bug的成本會越來越高,尤其以出現在已經打包釋出的軟體中的bug為最甚。發現bug時立刻解決掉是最好的,但前提是你對你的程式碼依然很熟悉,否則當你轉身投入到另外一個專案的開發中後,已經根本不記得當初的程式碼的模樣了。當過了一段時間後你再去閱讀當初的程式碼時你需要更多的時間:
- 重新學習並理解面臨的問題
- 理解用於問題的程式碼
在大專案或者大公司的軟體開發中還有另一個問題,就是解決這個bug的人和製造這個bug的人往往不是同一個人(而發現bug的往往又是另外一個人)。因此不管是隔了很長時間重讀自己的程式碼還是閱讀團隊內其他人的程式碼,減少理解程式碼所需的時間成本都是非常重要的。這對於公司的利益底線和工程師的幸福指數同樣重要,因為每個人都寧願去開發新的專案而不願花很多時間和精力去維護舊程式碼。
軟體開發中的另一個普遍現象是,在讀程式碼上花的時間要遠遠超過寫程式碼的時間。當你專注於某個問題的時候,你往往會坐下來用一下午的時間寫出大量的程式碼。在當時的場景下,這些程式碼是可以正常執行的,但當應用趨於成熟,會有很多因素促使你重讀程式碼、改進程式碼或對程式碼做微調。比如:
- 發現了bug
- 需要給應用新增新需求
- 需要將應用遷移到新的平臺中執行(比如當市場中出現了新的瀏覽器時)
- 程式碼重構
- 由於架構更改或者更換語言導致程式碼重寫
這些不確定因素帶來的後果是,少數人花幾小時寫的程式碼需要很多人花幾個星期去閱讀它。因此,建立可維護的程式碼對於一個成功的應用來說至關重要。
可維護的程式碼意味著程式碼是:
- 可讀的
- 風格一致的
- 可預測的
- 看起來像是同一個人寫的
- 有文件的
本章接下來的部分會對這幾點深入講解。
減少全域性變數
JavaScript使用函式來管理作用域,在一個函式內定義的變數稱作“本地變數”,本地變數在函式外部是不能被訪問的。與之相對,“全域性變數”是不在任何函式體內部宣告的變數,或者是直接使用而未宣告的變數。
每一個JavaScript執行環境都有一個“全域性物件”,不在任何函式體內使用this就可以獲得對這個全域性物件的引用。你所建立的每一個全域性變數都是這個全域性物件的屬性。為了方便起見,瀏覽器會額外提供一個全域性物件的屬性window
,(一般)指向全域性物件本身。下面的示例程式碼展示瞭如何在瀏覽器中建立或訪問全域性變數:
myglobal = "hello"; // 反模式
console.log(myglobal); // "hello"
console.log(window.myglobal); // "hello"
console.log(window["myglobal"]); // "hello"
console.log(this.myglobal); // "hello"
全域性變數的問題
全域性變數的問題是,它們在整個JavaScript應用或者是整個web頁面中是始終被所有程式碼共享的。它們存在於同一個名稱空間中,因此命名衝突的情況會時有發生,畢竟在應用程式的不同模組中,經常會出於某種目的定義相同的全域性變數。
同樣,在網頁中嵌入不是頁面開發者編寫的程式碼是很常見的,比如:
- 網頁中使用了第三方的JavaScript庫
- 網頁中使用了廣告程式碼
- 網頁中使用了用以分析流量和點選率的第三方統計程式碼
- 網頁中使用了很多元件、掛件和按鈕等等
假設某一段第三方提供的指令碼定義了一個全域性變數result。隨後你在自己寫的某個函式中也定義了一個全域性變數result。這時,第二個變數就會覆蓋第一個,會導致第三方指令碼工作不正常。
因此,為了讓你的指令碼和這個頁面中的其他指令碼和諧相處,要儘量少使用全域性變數,這一點非常重要。本書隨後的章節中會講到一些減少全域性變數的技巧和策略,比如使用名稱空間或者即時函式等,但減少全域性變數最有效的方法還是堅持使用var
來宣告變數。
在JavaScript中有意無意地建立全域性變數是件很容易的事,因為它有兩個特性:首先,你可以不宣告而直接使用變數,其次,JavaScirpt中具有“隱式全域性物件”的概念,也就是說任何不通過var
宣告的變數都會成為全域性物件的一個屬性(可以把它們當作全域性變數)。(譯註:在ES6中可以通過let
來宣告塊級作用域變數。)看一下下面這段程式碼:
function sum(x, y) {
// 反模式:隱式全域性變數
result = x + y;
return result;
}
這段程式碼中,我們直接使用了result
而沒有事先宣告它。這段程式碼的確是可以正常工作,但被呼叫後會產生一個全域性變數result
,這可能會導致其他問題。
解決辦法是,總是使用var來宣告變數,下面程式碼就是改進了的sum()
函式:
function sum(x, y) {
var result = x + y;
return result;
}
另一種建立全域性變數的反模式,就是在var
宣告中使用鏈式賦值的方法。在下面這個程式碼片段中,a
是區域性變數,但b
是全域性變數,而作者的意圖顯然不是這樣:
// 反模式
function foo() {
var a = b = 0;
// ...
}
為什麼會這樣呢?因為這裡的計算順序是從右至左的:首先計算表示式b=0
,這裡的b
是未宣告的;這個表示式的結果是0
,然後通過var建立了本地變數a
,並賦值為0
。換言之,可以將程式碼寫成這樣:
var a = (b = 0);
如果變數b已經被宣告,這種鏈式賦值的寫法是可以使用的,不會意外地建立全域性變數,比如:
function foo() {
var a, b;
// ...
a = b = 0; // 兩個都是本地變數
}
避免使用全域性變數的另一個原因是出於可移植性考慮,如果你希望將你的程式碼運行於不同的平臺環境(宿主),那麼使用全域性變數就非常危險。因為很有可能你無意間建立的某個全域性變數在當前的平臺環境中是不存在的,你以為可以安全地使用,而在另一個環境中卻是本來就存在的。
忘記var時的副作用
隱式建立的全域性變數和顯式定義的全域性變數之間有著細微的差別,就是通過delete
來刪除它們的時候表現不一致。
- 通過
var
建立的全域性變數(在任何函式體之外建立的變數)不能被刪除。 - 沒有用
var
建立的隱式全域性變數(不考慮函式內的情況)可以被刪除。
也就是說,隱式全域性變數並不算是真正的變數,但它們卻是全域性物件的屬性。屬性是可以通過delete
運算子刪除的,而變數不可以被刪除:
// 定義三個全域性變數
var global_var = 1;
global_novar = 2; // 反模式
(function () {
global_fromfunc = 3; // 反模式
}());
// 嘗試刪除
delete global_var; // false
delete global_novar; // true
delete global_fromfunc; // true
// 測試刪除結果
typeof global_var; // "number"
typeof global_novar; // "undefined"
typeof global_fromfunc; // "undefined"
在ES5嚴格模式中,給未宣告的變數賦值會報錯(比如這段程式碼中提到的兩個反模式)。
訪問全域性物件
在瀏覽器中,我們可以隨時隨地通過window
屬性來訪問全域性物件(除非你定義了一個名叫window
的區域性變數)。但換一個執行環境這個window
可能就換成了別的名字(甚至根本就被禁止訪問全域性物件了)。如果不想通過這種寫死window
的方式來訪問全域性變數,那麼你可以在任意函式作用域內執行:
var global = (function () {
return this;
}());
這種方式總是可以訪問到全域性物件,因為在被當作函式(而不是建構函式)執行的函式體內,this
總是指向全域性物件。但這種情況在ECMAScript5的嚴格模式中行不通,因此在嚴格模式中你不得不尋求其他的替代方案。比如,如果你在開發一個庫,你會將你的程式碼包裝在一個即時函式中(在第四章會講到),然後從全域性作用域給這個匿名函式傳入一個指向this
的引數。
單var模式
在函式的頂部使用唯一一個var
語句是非常推薦的一種模式,它有如下一些好處:
- 可以在同一個位置找到函式所需的所有變數
- 避免在變數宣告之前使用這個變數時產生的邏輯錯誤(參考下一小節“宣告提前:分散的var帶來的問題”)
- 提醒你不要忘記宣告變數,順便減少潛在的全域性變數
- 程式碼量更少(輸入程式碼更少且更易做程式碼優化)
單var
模式看起來像這樣:
function func() {
var a = 1,
b = 2,
sum = a + b,
myobject = {},
i,
j;
// 函式體…
}
你可以使用一個var
語句來宣告多個變數,變數之間用逗號分隔,也可以在這個語句中加入變數初始化的部分。這是一種非常好的實踐方式,可以避免邏輯錯誤(所有未初始化的變數都被聲明瞭,且值為undefined),並增加了程式碼的可讀性。過段時間後再看這段程式碼,你可以從初始化的值中大概知道這個變數的用法,比如你一眼就可看出某個變數是物件還是整數。
你可以在宣告變數時做一些額外的工作,比如在這個例子中就寫了sum=a+b
這種程式碼。另一個例子就是當代碼中用到對DOM元素時,你可以把DOM引用賦值的操作也放在這個變數宣告語句中,比如下面這段程式碼:
function updateElement() {
var el = document.getElementById("result"),
style = el.style;
// 使用el和style…
}
宣告提前:分散的var帶來的問題
JavaScript允許在函式的任意地方寫任意多個var
語句,但它們的行為會像在函式體頂部宣告變數一樣,這種現象被稱為“宣告提前”,當你在宣告語句之前使用這個變數時,可能會造成邏輯錯誤。對於JavaScript來說,一旦在某個作用域(同一個函式內)裡聲明瞭一個變數,那麼這個變數在整個作用域內都是存在的,包括在var
宣告語句之前的位置。看一下這個例子:
// 反模式
myname = "global"; // 全域性變數
function func() {
alert(myname); // "undefined"
var myname = "local";
alert(myname); // "local"
}
func();
這個例子中,你可能會期望第一個alert()
彈出“global”,第二個alert()
彈出“local”。這種結果看起來是合乎常理的,因為在第一個alert()
執行時,myname
還沒有被宣告,這時就應該“尋找”全域性變數myname
。但實際情況並不是這樣,第一個alert()
彈出“undefined”,因為myname
已經在函式內被聲明瞭(儘管宣告語句在後面)。所有的變數宣告都會被提前到函式的頂部,因此,為了避免類似帶有“歧義”的程式邏輯,最好在使用之前一起宣告它們。
上一個程式碼片段等價於下面這個程式碼片段:
myname = "global"; // 全域性變數
function func() {
var myname; // 等價於 -> var myname = undefined;
alert(myname); // "undefined"
myname = "local";
alert(myname); // "local"
}
func();
這裡有必要對“變數提前”做進一步補充,實際上從JavaScript引擎的工作機制上看,這個過程稍微有點複雜。程式碼處理經過了兩個階段:第一階段是建立變數、函式和形參,也就是預編譯的過程,它會掃描整段程式碼的上下文;第二階段是在程式碼的執行時(runtime),這一階段將建立函式表示式和一些非法的識別符號(未宣告的變數)。(譯註:這兩個階段並沒有包含程式碼的執行,是在執行前的處理過程。)從實用性角度來講,我們更願意將這兩個階段歸成一個概念“變數提前”,儘管這個概念並沒有在ECMAScript標準中定義,但我們常常用它來解釋預編譯的行為過程。
for迴圈
在for
迴圈中,可以對陣列或類似陣列的物件(比如arguments
和HTMLCollection
物件)進行遍歷,通常for
迴圈模式形如:
// 非最優的迴圈方式
for (var i = 0; i < myarray.length; i++) {
// 訪問myarray[i]…
}
這種模式的問題是,每次遍歷都會訪問陣列的length屬性,這會降低程式碼執行效率,特別是當myarray
不是一個數組而是一個HTMLCollection
物件的時候。
HTMLCollection
是由DOM方法返回的物件集合,比如:
- document.getElementsByName()
- document.getElementsByClassName()
- document.getElementsByTagName()
還有一些HTMLCollection
是在DOM標準誕生之前就已經在用了並且現在仍然可用,包括:
-
document.images
頁面中所有的IMG元素
-
document.links
頁面中所有的A元素
-
document.forms
頁面中所有的表單
-
document.forms[0].elements
頁面中第一個表單的所有欄位
這些物件的問題在於,它們都會實時查詢文件(HTML頁面)中的物件。也就是說每次通過它們訪問集合的length
屬性時,總是都會去查詢DOM,而DOM操則是很耗資源的。
更好的辦法是在for
迴圈中快取要遍歷的陣列的長度,比如下面這段程式碼:
for (var i = 0, max = myarray.length; i < max; i++) {
// 訪問myarray[i]…
}
通過這種方法只需要獲取length
一次,然後在整個迴圈過程中使用它。
不管在什麼瀏覽器中,在遍歷HTMLCollection
時快取length
都可以讓程式執行的更快,可以提速2倍(Safari3)到190倍(IE7)不等。更多細節可以參照Nicholas Zakas的《高效能JavaScript》,這本書也是由O'Reilly出版。
需要注意的是,當你在迴圈過程中需要修改這個元素集合(比如增加DOM元素)時,你可能需要更新length
。
按照“單var
模式”,你可以將var
提到迴圈的外部,比如:
function looper() {
var i = 0,
max,
myarray = [];
// …
for (i = 0, max = myarray.length; i < max; i++) {
// 訪問myarray[i]…
}
}
當你越來越依賴“單var
模式”時,帶來的好處就是提高了程式碼的一致性。而缺點則是在重構程式碼的時候不能直接複製貼上一個迴圈體,比如,你正在將某個迴圈從一個函式複製至另外一個函式中,那麼必須確保i
和max
也複製到新函式裡,並且需要從舊函式中將這些沒用的變數刪除掉。
最後一個需要對迴圈做出調整的地方是將i++替換成為下面兩者之一:
i = i + 1
i += 1
JSLint提示你這樣做,是因為++
和--
實際上降低了程式碼的可讀性,如果你覺得無所謂,可以將JSLint的plusplus
選項設為false
(預設為true
)。稍後,在本書所介紹的最後一個模式中用到了:i += 1
。
關於這種for
模式還有兩種變化的形式,做了少量改進,原因有二:
- 減少一個變數(沒有
max
) - 減量迴圈至0,這種方式速度更快,因為和零比較要比和非零數字或陣列長度比較要高效的多
第一種變化形式是:
var i, myarray = [];
for (i = myarray.length; i--;) {
// 訪問myarray[i]…
}
第二種變化形式用到了while迴圈:
var myarray = [],
i = myarray.length;
while (i--) {
// 訪問myarray[i]…
}
這些小改進只能體現在對效能要求比較苛刻的地方,此外,JSLint不推薦使用i--
。
for-in迴圈
for-in
迴圈用於對非陣列物件進行遍歷。通過for-in
進行迴圈也被稱作“列舉”(enumeration)。
從技術上講,for-in
迴圈同樣可以用於陣列(JavaScript中陣列也是物件),但不推薦這樣做。當陣列物件被擴充了自定義函式時,可能會產生邏輯錯誤。另外,for-in
迴圈中屬性的遍歷順序是不固定的,所以最好陣列使用普通的for
迴圈,物件使用for-in
迴圈。
可以使用物件的hasOwnProperty()
方法過濾來自原型鏈中繼承來的屬性,這一點非常重要。看一下這段程式碼:
// 物件
var man = {
hands: 2,
legs: 2,
heads: 1
};
// 在程式碼的另一個地方給所有的物件添加了一個方法
if (typeof Object.prototype.clone === "undefined") {
Object.prototype.clone = function () {};
}
在這個例子中,我們使用物件字面量定義了一個名叫man
的物件。在程式碼中的某個地方(可以是man
定義之前也可以是之後),給Object
的原型增加了一個方法clone()
。原型鏈是實時的,這意味著所有的物件都可以訪問到這個新方法。要想在列舉man
的時候避免枚舉出clone()
方法,就需要呼叫hasOwnProperty()
來過濾來自原型的屬性。如果不做過濾,clone()
也會被遍歷到,這是我們不希望看到的:
// 1.for-in迴圈
for (var i in man) {
if (man.hasOwnProperty(i)) { // filter
console.log(i, ":", man[i]);
}
}
/*
控制檯中的結果
hands : 2
legs : 2
heads : 1
*/
// 2.反模式:
// 不使用hasOwnProperty()過濾的for-in迴圈
for (var i in man) {
console.log(i, ":", man[i]);
}
/*
控制檯中的結果
hands : 2
legs : 2
heads : 1
clone: function()
*/
另外一種呼叫hasOwnProperty()
的方法是通過Object.prototype
來呼叫,像這樣:
for (var i in man) {
if (Object.prototype.hasOwnProperty.call(man, i)) { // 過濾
console.log(i, ":", man[i]);
}
}
這種做法的好處是,在man
物件中重新定義了hasOwnProperty
方法的情況下,可以避免呼叫時的命名衝突。為了避免查詢屬性時從Object
物件一路找到原型的冗長過程,你可以定義一個變數來“快取”住它:
var i,
hasOwn = Object.prototype.hasOwnProperty;
for (i in man) {
if (hasOwn.call(man, i)) { // 過濾
console.log(i, ":", man[i]);
}
}
嚴格說來,省略
hasOwnProperty()
並不是一個錯誤。根據具體的任務以及你對程式碼的自信程度,你可以省略掉它以提高一些程式執行效率。但當你對當前要遍歷的物件不確定的時候,新增hasOwnProperty()則更加保險些。
這裡介紹一種格式上的變種(這種寫法無法通過JSLint檢查),這種寫法在for
迴圈所在的行加入了if
判斷條件,他的好處是能讓迴圈語句讀起來更完整和通順(“如果元素包含屬性X,則對X做點什麼”):
// 警告:無法通過JSLint檢查
var i,
hasOwn = Object.prototype.hasOwnProperty;
for (i in man) if (hasOwn.call(man, i)) { // 過濾
console.log(i, ":", man[i]);
}
(不)擴充內建原型
我們可以擴充建構函式的prototype
屬性來為建構函式增加功能,這個特性非常強大,但有時會強大到超過我們的掌控。
給內建建構函式如Object()
、Array()
、Function()
擴充原型看起來非常誘人,但這種做法會嚴重降低程式碼的可維護性,因為它會讓你的程式碼變得難以預測。對於那些基於你的程式碼來做開發的開發者來說,他們更希望使用原生的JavaScript方法來保持程式碼的一致性,而不願意使用你所新增的方法。
另外,如果將屬性新增至原型中,很可能導致原型上的屬性在那些不使用hasOwnProperty()
做過濾的迴圈中被遍歷出來,從而造成混亂。
因此,不擴充內建物件的原型是最好的,你也可以自己定義一個規則,僅當下列條件滿足時才考慮擴充內建物件的原型:
- 未來的ECMAScript版本或者JavaScirpt會將你將要實現的方法新增為內建方法。比如,你可以實現ECMAScript5定義的一些方法,直到瀏覽器升級至支援ES5。這樣,你只是提前定義了這些方法。
- 當某個屬性或者方法是你在其它地方實現過的,或者是某個JavaScript引擎或瀏覽器的一部分,而你檢查時又發現它不存在時。
- 在有充分的文件說明,並且和團隊其他成員做了溝通的時候。
如果你遇到這三種情況之一,你可以給內建原型新增自定義方法,寫法如下:
if (typeof Object.protoype.myMethod !== "function") {
Object.protoype.myMethod = function () {
// 實現…
};
}
switch模式
你可以通過下面這種模式來增強switch
語句的可讀性和健壯性:
var inspect_me = 0,
result = '';
switch (inspect_me) {
case 0:
result = "zero";
break;
case 1:
result = "one";
break;
default:
result = "unknown";
}
這個簡單的例子所遵循的風格約定如下:
- 每個
case
和switch
對齊(這裡不考慮花括號相關的縮排規則)。 - 每個
case
中的程式碼整齊縮排。 - 每個
case
都以break
作為結束。 - 避免連續執行多個case語句塊(省略break時),如果你堅持認為連續執行多個
case
語句塊是最好的方法,請務必補充文件說明,對於其他人來說,會覺得這種情況是錯誤的寫法。 - 以
default
結束整個switch
,以確保即便是在找不到匹配項時也有合理的結果。
避免隱式型別轉換
在JavaScript對變數進行比較時會有一些隱式的資料型別轉換。比如諸如false == 0
或"" == 0
之類的比較都返回true
。
為了避免隱式型別轉換對程式造成干擾,推薦使用===
和!==
運算子,它們除了比較值還會比較型別:
var zero = 0;
if (zero === false) {
// 不會執行,因為zero是0,不是false
}
// 反模式
if (zero == false) {
// 程式碼塊會執行…
}
有一種觀點認為當==
夠用的時候就不必使用===
。比如,當你知道typeof
的返回值是一個字串,就不必使用全等運算子。但JSLint卻要求使用全等運算子,這無疑會提高程式碼風格的一致性,並減少了閱讀程式碼時的思考量(“這裡使用==
是故意的還是無意的?”)。
避免使用eval()
當你想使用eval()
的時候,不要忘了那句話“eval()
is evil”(eval()
是魔鬼)。這個函式的引數是一個字串,它會將傳入的字串作為JavaScript程式碼執行。如果用來解決問題的程式碼是事先知道的(在執行之前),則沒有理由使用eval()
。如果需要在執行時動態生成並執行程式碼,那一般都會有更好的方式達到同樣的目的,而非一定要使用eval()
。例如,訪問動態屬性時可以使用方括號:
// 反模式
var property = "name";
alert(eval("obj." + property));
// 更好的方式
var property = "name";
alert(obj[property]);
eval()
還有安全隱患,因為你有可能會執行一些被幹擾過的程式碼(比如一段來自於網路的程式碼)。這是一種在處理Ajax請求所返回的JSON資料時比較常見的反模式。這種情況下最好使用瀏覽器的內建方法來解析JSON資料,以確保程式碼的安全性和資料的合法性。如果瀏覽器不支援JSON.parse()
,你可以使用JSON.org所提供的庫。
值得一提的是,多數情況下,給setInterval()
、setTimeout()
和Function()
建構函式傳入字串的情形和eval()
類似,這種用法也是應當避免的,因為這些情形中JavaScript最終還是會執行傳入的字串引數:
// 反模式
setTimeout("myFunc()", 1000);
setTimeout("myFunc(1, 2, 3)", 1000);
// 更好的方式
setTimeout(myFunc, 1000);
setTimeout(function () {
myFunc(1, 2, 3);
}, 1000);
new Function()
的用法和eval()
非常類似,應當特別注意。這種建構函式的方式很強大,但經常會被誤用。如果你不得不使用eval()
,你可以嘗試用new Function()
來代替。這有一個潛在的好處,在new Function()
中執行的程式碼會在一個區域性函式作用域內執行,因此原始碼中所有用var
定義的變數不會自動變成全域性變數。還有一種方法可以避免eval()
中定義的變數被轉換為全域性變數,即是將eval()
包裝在一個即時函式內(詳細內容請參見第四章)。
看一下這個例子,這裡只有un
成為全域性變數汙染了全域性名稱空間:
console.log(typeof un);// "undefined"
console.log(typeof deux); // "undefined"
console.log(typeof trois); // "undefined"
var jsstring = "var un = 1; console.log(un);";
eval(jsstring); // 打印出 "1"
jsstring = "var deux = 2; console.log(deux);";
new Function(jsstring)(); // 打印出 "2"
jsstring = "var trois = 3; console.log(trois);";
(function () {
eval(jsstring);
}()); // 打印出 "3"
console.log(typeof un); // "number"
console.log(typeof deux); // "undefined"
console.log(typeof trois); // "undefined"
eval()
和Function()
建構函式還有一個區別,就是eval()
可以修改作用域鏈,而Function
更像是一個沙箱。不管在什麼地方執行Function()
,它都只能看到全域性作用域。因此它不會太嚴重的汙染區域性變數。在下面的示例程式碼中,eval()
可以訪問並修改其作用域之外的變數,而Function()
則不能(注意,使用Function()
和new Function()
是完全一樣的)。
(function () {
var local = 1;
eval("local = 3; console.log(local)"); // 打印出 3
console.log(local); // 打印出 3
}());
(function () {
var local = 1;
Function("console.log(typeof local);")(); // 打印出 undefined
}());
使用parseInt()進行數字轉換
你可以使用parseInt()
將字串轉換為數字。函式的第二個引數是進位制引數,這個引數應該被指定,但卻通常被省略。當字串以0為字首時轉換就會出問題,例如,在表單中輸入日期的一個欄位。ECMAScript3中以0為字首的字串會被當作八進位制數處理,這一點在ES5中已經有了改變。為了避免轉換型別不一致而導致的意外結果,應當總是指定第二個引數:
var month = "06",
year = "09";
month = parseInt(month, 10);
year = parseInt(year, 10);
在這個例子中,如果省略掉parseInt的第二個引數,比如parseInt(year)
,返回的值是0,因為“09”被認為是八進位制數(等價於parseInt(year,8)
),但09是非法的八進位制數。
字串轉換為數字還有兩種方法:
+"08" // 結果為8
Number("08") // 結果為8
這兩種方法要比parseInt()
更快一些,因為顧名思義parseInt()
是一種“解析”而不是簡單的“轉換”。但當你期望將“08 hello”這類字串轉換為數字,則必須使用parseInt()
,其他方法都會返回NaN。
程式碼規範
確立並遵守程式碼規範非常重要,這會讓你的程式碼風格一致、可預測,並且可讀性更強。團隊新成員通過學習程式碼規範可以很快進入開發狀態,並寫出讓團隊其他成員易於理解的程式碼。
在開源社群和郵件組中關於編代風格的爭論一直不斷。(比如關於程式碼縮排,用tab還是空格?)因此,如果你打算在團隊內推行某種編碼規範時,要做好應對各種反對意見的心理準備,而且要吸取各種意見。確定並遵守程式碼規範非常重要,任何一種規範都可以,這甚至比程式碼規範中的具體約定是怎麼樣的還要重要。
縮排
程式碼如果沒有縮排就幾乎不能讀了,而不一致的縮排會使情況更加糟糕,因為它看上去像是遵守了規範,但真正讀起來卻沒那麼順利。因此規範地使用縮排非常重要。
有些開發者喜歡使用tab縮排,因為每個人都可以根據自己的喜好來調整tab縮排的空格數,有些人則喜歡使用空格縮排,通常是四個空格。這都無所謂,只要團隊每個人都遵守同一個規範即可,本書中所有的示例程式碼都採用四個空格的縮排寫法,這也是JSLint所推薦的。(譯註:電子版中看到的是用tab縮排,本譯文也保留使用tab縮排。)
那麼到底什麼時候應該縮排呢?規則很簡單,花括號裡的內容應當縮排,包括函式體、迴圈(do
、while
、for
和for-in
)體、if
語句、switch
語句和物件字面量裡的屬性。下面的程式碼展示瞭如何正確地使用縮排:
function outer(a, b) {
var c = 1,
d = 2,
inner;
if (a > b) {
inner = function () {
return {
r: c - d
};
};
} else {
inner = function () {
return {
r: c + d
};
};
}
return inner;
}
花括號
在特定的語句中應當總是使用花括號,即便是在可省略花括號的情況下也應當如此。從技術角度講,如果if
或for
中只有一個語句,花括號是可以省略的,但最好還是不要省略,這會讓你的程式碼更加工整一致而且易於修改。
假設有這樣一段程式碼,for
迴圈中只有一條語句,你可以省略掉這裡的花括號,而且不會有語法錯誤:
// 不好的方式
for (var i = 0; i < 10; i += 1)
alert(i);
但如果過了一段時間,你給這個迴圈添加了另一行程式碼會怎樣?
// 不好的方式
for (var i = 0; i < 10; i += 1)
alert(i);
alert(i + " is " + (i % 2 ? "odd" : "even"));
第二個alert
實際上在迴圈體之外,但這裡的縮排會讓你迷惑。從長遠考慮最好還是寫上花括號,即便是在只有一個語句的語句塊中也應如此:
// 更好的方式
for (var i = 0; i < 10; i += 1) {
alert(i);
}
同理,if條件句也應當如此:
// 不好的方式
if (true)
alert(1);
else
alert(2);
// 更好的試
if (true) {
alert(1);
} else {
alert(2);
}
左花括號的位置
開發人員對於左大括號的位置有著不同的偏好,在同一行呢還是在下一行?
if (true) {
alert("It's TRUE!");
}
或者:
if (true)
{
alert("It's TRUE!");
}
在這個例子中,這個問題只是個人偏好問題。但有時候花括號位置的不同會影響程式的執行,因為JavaScript會“自動插入分號”。JavaScript對行尾是否有分號並沒有要求,它會自動將分號補全。因此,當函式的return
語句返回了一個物件字面量,而物件的左花括號和return
又不在同一行時,程式的執行就和預期的不同了:
// 警告:返回值和預期的不同
function func() {
return
{
name: "Batman"
};
}
可以看出程式作者的意圖是返回一個包含了name
屬性的物件,但實際情況不是這樣。因為return後會填補一個分號,函式的返回值就是undefined。這段程式碼等價於:
// 警告:返回值和預期的不同
function func() {
return undefined;
// 下面的程式碼不會執行…
{
name: "Batman"
};
}
總結一下好的寫法,在特寫的語句中總是使用花括號,並且總是將左花括號與上一條語句放在同一行:
function func() {
return {
name: "Batman"
};
}
關於分號也值得注意:和花括號一樣,應當總是使用分號,儘管在JavaScript解析程式碼時會補全行末省略的分號,但嚴格遵守這條規則,可以讓程式碼更加嚴謹,同時可以避免前面例子中所出現的歧義。
### 空格
空格的使用同樣有助於改善程式碼的可讀性和一致性。在寫英文句子的時候,在逗號和句號後面會使用間隔,在JavaScript中,你可以按照同樣的邏輯在表示式(相當於逗號)和語句結束(相對於完成了某個“想法”的表達)後面新增間隔。
適合使用空格的地方包括:
- for迴圈中的分號之後,比如
for (var i = 0; i < 10; i += 1) {...}
- for迴圈中初始化多個變數,比如
for (var i = 0, max = 10; i < max; i += 1) {...}
- 用於分隔陣列元素的逗號之後,比如
var a = [1, 2, 3];
- 物件屬性後的逗號以及名值對之間的冒號之後,比如
var o = {a: 1, b: 2};
- 函式引數中,比如
myFunc(a, b, c)
- 函式宣告的花括號之前,比如
function myFunc() {}
- 匿名函式表示式
function
之後,比如var myFunc = function () {};
另外,我們推薦在運算子和運算元之間也新增空格。也就是說在+
、-
、*
、=
、<
、>
、<=
、>=
、===
、!==
、&&
、||
、+=
符號前後都新增空格。
// 適當且一致的空格給程式碼留了“呼吸空間”,使程式碼更易讀
var d = 0,
a = b + 1;
if (a && b && c) {
d = a % c;
a += d;
}
// 反模式,缺少或者不正確的空格使得程式碼不易讀
var d= 0,
a =b+1;
if (a&& b&&c) {
d=a %c;
a+= d;
}
最後,還應當注意,最好在花括號旁邊新增空格:
- 在函式、
if-else
語句、迴圈、物件字面量的左花括號之前補充空格 - 在右花括號和
else
或者while
之間補充空格
垂直空白的使用經常被我們忽略,你可以使用空行來將程式碼單元分隔開,就像文學作品中使用段落進行分隔一樣。
命名規範
另外一種可以提升你程式碼的可預測性和可維護性的方法是採用命名規範。也就是說變數和函式的命名都遵守同樣的習慣。
下面是一些建議的命名規範,你可以原樣採用,也可以根據自己的喜好作調整。同樣,遵循規範要比規範本身是什麼樣更加重要。
建構函式命名中的大小寫
JavaScript中沒有類,但有建構函式,可以通過new
來呼叫建構函式:
var adam = new Person();
由於建構函式畢竟還是函式,如果只通過函式名就可分辨出它是建構函式還是普通函式是非常有用的。
首字母大寫可以提示你這是一個建構函式,而首字母小寫的函式一般只認為它是普通的函式,不應該通過new來呼叫它:
function MyConstructor() {...}
function myFunction() {...}
下一章將介紹一些強制將普通函式用作建構函式的程式設計模式,但遵守我們所提到的命名規範會更好的幫助程式設計師閱讀原始碼。
單詞分隔
當你的變數名或函式名中含有多個單詞時,單詞之間的分隔也應當遵循統一的規範。最常見的是“駝峰式”(camel case)命名,單詞都是小寫,每個單詞的首字母是大寫。
對於建構函式,可以使用“大駝峰式”(upper camel case)命名,比如MyConstructor()
,對於函式和方法,可以採用“小駝峰式”(lower camel case)命名,比如myFunction()
、calculateArea()
和getFirstName()
。
那麼對於那些不是函式的變數應當如何命名呢?變數名通常採用小駝峰式命名,還有一個不錯的做法是,變數所有字母都是小寫,單詞之間用下劃線分隔,比如,first_name
、favorite_bands
和old_company_name
,這種方法可以幫助你區分函式和其他識別符號如原始型別資料或物件。
ECMAScript的屬性和方法均使用駝峰式命名,儘管包含多個單詞的屬性名稱並不多見(正則表示式物件的lastIndex
和ignoreCase
屬性)。
其他命名風格
有時開發人員使用命名規範來彌補或代替語言特性的不足。
比如,JavaScript中無法定義常量(儘管有一些內建常量比如Number.MAX_VALUE
),所以開發者都採用了一種命名規範,對於那些程式執行週期內不會更改的變數使用全大寫字母來命名。比如:
// 常量,請勿修改
var PI = 3.14,
MAX_WIDTH = 800;
除了使用大寫字母的命名方式之外,還有另一種命名規範:全域性變數全大寫。這種命名方式和“減少全域性變數”的約定相輔相成,並讓全域性變數很容易辨認。
除了常量和全域性變數的命名規範,這裡討論另外一種命名規範,即私有變數的命名。儘管在JavaScript是可以實現真正的私有變數的,但開發人員更喜歡在私有成員或方法名之前加上下劃線字首,比如下面的例子:
var person = {
getName: function () {
return this._getFirst() + ' ' + this._getLast();
},
_getFirst: function () {
// ...
},
_getLast: function () {
// ...
}
};
在這個例子中,getName()
是一個公有方法,是確定的API的一部分,而_getFirst()
和_getLast()
則是私有方法。儘管這兩個方法本質上和公有方法沒有區別,但在方法名前加下劃線字首就是為了告知使用者不要直接使用這兩個私有方法,因為不能保證它們在下一個版本中還能正常工作。JSLint會對私有方法作檢查,除非設定了JSLint的nomen
選項為false
。
下面介紹一些_private
風格寫法的變種:
- 在名字尾部新增下劃線以表明私有,比如
name_
和getElements_()
- 使用一個下劃線字首表明受保護的屬性
_protected
,用兩個下劃線字首表明私有屬性__private
- 在Firefox中實現了一些非標準的內建屬性,這些屬性在開頭和結束都有兩個下劃線,比如
__proto__
和__parent__
寫註釋
在寫程式碼時,即便你認為你的程式碼不會被別人讀到,也應該寫好註釋。因為當你對一個問題非常熟悉時,你會非常明白這些程式碼的作用,但當過了幾個星期後再來讀這段程式碼時,則需要絞盡腦汁的回想這些程式碼在幹什麼。
你不必對那些淺顯易懂的程式碼寫過多的註釋,比如每個變數、每一行都寫註釋。但你應該對所有的函式、它們的引數和返回值進行註釋,除此之外,對於那些值得注意的或是比較怪異的演算法和技術也應當寫好註釋。對於其他閱讀你程式碼的人來說,註釋就是一種提示,只要閱讀註釋、函式名和引數,就算不讀其它部分的程式碼也能大概理解程式的邏輯。比如,這裡有五六行程式碼完成了某個功能,如果有一行描述這段程式碼功能的註釋,讀程式的人就不必再去關注程式碼的實現細節了。程式碼註釋的寫法並沒有硬性規定,但有些程式碼片段(比如正則表示式)需要比程式碼本身更多的註釋。
過時的註釋會造成誤導,這比不寫註釋還要糟糕。保持註釋的狀態為最新的習慣非常重要,儘管對很多人來說這很難做到。
在下一小節我們會講到,利用註釋可以自動生成文件。
寫API文件
很多人都覺得寫文件是一件很枯燥而且吃力不討好的事情,但實際情況並不是這樣。我們可以通過程式碼註釋自動生成文件,這樣就不用再去專門寫文件了。很多人覺得這是一個不錯的點子,因為根據某些關鍵字和特定的格式自動生成可閱讀的參考手冊本身就是“某種程式設計”。
最早利用註釋生成API文件的工具誕生自Java業界,這個工具名叫“javadoc”,和Java SDK(軟體開發工具包)一起提供,但這個創意迅速被其他語言借鑑。JavaScript領域有兩個非常優秀的開源工具,它們是JSDoc Toolkit(http://code.google.com/p/jsdoc-toolkit/)和YUIDoc(http://yuilibrary.com/projects/yuidoc)。
生成API文件的過程:
- 以特定的格式來寫程式碼
- 執行工具來對程式碼和註釋進行解析
- 釋出工具執行的結果,通常是HTML頁面
這種語法包括十幾種標籤(tag),寫法類似於:
/**
* @tag value
*/
比如這裡有一個函式reverse()
,可以對字串進行反序操作。它的引數和返回值都是字串。給它補充註釋如下:
/**
* Reverse a string
*
* @param {String} input String to reverse
* @return {String} The reversed string
*/
var reverse = function (input) {
// ...
return output;
};
如你所見,@param
是用來說明輸入引數的標籤,@return
是用來說明返回值的標籤,文件生成工具最終會將這種帶註釋的原始碼解析成HTML文件。
示例:YUIDoc
YUIDoc的初衷是為YUI(Yahoo! User Interface)庫生成文件,但其實它也可以應用於任何專案。為了更充分的使用YUIDoc,你需要學習它的註釋規範,比如模組和類的寫法。(儘管在JavaScript中其實是沒有類的概念的)。
讓我們看一個用YUIDoc生成文件的完整例子。
圖2-1展示了最終生成的文件的樣子,你可以根據專案需要定製HTML模板,讓生成的文件更加友好和個性化。
這裡提供了線上的demo,請參照http://jspatterns.com/book/2/。
這個例子中所有的應用作為一個模組(myapp)放在一個檔案裡(app.js),後續的章節會更詳細的介紹模組,現在只需知道可以用一個YUIDoc的標籤來表示模組即可。
圖2-1 YUIDoc生成的文件
app.js
的開始部分:
/**
* My JavaScript application
*
* @module myapp
*/
然後定義了一個空物件作為模組的名稱空間:
var MYAPP = {};
緊接著定義了一個包含兩個方法的物件math_stuff
,這兩個方法分別是sum()
和multi()
:
/**
* A math utility
* @namespace MYAPP
* @class math_stuff
*/
MYAPP.math_stuff = {
/**
* Sums two numbers
*
* @method sum
* @param {Number} a First number
* @param {Number} b The second number
* @return {Number} The sum of the two inputs
*/
sum: function (a, b) {
return a + b;
},
/**
* Multiplies two numbers
*
* @method multi
* @param {Number} a First number
* @param {Number} b The second number
* @return {Number} The two inputs multiplied
*/
multi: function (a, b) {
return a * b;
}
};
這樣就完成了第一個“類”的定義,注意以下標籤:
-
@namespace
包含物件的全域性引用
-
@class
代表一個物件或建構函式(JavaScript中沒有類)
-
@method
定義物件的方法,並指定方法的名稱
-
@param
列出函式需要的引數,引數的型別放在一對花括號內,後面跟引數名和描述
-
@return
和@param類似,用以描述方法的返回值,可以不帶名字
我們來實現第二個“類”,使用一個建構函式,並給這個建構函式的原型新增一個方法,看看YUIDoc在面對不同的物件建立方式時是如何工作的:
/**
* Constructs Person objects
* @class Person
* @constructor
* @namespace MYAPP
* @param {String} first First name
* @param {String} last Last name
*/
MYAPP.Person = function (first, last) {
/**
* Name of the person
* @property first_name
* @type String
*/
this.first_name = first;
/**
* Last (family) name of the person
* @property last_name
* @type String
*/
this.last_name = last;
};
/**
* Returns the name of the person object
*
* @method getName
* @return {String} The name of the person
*/
MYAPP.Person.prototype.getName = function () {
return this.first_name + ' ' + this.last_name;
};
在圖2-1中可以看到生成的文件中Person
建構函式的生成結果,值得注意的部分是:
@constructor
說明這個“類”其實是一個建構函式@prototype
和@type
用來描述物件的屬性
YUIDoc工具是與語言無關的,只解析註釋塊,而不是JavaScript程式碼。它的缺點是必須要在註釋中指定屬性、引數和方法的名字,比如,@property first_name
。好處是一旦你熟練掌握YUIDoc,就可以用它對任何語言原始碼生成文件。
編寫易讀的程式碼
這種編寫註釋塊來生成API文件的做法可不僅僅是為了偷懶,它還有另外一個作用,就是通過回頭重看程式碼來提高程式碼質量。
隨便一個作者或者編輯都會告訴你“編輯非常重要”,甚至是寫一本好書或好文章最最重要的步驟。將想法落實在紙上形成草稿只是第一步,草稿確實可以給讀者提供不少資訊,但往往還不是重點最明晰、結構最合理、最符合閱讀習慣的呈現形式。
程式設計也是同樣的道理,當你坐下來解決一個問題的時候,這時的解決方案只是一種“草案”,儘管能正常工作,但是不是最優的方法呢?是不是可讀性好、易於理解、可維護和更新?假設當你過一段時間後再來回頭看你的程式碼,一定會發現很多需要改進的地方,比如需要重新組織程式碼或刪掉多餘的內容等等。這實際上就是在“整理”你的程式碼了,可以很大程度上提高你的程式碼質量。但事實卻不那麼如願,我們常常承受著高強度的工作,根本沒有時間來整理程式碼,因此通過程式碼註釋來寫文件其實是個不錯的機會。
你往往會在寫註釋文件的時候發現很多問題,也會重新思考程式碼中的不合理之處,比如,某個方法中的第三個引數比第二個引數更常用,第二個引數多數情況下取值為true
,因此就需要對這個方法進行適當的改造和包裝。
寫出易讀的程式碼(或API),是指寫程式碼時要有讓別人能輕易讀懂的意識。帶著這個意識,你就需要不斷思考採用更好的方法來解決手頭的問題。
說回“草稿”的問題,也算是“抱佛腳”的權宜之計,一眼看上去是有點“草”,不過至少是有用的,特別是當你處理的是一個關鍵專案時(比如人命關天時)。一個合適的思路是,你應當始終扔掉你所給出的第一個解決方案,雖然它是可以正常工作的,但畢竟是一個草稿,是一種僅用於驗證解決問題可行性的方案。事實上,第二個方案往往會更好,因為這時你對問題的理解會更加透徹。在產生第二個方案的過程中,不要允許自己去複製貼上之前的程式碼,這有助於阻止自己投機取巧利用之前的捷徑,最後產生不完美的方案。
同事評審(Peer Reviews)
另外一種可以提高程式碼質量的方法是組織相互評審。同事評審可以用一些工具輔助,可以很正式很規範,也是一種開發流程中值得提倡的步驟。你可能覺得沒有時間去作程式碼互相評審,沒關係,你可以讓坐在你旁邊的同事讀一下你的程式碼,或者和她(譯註:注意是“她”而不是“他”)一起過一遍你的程式碼。
同樣,當你在寫API文件或者其他文件的時候,同事評審能讓你的產出物更加清晰,因為你寫的文件是本來就是讓別人讀的,你得讓別人通過文件知道你所做的東西。
同事評審是一種很好的實踐,不僅僅是因為它能讓程式碼變得更好,更重要的是,在評審的過程中,評審人和程式碼作者通過分享和討論,兩人都能取長補短、相互促進。
如果你的團隊只有你一個開發人員,找不出第二個人能給你作程式碼評審,這也沒關係。你可以通過將你的程式碼片段開源,或把有意思的程式碼片段貼在部落格中,讓全世界的人為你評審。
另外一個很好的實踐是使用版本管理工具(CVS、SVN或Git),一旦有人修改並提交了程式碼,就會發郵件通知組內成員。雖然大部分郵件都進入了垃圾箱,但總是會碰巧有人在工作間隙看到你所提交的程式碼,並對程式碼做出一些評價。
釋出時的程式碼壓縮(Minify)
這裡所說的程式碼壓縮(Minify)是指去除JavaScript程式碼中的空格、註釋以及其他不必要的部分,用以減少JavaScript檔案的體積,降低網路頻寬消耗。我們通常使用壓縮工具來進行壓縮,比如YUICompressor(Yahoo!)或Closure Compiler(Google),這可以減少頁面載入時間。壓縮用於釋出的的指令碼是很重要的,壓縮後的檔案體積能減少至原來的一半以下。
下面這段程式碼是壓縮後的樣子(這段程式碼是YUI2庫中的事件模組):
YAHOO.util.CustomEvent=function(D,C,B,A){this.type=D;this.scope=C||window;this.silent
=B;this.signature=A||YAHOO.util.CustomEvent.LIST;this.subscribers=[];if(!this.silent)
{}var E="_YUICEOnSubscribe";if(D!==E){this.subscribeEvent=new
YAHOO.util.CustomEvent(E,this,true);}...
除了去除空格、空行和註釋之外,壓縮工具還能縮短命名的長度(在保證程式碼安全的前提下),比如這段程式碼中的引數A
、B
、C
、D
。壓縮工具只會重新命名區域性變數,因為更改全域性變數會破壞程式碼的邏輯,這也是要儘量使用區域性變數的原因。如果你使用的全域性變數是對DOM節點的引用,而且程式中多次用到,那麼最好將它賦值給一個區域性變數,這樣能提高查詢速度,程式碼也會執行的更快,此外還能提高壓縮比、加快下載速度。
補充說明一下,Goolge Closure Compiler還會為了更高的壓縮比對全域性變數進行壓縮(在“高階”模式中),這是很危險的,且對程式設計規範的要求非常苛刻。
對用於生產環境的指令碼做壓縮是非常重要的步驟,因為它能提升頁面效能,但你應當將這個過程交給工具來完成。千萬不要試圖手寫“壓縮好的”程式碼,你應當在編寫程式碼時堅持使用語義化的變數命名,並保留足夠的空格、縮排和註釋。你寫的程式碼是需要被人閱讀的,所以應當將注意力放在程式碼可讀性和可維護性上,將程式碼壓縮的工作交給工具去完成。
執行JSLint
在上一章我們已經介紹了JSLint,本章中也提到了數次。到現在你應該已經相信用JSLint檢查你的程式碼是一種好的程式設計模式了。
JSLint的檢查點都有哪些呢?它會對本章討論過的一些模式(單var
模式、parseInt()
的第二個引數、總是使用花括號)做檢查。JSLint還包括其他方面的檢查:
- 不可達程式碼(譯註:指永遠不可能執行的程式碼)
- 變數在宣告之前被使用
- 不安全的UTF字元
- 使用
void
、with
或者eval
- 無法正確解析的正則表示式
JSLint是基於JavaScript實現的(它自己的程式碼是可以通過JSLint檢查的),它提供了線上工具,也可以下載使用,可以運行於很多種平臺的JavaScript解析器。你可以將原始碼下載後在本地執行,支援的環境包括WSH(Windows Scripting Host,Windows)、JSC(JavaScriptCore,MacOSX)或Rhino(Mozilla開發的JavaScript引擎)。
將JSLint下載後和你的程式碼編輯器配置在一起是個很不錯的主意,這樣每次你儲存程式碼的時候都會自動執行程式碼檢查。(為它配置一個快捷鍵也很有用)。
小結
本章我們討論了編寫可維護性程式碼的意義,它不僅關係著軟體專案的成功與否,還關係到參與專案的工程師的“精神健康”和“幸福指數”。隨後我們討論了一些最佳實踐和模式,它們包括:
- 減少全域性物件,最好每個應用只有一個全域性物件
- 函式都使用單
var
模式來定義,這樣可以將所有的變數放在同一個地方宣告,同時可以避免“宣告提前”給程式邏輯帶來的影響 for
迴圈、for-in
迴圈、switch
語句、“避免使用eval()
”、不要擴充內建原型- 遵守統一的編碼規範(在任何必要的時候保持空格、縮排、花括號和分號)和命名規範(建構函式、普通函式和變數)。
本章還討論了其他一些和程式碼本身無關的實踐,這些實踐和編碼過程緊密相關,包括寫註釋、寫API文件、組織同事評審、不要試圖去手動“壓縮”(minify)程式碼而犧牲程式碼可讀性、堅持使用JSLint來對程式碼進行檢查。