輕鬆理解Javascript變數的相關問題,javascript相關問題
前言
再說本文的內容之前,我們先回溯到1995年,當Brendan Eich在設計第一版JavaScript時,他搞錯了許多東西,當然這也包括曾屬於語言本身的一部分,例如Date對象,對象相乘被自動轉換為NaN等。然而現在回過頭看,語言最重要的部分都是設計合理的:對象、原型、具有詞法範圍的一等函數、預設情況下的可變性等。語言的骨架非常優秀,甚至超越了人們對它的初步印象。
話說回來,正是Brendan當初的設計錯誤才誕生了今天這篇文章。我們這次關注的目標非常小,在你使用這門語言多年後可能根本不會注意到這個問題,但是它又如此重要,因為我們可能會誤認為這個錯誤就是語言設計中的“the good parts”(譯者註:請參考《JavaScript語言精粹》一書中附錄A:毒瘤中有關範圍的描述)。
今天我們一定要把這些與變數有關的問題拿下。
問題 #1:JS沒有塊級範圍
請看這樣一條規則: 在JS函數中的var聲明,其 範圍 是函數體的全部 。乍一聽沒什麼問題,但是如果碰到以下兩種情況就不會得到令人滿意的結果。
其一,在代碼塊內聲明的變數,其範圍是整個函數範圍而不是塊級範圍。
你之前可能沒有關注到這一點,但我擔心這個問題確實是你不能夠輕易忽視的。我們一起重現一下由這個問題引發的bug。
假如你現在的代碼使用了一個變數t:
function runTowerExperiment(tower, startTime) { var t = startTime; tower.on("tick", function () { ... 使用了變數t的代碼 ... }); ... 更多代碼 ... }
到目前為止,一切都很順利。現在你想添加測量保齡球速度的功能,所以你在回呼函數內部添加了一個簡單的if語句。
function runTowerExperiment(tower, startTime) { var t = startTime; tower.on("tick", function () { ... 使用了變數t的代碼 ... if (bowlingBall.altitude() <= 0) { var t = readTachymeter(); ... } }); ... 更多代碼 ... }
哦,親愛的,之前那段“使用了變數t的代碼”運行良好,現在你無意中添加了第二個變數t,這裡的t指向的是一個新的內部變數t而不是原來的外部變數。
JavaScript中var聲明的範圍像是Photoshop中的油漆桶工具,從聲明處開始向前後兩個方向擴散,直到觸及函數邊界才停止擴散。你想啊,這種變數t的範圍甚廣,所以一進入函數就要馬上將它建立出來。這就是所謂的提升(hoisting)。變數提升就好比是,JS引擎用一個很小的代碼起重機將所有var聲明和function函式宣告都舉起到函數內的最高處。
現在看來,提升特性自有它的優點。如果沒有提升的動作,許多在全域範圍範圍內看似合理的完美技術在立即調用函數運算式( IIFE )中通通失效。但在上面示範的這種情況下,提升會引發令人不愉快的bug:所有使用變數t進行的計算最終的結果都是NaN。這種問題極難定位,尤其是當你的代碼量遠超上面這個玩具一般的樣本,你會發狂到崩潰。
在原有代碼塊之前添加新的代碼塊會導致詭異的錯誤,這時候我就會想,到底是誰的問題,我的還是系統的?我們可不希望自己搞砸了系統。
而這個問題與接下來這個問題相比就相形見絀了。
問題 #2:迴圈內變數過度共用
你可以猜一下當執行以下這段代碼時會發生什麼,非常簡單:
var messages = ["嗨!", "我是一個web頁面!", "alert()方法非常有趣!"]; for (var i = 0; i < messages.length; i++) { alert(messages[i]); }
如果你一直跟隨這個專欄的文章,你知道我喜歡在範例程式碼中使用alert()方法。可能你也知道alert()不是一個好的API,它是一個同步方法,所以當彈出一個警告對話方塊時,輸入事件不會觸發,你的JS代碼,包括你的整個UI,直到使用者點擊OK確認之前完全處於暫停狀態。
請不要輕易使用alert()來實現Web頁面中的功能,我之所以在代碼中使用是因為alert()特性使它變成一個非常有教學意義的工具。
而且,如果放棄所有笨重的方法和糟糕的行為就可以做出一隻會說話的貓,何樂而不為呢?
var messages = ["喵!", "我是一隻會說話的貓!", "回調(callback)非常有趣!"]; for (var i = 0; i < messages.length; i++) { setTimeout(function () { cat.say(messages[i]); }, i * 1500); }
然而一定是哪裡不對,這隻會說話的貓並沒有按照預期連說三條訊息,它說了三次“undefined”。
你知道問題出在哪裡嗎?
你能看到樹上的毛毛蟲(bug)嗎?(圖片來源: nevil saveri )
事實上,這個問題的答案是,迴圈本身及三次timeout回調均共用唯一的變數i。當迴圈結束執行時,i的值為3(因為messages.length的值為3),此時回調尚未被觸發。
所以當第一個timeout執行時,調用cat.say(messages[i]) ,此時i的值為3,所以貓咪最終列印出來的是messages[3]的值亦即undefined。
解決這個問題有很多種方法( 這裡有一種 ),但是你想,var範圍規則接連給你添麻煩,如果能在第一時間徹底解決掉這個問題多好啊!
let是更完美的var
JavaScript的設計錯誤(其它語言也有,奈何JavaScript太突出)多半不能被修複。保持向後相容性意味著永不改變JS代碼在Web平台上的行為,即使連標準委員會都無權要求修複JavaScript中自動插入分號這種怪異的特性;瀏覽器廠商也從來不會做出突破性的改變,因為如此一來傷害的是他們的忠實使用者。
所以大約十年以前,Brendan Eich決定修複這個問題,但只有唯一的解決方案。
他添加了一個新的關鍵詞:let。let與var一樣,也可以用來聲明變數,但它有著更好的範圍規則。
它看起來是這樣的:
let t = readTachymeter();
或者這樣的:
for (let i = 0; i < messages.length; i++) { ... }
let與var還是有不同之處的,所以如果你只是在代碼中將var全域搜尋替換為let,一些依賴var聲明的獨特特性(可能你不是故意這樣寫)的代碼可能無法正常運行。但對於絕大多數代碼來說,在ES6的新代碼模式下,你應該停止使用var聲明變數,能使用let就用吧!從現在起,請記住這句口號:“let是更完美的var”。
那到底let和var有什麼不同呢?非常高興你提出這個問題!
這一規則可以協助你捕捉bug,除了NaN錯誤以外,每一個異常都會在當前行拋出。
let聲明的變數擁有塊級範圍。也就是說用let聲明的變數的範圍只是外層塊,而不是整個外層函數。
let聲明仍然保留了提升的特性,但不會盲目提升。在runTowerExperiment這個樣本中,通過將var替換為let可以快速修複問題,如果你處處使用let進行聲明,就不會遇到類似的bug。
let聲明的全域變數不是全域對象的屬性。這就意味著,你不可 以通過window.變數名的方式訪問這些變數。它們只存在於一個不可見的塊的範圍中,這個塊理論上是Web頁面中啟動並執行所有JS代碼的外層塊。
形如for (let x...)的迴圈在每次迭代時都為x建立新的綁定。
這是一個非常微妙的區別,拿我們的會說話的貓的例子來說,如果一個for (let...)迴圈執行多次並且迴圈保持了一個閉包,那麼每個閉包將捕捉一個迴圈變數的不同值作為副本,而不是所有閉包都捕捉迴圈變數的同一個值。
所以在會說話的貓樣本中,也可以通過將var替換為let修複bug。
這種情況適用於現有的三種迴圈方式:for-of、for-in、以及傳統的用分號分隔的類C迴圈。
let聲明的變數直到控制流程到達該變數被定義的程式碼時才會被裝載,所以在到達之前使用該變數會觸發錯誤。舉個例子:
function update() { console.log("目前時間:", t); // 引用錯誤(ReferenceError) ... let t = readTachymeter() }
不可訪問的這段時間變數一直處於範圍中,但是尚未裝載,它們位於臨時死區(Temporal Dead Zone,簡稱TDZ)中。我一直想用科幻小說來類比這個腦洞大開的行話,但是還沒想好怎麼搞。
(脆弱的效能細節:在大多數情況下,查看代碼就可以區分聲明是否已經執行,所以事實上,JavaScript引擎不需要在每次代碼運行時都額外執行 一次變數可訪問檢查來確保變數已經被初始化。然而在閉包內部有時不是透明的,這時JavaScript引擎將會做一個運行時檢查,也就意味著let相對var而言比較慢。)
(脆弱的平行宇宙範圍細節:在一些程式設計語言中,一個變數的範圍始於聲明之處,而非前後覆蓋整個封閉代碼塊。標準委員會曾考慮過將這種範圍準則賦予let關鍵詞,但是一旦使用這種準則,原本提前使用變數的語句會導致引用錯誤(ReferenceError),現在該語句不位於let t的聲明範圍中,根本不會引用此處的變數t,而是引用外層範圍的相應變數。但是這個方法無法與閉包和函數提升很好得結合,所以該提案最終被否決了。)
用let重定義變數會拋出一個語法錯誤(SyntaxError)。
這一條規則也可以協助你檢測瑣碎的小問題。誠然,這亦是var與let的不同之處,當你全域搜尋var替換為let時也會導致let重定義語法錯誤,因為這一規則對全域let變數也有效。
如果你的多個指令碼中都聲明了相同的全域變數,你最好繼續用var聲明這些變數。如果你換用了let,後載入的指令碼都會執行失敗並拋出錯誤。
或者你可以考慮使用ES6內建的模組機制,後面的文章中會詳細講解。
(脆弱的文法細節:let是一個strict 模式下的保留詞。在非strict 模式下,出於向後相容的目的,你仍可以用let命名來聲明變數、函數和參數,雖然你不會犯傻,但是你確實可以編寫var let = 'q';這樣的代碼!不過let let;無論如何都是非法的。)
在那些不同之外,let和var幾乎很相似了。舉個例子,它們都支援使用逗號分隔聲明多重變數,它們也都支援 解構 特性。
注意,class類聲明的行為與var不同而與let一致。如果你載入一段包含同名類的指令碼,後定義的類會拋出重定義錯誤。
const
是的,還有一個新的關鍵詞!
ES6引入的第三個聲明類關鍵詞與let類似:const。
const聲明的變數與let聲明的變數類似,它們的不同之處在於,const聲明的變數只可以在聲明時賦值,不可隨意修改,否則會導致SyntaxError(語法錯誤)。
const MAX_CAT_SIZE_KG = 3000; // 正確 MAX_CAT_SIZE_KG = 5000; // 語法錯誤(SyntaxError) MAX_CAT_SIZE_KG++; // 雖然換了一種方式,但仍然會導致語法錯誤
當然,規範設計的足夠明智,用const聲明變數後必須要賦值,否則也拋出語法錯誤。
const theFairest; // 依然是語法錯誤,你這個倒黴蛋
神秘的代理命名空間
“命名空間是一種絕妙的理念,我們應當多加利用!”——Tim Peters,“這是Python之禪”
嵌套範圍是程式設計語言背後的核心理念之一,這個理念始於大約57年前的 ALGOL,現在回過頭看當時的決定無比正確。
在ES3之前,JavaScript中只有全域範圍和函數範圍。(讓我們忽略with語句吧。)ES3中引入了try-catch語句,意味著語言中誕生一種新的範圍,只用於catch塊中的異常變數。ES5添加了用於嚴格的eval()方法的範圍。ES6添加了塊範圍,for迴圈範圍,新的全域let範圍,模組範圍,以及求參數的預設值時使用的附加範圍。
所有自ES3開始添加的其它範圍非常重要,它們的加入使得JavaScript面向過程與物件導向的特性運行得猶如閉包一樣平穩、精準,當然閉包也可以無縫銜接這些範圍實現各種功能。或許你在閱讀這篇文章之前從未注意到這些範圍規則的存在,如果真的這樣,那這門語言就恰如其分地完成了它的本職工作。
我現在可以使用let和const了嗎?
可以。如果要在Web上使用let和const特性,你需要使用一個諸如 Babel 、 Traceur或 TypeScript 的ES6轉譯器。(Babel和Traceur暫不支援臨時死區特性。)
io.js支援let和const,但是只在strict 模式下編碼可以使用。Node.js同樣支援,但是需要啟用--harmony選項。
九年前 ,Brendan Eich在Firefox中實現了初版的let關鍵詞。這個特性在隨後的標準化進程中徹底地被重新設計了。Shu-yu Guo正在按照新標準對原有實現進行升級,該項目由Jeff Walden和其他人做代碼審查。
總結
以上就是這篇文章的全部內容了,希望本文的內容對大家的學習或者工作能帶來一定的協助,如果有疑問大家可以留言交流。