沒有一門編程語言是完美的,JavaScript 也不例外,它語法陷阱重重,防不勝防:
- 加號
- "with"
- 分號自動插入
- 聲明提升
- "eval"
- 多行字符串
- 變量泄漏
- "arguments.callee"
- ...
了解和熟悉這些陷阱,並在開發時注意規避它們,可以給我們省去很多麻煩事。
加號
作為二元運算符時,+
既是數學運算的加法,也是字符串的拼接。另外,它還可以作為一元符號,表示正數。
看看下面的代碼:
// 1 console.log( 1 + 2 ); // 3 console.log( "3" + "4" ); // "34" // 2 console.log( 1 + "3" ); // "13" console.log( "3" + 1 ); // "31" // 3 console.log( 1 + null ); console.log( 1 + undefined ); console.log( 1 + NaN ); // 4 console.log( "3" + null ); console.log( "3" + undefined ); console.log( "3" + NaN ); // 5 console.log( 1 + {} ); console.log( 1 + [] ); // 6 console.log( "3" + {} ); console.log( "3" + [] );
也許你可以准確的說出第1組代碼的結果,甚至第2組也能答上,但剩下的幾組你能毫不猶豫地給出答案嗎?
在 JavaScript 中,是如何決定一段代碼中的 +
是數學運算還是字符串拼接呢?答案請看下面這段邏輯:
a + b: pa = ToPrimitive(a) pb = ToPrimitive(b) if (pa is string || pb is string) return concat(ToString(pa), ToString(pb)) else return add(ToNumber(pa), ToNumber(pb))
- 收集
+
兩端的操作數的原始值。 - 如果其中之一是字符串,則進行字符串拼接。
- 否則,執行數學加法。
需要注意的是,JavaScript 的原始值類型包括 number, string, boolean, undefined, 而 null 也是一種特殊的原始值。另一方面,對於非原始值類型(即復合類型,也即 object )的變量,其原始值被認為是字符串。
按這個邏輯,之前的測試結果就容易理解了。當然,像上面那樣使用加號是不被推薦的,為了避免混淆,利用上面的加號邏輯,我們通常可以這樣使用加號:
// 確保數字相加 a = +b + (+c); // 確保變量 d 為字符串 d = "" + d;
"with"
使用 with
語句,可以將一個語句塊的上下文綁定為一個指定對象。
with (document) { write("foo"); getElemntById("bar").innerHTML = "foobar"; alert("Hello world!"); } // 等同於以下代碼 document.write("foo"); document.getElemntById("bar").innerHTML = "foobar"; window.alert("Hello world!");
但是咱們不推薦使用 with ,事實上,ECMAScript 5 中引入的嚴格模式也禁止使用 with :
- JavaScript 解釋器引擎將難以對代碼執行優化。解釋器引擎的執行優化是建立在“明確的知道這個變量在運行時所指向的引用”的基礎上的。而在 with 語句塊中的變量或函數,在解釋階段無法判斷其是屬於 with 的上下文,還是其所在作用域,只有等到代碼運行時才能確定。
- 代碼可閱讀性差。
分號自動插入
在語句結束時,你不必手動輸入分號,換行即可。
function foo() { var bar = "value" return bar } // `{}` 包圍的語句塊的最后一個語句的分號也可省略 function bar() { return "foo" }
開發者們每寫一行代碼,就可以少敲打一次鍵盤,這看起來很人性化。但過於依賴分號自動插入,會帶來一些潛在問題。
function foo() { return { bar: 1 } } function bar() { var a, b, c, d, e a = b + c (d + e).toString() }
看看上面的代碼,foo()
將返回什么? bar()
又將怎么運行?
事實上,前者將返回 undefined
,而后者的后兩行代碼將被理解為 a = b + c(d + e).toString()
。
JavaScript 的分號自動插入的規則並不那么清晰可辨,老實地多敲幾次鍵盤,可以避免那些讓你摸不着頭緒的bug在某一天突然出現。
聲明提升
看看下面這段代碼,我們將得到什么結果?
var foo = 1; function bar() { // 這個條件成立嗎? if (! foo) { var foo = 10; } alert(foo); } bar();
那么這段代碼呢?
var a = 1; function b() { a = 10; return; function a() {} } b(); alert(a);
第1個例子,也許你會覺得是 "1" ,因為 ! 1
為假,if
里的代碼不會執行。而第2個例子,可能你認為應該是 "10" 。
事實上,結果相反,我們將分別得到 "10" 和 "1" 。
在 JavaScript 中,變量、函數的聲明會被提升到當前函數主體的頂部,而不管這個聲明語句是否出現在了不可到達的地方。
上面的兩段其實等同於:
var foo = 1; function bar() { var foo; if (! foo) { foo = 10; } alert(foo); } bar(); var a = 1; function b() { function a() {} a = 10; return; } b(); alert(a);
需要注意的是,只有變量或函數的聲明被提升了,而賦值語句並沒有。
"eval"
eval
是 JavaScript 的動態特性之一,在運行時, eval 可以將給定的字符串當作代碼語句執行:
<script> var func = <?php echo json_encode($user_send['func']); ?>; eval(func + "()"); function sayHello() {} function sayGoodbye() {} </script>
在代碼中用一組字符串與變量拼出另一串代碼來運行,這看起來吊爆了。
但請在使用 eval 之前考慮下它將帶來的潛在問題:
- 使用了 eval 的代碼可閱讀性很差,你讀到這樣的代碼時很難判斷它究竟要做啥,即使那是你自己幾天前寫的。
- JavaScript 解釋器引擎難以對代碼執行優化。
- 如果 eval 中的字符串包含用戶輸入的數據,這會給攻擊者有機可乘。
- 如果你是有經驗的開發者,大多數情況下你可以使用更高效的函數嵌套(閉包)等來解決問題;如果你沒有足夠的經驗,那更不要使用 eval ,如果你不想你或你的用戶遭受攻擊。
多行字符串
JavaScript 中不能直接書寫多行的字符串,需要在行尾輸入一個反斜杠 \
。
假設我們的項目中有一段這樣的代碼:
var multiStr = "this is a multi-line string, \ and this is the second line. \ yes, the string ends here";
然后做了一些維護和更新:
var multiStr = "this is a multi-line string, \ and this is the second line. \ now i want to insert a line right here, \ yes, the string ends here";
憑肉眼似乎沒看出毛病,但運行時卻得到了一個語法錯誤,這之前你可能已經注意到語法高亮已經失效了。幾經周折,你終於注意到了第2行行尾的那個不起眼的空格。。
變量泄漏
JavaScript 的全局作用域給了我們很多便利,有時我們無需使用 var
來聲明變量。
很多 JavaScript 的入門開發者,喜歡利用這個“便利”。但事實上它是一個陷阱。它很可能讓我們的一些敲打錯誤被隱藏和掩蓋。
function foo() { var type = "first"; if (something) { // 這里假設我們手一抖,把type打成了typo typo = "second"; } return type; }
這段代碼可以讓項目長久地穩定運行,但隨后的某天,我們吃驚地發現,所有的 type 都是 "first" !在找到並修復這個手誤之前,我們以此得到的數據或結論可能都要被廢棄。
直接使用沒有聲明的變量,將自動創建一個全局變量,濫用會導致全局變量污染,或者讓類似上面這樣的手誤逍遙法外。合理的聲明變量,並利用作用域鏈與閉包,是 JavaScript 解決很多問題的思路。
"arguments.callee"
寫一個遞歸函數,我們通常這樣:
function factorial(n) { return n <= 1 ? 1 : factorial(n - 1) * n; } [1, 2, 3, 4, 5].map(factorial);
有時我們不想污染命名空間,需要遞歸調用一個匿名函數,怎么辦?
[1, 2, 3, 4, 5].map(function(n) { return n <= 1 ? 1 : /* what goes here? */ (n - 1) * n; });
還好我們有 arguments.callee
:
[1, 2, 3, 4, 5].map(function(n) { return n <= 1 ? 1 : arguments.callee(n - 1) * n; });
但是同樣不推薦使用 arguments.callee :
- 訪問 arguments.callee 的開銷是昂貴的。
- 使用它將導致 JavaScript 解釋器難以執行優化。
- 從 ECMAScript 3 開始,已經支持命名函數表達式。
命名函數表達式:
[1, 2, 3, 4, 5].map(function factorial(n) { return n <= 1 ? 1 : factorial(n - 1) * n; });
注意,這里的函數 factorial()
並不是函數聲明,而是命名函數表達式,factorial
所處的作用域是其函數本身的作用域(與參數 n
屬同一個作用域),而不是當前的全局作用域。但是,在 IE8 及以下瀏覽器中,情況則不同,它將屬於全局作用域。
避開陷阱
JavaScript 有這么多的語法陷阱,如何規避,並保證我們的代碼質量呢?后面再談。
這篇文章也發表在我的個人網站上:http://wangshenwei.com/article/javascript-syntax-trap