你不懂JS:類型與文法 附錄A:與環境混合的JavaScript

官方中文版原文鏈接

感謝社區中各位的大力支持,譯者再次奉上一點點福利:阿里云產品券,享受所有官網優惠,并抽取幸運大獎:點擊這里領取

當你的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.substrString.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的別名)。注意: argumentsarguments.caller都被廢棄了,所以你應當盡可能避免使用它們。這些別名更是這樣 —— 不要使用它們!

注意: 其他的一些微小和罕見的偏差點沒有包含在我們這里的列表中。有必要的話,更多詳細信息可以參見外部的“Annex B”和“Web ECMAScript”文檔。

一般來說,所有這些不同點都很少被使用,所以這些與語言規范有出入的地方不是什么重大問題。只是如果你依賴于其中任何一個的話,要小心

宿主對象

JS中變量的行為有一些廣為人知的例外 —— 當它們是被自動定義,或由持有你代碼的環境(瀏覽器等)創建并提供給JS時 —— 也就是所謂的“宿主對象”(包括objectfunction兩者)。

例如:

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()黑科技。在我的情況中,這可能就夠了。但即便是這種方式也不是沒有風險:

  1. 如果網站的代碼(為了某些瘋狂的理由!)有賴于忽略多個項目的push(..),那么幾年以后當標準的push(..)推出時,那些代碼將會壞掉。
  2. 如果有其他庫被引入,并在這個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布爾字面量。

functionswitch這樣的關鍵字是顯而易見的。像enum之類的未來保留字,雖然它們中的許多(classextends等等)現在都已經實際被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中的保留字(bytelong等等),它們在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幾乎不會與世隔絕地運行。它會運行在混合了第三方庫的環境中運行,而且有時甚至會在不同瀏覽器中不同的引擎/環境中運行。

對這些問題多加注意,會改進你代碼的可靠性和健壯性。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容