感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取
當你的JS代碼在真實世界中運行時,除了我們在本書中完整探索過的核心語言機制以外,它還有好幾種不同的行為方式。如果JS純粹地運行在一個引擎中,那么它就會按照語言規范非黑即白地動作,是完全可以預測的。但是JS很可能總是運行在一個宿主環境的上下文中,這將會給你的代碼帶來某種程度的不可預測性。
例如,當你的代碼與源自于其他地方的代碼并肩運行時,或者當你的代碼在不同種類的JS引擎(不只是瀏覽器)中運行時,有些事情的行為就可能不同。
我們將簡要地探索這些問題中的一些。
Annex B (ECMAScript)
一個鮮為人知的事實是,這門語言的官方名稱是ECMAScript(意指管理它的ECMA標準本體)。那么“JavaScript”是什么?JavaScript是這門語言常見的商業名稱,當然,更恰當地說,JavaScript基本上是語言規范的瀏覽器實現。
官方的ECMAScript語言規范包含“Annex B”,它是為了瀏覽器中JS的兼容性,討論那些與官方語言規范有偏差的特別部分。
考慮這些偏差部分的恰當方法是,它們僅在你的代碼運行在瀏覽器中時才是確實會出現/合法的。如果你的代碼總是運行在瀏覽器中,那你就不會看到明顯的不同。如果不是(比如它可以運行在node.js、Rhino中,等等),或者你不確定,那么就要小心對待。
兼容性上的主要不同是:
- 八進制數字字面量是允許的,比如在非
strict mode
下的0123
(小數83
)。 -
window.escape(..)
和window.unescape(..)
允許你使用%
分割的十六進制轉義序列來轉義或非轉義字符串。例如:window.escape( "?foo=97%&bar=3%" )
產生"%3Ffoo%3D97%25%26bar%3D3%25"
-
String.prototype.substr
與String.prototype.substring
十分相似,除了第二個參數是length
(要包含的字符數),而非結束(不含)的索引。
Web ECMAScript
Web ECMAScript語言規范(http://javascript.spec.whatwg.org/)涵蓋了官方ECMAScript語言規范與當前瀏覽器中JavaScript實現之間的不同。
換言之,這些項目是瀏覽器的“必須品”(為了相互兼容),但是(在本書編寫時)沒有列在官方語言規范的“Annex B”部分是:
- ``是合法的單行注釋分割符。
-
String.prototype
擁有返回HTML格式化字符串的附加方法:anchor(..)
、big(..)
、blink(..)
、bold(..)
、fixed(..)
、fontcolor(..)
、fontsize(..)
、italics(..)
、link(..)
、small(..)
、strike(..)
、和sub(..)
。注意: 它們在實際應用中非常罕見,而且一般來說不鼓勵使用,而是用其他內建DOM API或用戶定義的工具取代。 -
RegExp
擴展:RegExp.$1
..RegExp.$9
(匹配組)和RegExp.lastMatch
/RegExp["$&"]
(最近的匹配)。 -
Function.prototype
附加功能:Function.prototype.arguments
(內部arguments
對象的別名)和Function.caller
(內部arguments.caller
的別名)。注意:arguments
和arguments.caller
都被廢棄了,所以你應當盡可能避免使用它們。這些別名更是這樣 —— 不要使用它們!
注意: 其他的一些微小和罕見的偏差點沒有包含在我們這里的列表中。有必要的話,更多詳細信息可以參見外部的“Annex B”和“Web ECMAScript”文檔。
一般來說,所有這些不同點都很少被使用,所以這些與語言規范有出入的地方不是什么重大問題。只是如果你依賴于其中任何一個的話,要小心。
宿主對象
JS中變量的行為有一些廣為人知的例外 —— 當它們是被自動定義,或由持有你代碼的環境(瀏覽器等)創建并提供給JS時 —— 也就是所謂的“宿主對象”(包括object
和function
兩者)。
例如:
var a = document.createElement( "div" );
typeof a; // "object" -- 意料之中的
Object.prototype.toString.call( a ); // "[object HTMLDivElement]"
a.tagName; // "DIV"
a
不僅是一個object
,而且是一個特殊的宿主對象,因為它是一個DOM元素。它擁有一個不同的內部[[Class]]
值("HTMLDivElement"
),而且帶有預定義的(而且通常是不可更改的)屬性。
另一個已經在第四章的“Falsy對象”一節中探討過的同樣的怪異之處是:存在這樣一些對象,當被強制轉換為boolean
時,它們將(令人糊涂地)被轉換為false
而不是預期的true
。
另一些需要小心的宿主對象行為包括:
- 不能訪問像
toString()
這樣的object
內建方法 - 不可覆蓋
- 擁有特定的預定義只讀屬性
- 擁有一些
this
不可被重載為其他對象的方法 - 其他……
為了使我們的JS代碼與它外圍的環境一起工作,宿主對象至關重要。但在你與宿主對象交互時是要特別注意,并且在推測它的行為時要小心,因為它們經常與普通的JSobject
不符。
一個盡人皆知的你可能經常與之交互的宿主對象的例子,就是console
對象和他的各種函數(log(..)
、error(..)
等等)。console
對象是由 宿主環境 特別提供的,所以你的代碼可以與之互動來進行各種開發相關的輸出任務。
在瀏覽器中,console
與開發者工具控制臺的顯示相勾連,因此在node.js和其他服務器端JS環境中,console
一般連接著JavaScript環境系統進程的標準輸出流(stdout
)和標準錯誤流(stderr
)。
全局DOM變量
你可能知道,在全局作用域中聲明變量(用或者不用var
)不僅會創建一個全局變量,還會創建它的鏡像:在global
對象(瀏覽器中的window
)上的同名屬性。
但少為人知的是,(由于瀏覽器的遺留行為)使用id
屬性創建DOM元素會創建同名的全局變量。例如:
<div id="foo"></div>
和:
if (typeof foo == "undefined") {
foo = 42; // 永遠不會運行
}
console.log( foo ); // HTML元素
你可能臆測只有JS代碼會創建這樣的變量,并習慣于在這樣假定的前提下進行全局變量檢測(使用typeof
或者.. in window
檢查),但是如你所見,你的宿主HTML頁面的內容也會創建它們,如果你不小心它們就可以輕而易舉地擺脫你的存在性檢查。
這就是另一個你為什么應該盡全力避免使用全局變量的原因,如果你不得不這樣做,那就使用不太可能沖突的變量名。但是你還是需要確認它不會與HTML的內容以及其他的代碼相沖突。
原生原型
最廣為人知的,經典的JavaScript 最佳實踐 智慧之一是:永遠不要擴展原生原型。
當你將方法或屬性添加到Array.prototype
時,無論你想出什么樣的(還)不存在于Array.prototype
上名稱,如果它是有用的、設計良好的、并且被恰當命名的新增功能,那么它就有很大的可能性被最終加入語言規范 —— 這種情況下你的擴展就處于沖突之中。
這里有一個真實地發生在我身上的例子,很好地展示了這一點。
那時我正在為其他網站建造一個可嵌入的控件,而且我的控件依賴于JQuery(雖然任何框架都很可能遭受這樣的坑)。它幾乎可以在每一個網站上工作,但是我們碰到了一個它會完全崩潰的網站。
經過差不多一周的分析/調試之后,我發現這個出問題的網站有這樣一段代碼,埋藏在它的一個遺留文件的深處:
// Netscape 4 沒有 Array.push
Array.prototype.push = function(item) {
this[this.length] = item;
};
除了那瘋狂的注釋(誰還會關心Netscape 4!?),它看起來很合理,對吧?
問題是,在這段 Netscape 4 時代的代碼被編寫之后的某個時點,Array.prototype.push
被加入了語言規范,但是被加入的東西與這段代碼是不兼容的。標準的push(..)
允許一次加入多個項目,而這個黑進來的東西會忽略后續項目。
基本上所有的JS框架都有這樣的代碼 —— 依賴于帶有多個元素的push(..)
。在我的例子中,我在圍繞著一個完全被毀壞的CSS選擇器引擎進行編碼。但是可以料想到還有其他十幾處可疑的地方。
一開始編寫這個push(..)
黑科技的開發者稱它為push
,這種直覺很正確,但是沒有預見到添加多個元素。當然他們的初衷是好的,但是也埋下了一個地雷,當我差不多在10年之后路過時才不知不覺地踩上。
這里要吸取幾個教訓。
第一,不要擴展原生類型,除非你絕對確信你的代碼將是運行在那個環境中的唯一代碼。如果你不能100%確信,那么擴展原生類型就是危險的。你必須掂量掂量風險。
其次,不要無條件地定義擴展(因為你可能意外地覆蓋原生類型)。就這個特定的例子,用代碼說話就是:
if (!Array.prototype.push) {
// Netscape 4 沒有 Array.push
Array.prototype.push = function(item) {
this[this.length] = item;
};
}
if
守護語句將會僅在JS環境中不存在push()
時才定義那個push()
黑科技。在我的情況中,這可能就夠了。但即便是這種方式也不是沒有風險:
- 如果網站的代碼(為了某些瘋狂的理由!)有賴于忽略多個項目的
push(..)
,那么幾年以后當標準的push(..)
推出時,那些代碼將會壞掉。 - 如果有其他庫被引入,并在這個
if
守護之前就黑進了push(..)
,而且還是以一種不兼容的方式,那么它就在那一刻毀壞了這個網站。
這里的重點,坦白地講,是一個沒有得到JS開發者們足夠重視的有趣問題:如果在你代碼運行的環境中,你的代碼不是唯一的存在,那么 你應該依賴于任何原生的內建行為嗎?
嚴格的答案是 不,但這非常不切實際。你的代碼通常不會為所有它依賴的內建行為重新定義它自己的、不可接觸的私有版本。即便你 能,那也是相當的浪費。
那么,你應當為內建行為進行特性測試,以及為了驗證它能如你預期的那樣工作而進行兼容性測試嗎?但如果測試失敗了 —— 你的代碼應當拒絕運行嗎?
// 不信任 Array.prototype.push
(function(){
if (Array.prototype.push) {
var a = [];
a.push(1,2);
if (a[0] === 1 && a[1] === 2) {
// 測試通過,可以安全使用!
return;
}
}
throw Error(
"Array#push() is missing/broken!"
);
})();
理論上,這貌似有些道理,但是為每一個內建方法設計測試還是非常不切實際。
那么,我們應當怎么做?我們應當 信賴但驗證(特性測試和兼容性測試)每一件事嗎?我們應當假設既存的東西是符合規范的并讓(由他人)造成的破壞任意傳播嗎?
沒有太好的答案。可以觀察到的唯一事實是,擴展原生原型是這些東西咬到你的唯一方式。
如果你不這么做,而且在你的應用程序中也沒有其他人這么做,那么你就是安全的。否則,你就應當多多少少建立一些懷疑的、悲觀的機制、并對可能的破壞做好準備。
在所有已知環境中,為你的代碼準備一整套單元/回歸測試是發現一些前述問題的方法,但是它不會對這些沖突為你做出任何實際的保護。
Shims/Polyfills
人們常說,擴展一個原生類型唯一安全的地方是在一個(不兼容語言規范的)老版本環境中,因為它不太可能再改變了 —— 帶有新語言規范特性的新瀏覽器會取代老版本瀏覽器,而非改良它們。
如果你能預見未來,而且確信未來的標準將是怎樣,比如Array.prototype.foobar
,那么現在就制造你自己的兼容版本來使用就是完全安全的,對吧?
if (!Array.prototype.foobar) {
// 愚蠢,愚蠢
Array.prototype.foobar = function() {
this.push( "foo", "bar" );
};
}
如果已經有了Array.prototype.foobar
的規范,而且規定的行為與這個邏輯等價,那么你定義這樣的代碼段就十分安全,在這種情況下它通常稱為一個“polyfill(填補)”(或者“shim(墊片)”)。
在你的代碼庫中引入這樣的代碼,對給那些沒有更新到最新規范的老版本瀏覽器環境打“補丁”非常 有用。為所有你支持的環境創建可預見的代碼,使用填補是非常好的方法。
提示: ES5-Shim (https://github.com/es-shims/es5-shim) 是一個將項目代碼橋接至ES5基準線的完整的shims/polyfills集合,相似地,ES6-Shim (https://github.com/es-shims/es6-shim) 提供了ES6新增的新API的shim。雖然API可以被填補,但新的語法通常是不能的。要橋接語法的部分,你將還需要使用一個ES6到ES5的轉譯器,比如Traceur (https://github.com/google/traceur-compiler/wiki/GettingStarted)。
如果有一個即將到來的標準,而且關于它叫什么名字和它將如何工作的討論達成了一致,那么為了兼容面向未來的標準提前創建填補,被稱為“prollyfill(probably-fill —— 預填補)”。
真正的坑是某些標準行為不能被(完全)填補/預填補。
在開發者社區中有這樣一種爭論:對于常見的情況一個部分地填補是否是可接受的,或者如果一個填補不能100%地與語言規范兼容是否應當避免它。
許多開發者至少會接受一些常見的部分填補(例如Object.create(..)
),因為沒有被填補的部分是他們不管怎樣都不會用到的。
一些開發者相信,包圍著 polyfill/shim 的if
守護語句應當引入某種形式的一致性測試,在既存的方法缺失或者測試失敗時取代它。這額外的一層兼容性測試有時被用于將“shim”(兼容性測試)與“polyfill”(存在性測試)區別開。
這里的要點是,沒有絕對 正確 的答案。即使是在老版本環境中“安全地”擴展原生類型,也不是100%安全的。在其他人代碼存在的情況下依賴于(可能被擴展過的)原生類型也是一樣。
在這兩種情況下都應當小心地使用防御性的代碼,并在文檔中大量記錄它的風險。
<script>
大多數通過瀏覽器使用的網站/應用程序都將它們的代碼包含在一個以上的文件中,在一個頁面中含有幾個或好幾個分別加載這些文件的<script src=..></script>
元素,甚至幾個內聯的<script> .. </script>
元素也很常見。
但這些分離的文件/代碼段是組成分離的程序,還是綜合為一個JS程序?
(也許令人吃驚)現實是它們在極大程度上,但不是全部,像獨立的JS程序那樣動作。
它們所 共享 的一個東西是一個單獨的global
對象(在瀏覽器中是window
),這意味著多個文件可以將它們的代碼追加到這個共享的名稱空間中,而且它們都是可以交互的。
所以,如果一個script
元素定義了一個全局函數foo()
,當第二個script
運行時,它就可以訪問并調用foo()
,就好像它自己已經定義過了這個函數一樣。
但是全局變量作用域 提升(參見本系列的 作用域與閉包)不會跨越這些界線發生,所以下面的代碼將不能工作(因為foo()
的聲明還沒有被聲明過),無論它們是否是內聯的<script> .. </script>
元素還是外部加載的<script src=..></script>
文件:
<script>foo();</script>
<script>
function foo() { .. }
</script>
但是這兩個都將 可以 工作:
<script>
foo();
function foo() { .. }
</script>
或者:
<script>
function foo() { .. }
</script>
<script>foo();</script>
另外,如果在一個script
元素(內聯或者外部的)中發生了一個錯誤,一個分離的獨立的JS程序將會失敗并停止,但是任何后續的script
都將會(依然在共享的global
中)暢通無阻地運行。
你可以在你的代碼中動態地創建script
元素,并將它們插入到頁面的DOM中,它們之中的代碼基本上將會像從一個分離的文件中普通地加載那樣運行:
var greeting = "Hello World";
var el = document.createElement( "script" );
el.text = "function foo(){ alert( greeting );\
} setTimeout( foo, 1000 );";
document.body.appendChild( el );
注意: 當然,如果你試一下上面的代碼段并將el.src
設置為某些文件的URL,而非將el.text
設置為代碼內容,你就會動態地創建一個外部加載的<script src=..></script>
元素。
內聯代碼塊中的代碼,與在外部文件中的相同的代碼之間的一個不同之處是,在內聯的代碼塊中,字符</script>
的序列不能一起出現,因為(無論它在哪里出現)它將會被翻譯為代碼塊的末尾。所以,小心這樣的代碼:
<script>
var code = "<script>alert( 'Hello World' )</script>";
</script>
它看起來無害,但是在string
字面量中出現的</script>
將會不正常地終結script塊,造成一個錯誤。繞過它最常見的一個方法是:
"</sc" + "ript>";
另外要小心的是,一個外部文件中的代碼將會根據和文件一起被提供(或默認的)的字符集編碼(UTF-8、ISO-8859-8等等)來翻譯,但在內聯在你HTML頁面中的一個script
元素中的相同代碼將會根據這個頁面的(或它默認的)字符集編碼來翻譯。
警告: charset
屬性在內聯script元素中不能工作。
關于內聯script
元素,另一個被廢棄的做法是在內聯代碼的周圍引入HTML風格或X(HT)ML風格的注釋,就像:
<script>
<!--
alert( "Hello" );
//-->
</script>
<script>
<!--//--><![CDATA[//><!--
alert( "World" );
//--><!]]>
</script>
這兩種東西現在完全是不必要的了,所以如果你還在這么做,停下!
注意: 實際上純粹是因為這種老技術,JavaScript才把``(HTML風格的注釋)兩者都被規定為合法的單行注釋分隔符(var x = 2; another valid line comment
)。永遠不要使用它們。
保留字
ES5語言規范在第7.6.1部分中定義了一套“保留字”,它們不能被用作獨立的變量名。技術上講,有四個類別:“關鍵字”,“未來保留字”,null
字面量,以及true
/false
布爾字面量。
像function
和switch
這樣的關鍵字是顯而易見的。像enum
之類的未來保留字,雖然它們中的許多(class
、extends
等等)現在都已經實際被ES6使用了;但還有另外一些像interface
之類的僅在strict模式下的保留字。
StackOverflow用戶“art4theSould”創造性地將這些保留字編成了一首有趣的小詩(http://stackoverflow.com/questions/26255/reserved-keywords-in-javascript/12114140#12114140):
Let this long package float,
Goto private class if short.
While protected with debugger case,
Continue volatile interface.
Instanceof super synchronized throw,
Extends final export throws.Try import double enum?
- False, boolean, abstract function,
Implements typeof transient break!
Void static, default do,
Switch int native new.
Else, delete null public var
In return for const, true, char
…Finally catch byte.
注意: 這首詩包含ES3中的保留字(byte
、long
等等),它們在ES5中不再被保留了。
在ES5以前,這些保留字也不能被用于對象字面量中的屬性名或鍵,但這種限制已經不復存在了。
所以,這是不允許的:
var import = "42";
但這是允許的:
var obj = { import: "42" };
console.log( obj.import );
你應當小心,有些老版本的瀏覽器(主要是老IE)沒有完全地遵循這些規則,所以有些將保留字用作對象屬性名的地方任然會造成問題。小心地測試所有你支持的瀏覽器環境。
實現的限制
JavaScript語言規范沒有在諸如函數參數值的個數,或者字符串字面量的長度上做出隨意的限制,但是由于不同引擎的實現細節,無論如何這些限制是存在的。
例如:
function addAll() {
var sum = 0;
for (var i=0; i < arguments.length; i++) {
sum += arguments[i];
}
return sum;
}
var nums = [];
for (var i=1; i < 100000; i++) {
nums.push(i);
}
addAll( 2, 4, 6 ); // 12
addAll.apply( null, nums ); // 應該是:499950000
在某些JS引擎中,你將會得到正確答案499950000
,但在另一些引擎中(比如Safari 6.x),你會得到一個錯誤:“RangeError: Maximum call stack size exceeded.”
已知存在的其他限制的例子:
- 在字符串字面量(不是一個字符串變量)中允許出現的最大字符個數
- 在一個函數調用的參數值中可以發送的數據的大小(字節數,也稱為棧的大小)
- 在一個函數聲明中的參數數量
- 沒有經過優化的調用棧最大深度(比如,使用遞歸時):從一個函數到另一個函數的調用鏈能有多長
- JS程序可以持續運行并阻塞瀏覽器的秒數
- 變量名的最大長度
- ...
遭遇這些限制不是非常常見,但你應當知道這些限制存在并確實會發生,而且重要的是它們因引擎不同而不同。
復習
我們知道并且可以依賴于這樣的事實:JS語言本身擁有一個標準,而且這個標準可預見地被所有現代瀏覽器/引擎實現了。這是非常好的一件事!
但是JavaScript幾乎不會與世隔絕地運行。它會運行在混合了第三方庫的環境中運行,而且有時甚至會在不同瀏覽器中不同的引擎/環境中運行。
對這些問題多加注意,會改進你代碼的可靠性和健壯性。