const 和 let 的唯一區別就是用 const 聲明的變量不能被重新賦值(只讀變量),比如像下面這樣就會報錯:
const foo = 1 foo = 2 // TypeError: Assignment to constant variable.
注:本文不會使用“常量”這個術語,因為我覺的這個術語容易有歧義:有些人把數字、字符串等這些不可改變的字面量稱為常量,也有人把一些只讀屬性稱為常量,比如 Math.PI,還有人把 ES6 里用 const 聲明的變量稱為常量。不過一般來說,這點歧義不是個事。
但遺憾的是,這個錯誤不是個靜態錯誤(static error),而是個運行時錯誤(runtime error)。靜態錯誤,也被稱為解析錯誤(parsing error),因為是在解析的時候報的錯,其實規范里的正統叫法叫提前錯誤(early error),有時候也能看到對應的的叫法 late error,但其實規范里的正統叫法只有 runtime error。考慮到一般人完全不知道 early error 是什么,所以本文采用靜態錯誤這個術語。
錯誤當然是越早知道越好,所以靜態錯誤一定比運行時錯誤好,比如下面這種代碼:
const foo = 1 /* 這里有很多行代碼 */ if (this.isInProduction) { // 只在生產環境中執行的代碼 /* 這里有很多行代碼 */ foo = 2 // 寫這行代碼的人忘了 foo 是用 const 聲明的了 } alert(foo) // 開發環境彈出 1,線上環境報錯,悲劇
假如 foo = 2 是個靜態錯誤,這個代碼在開發環境就直接報錯了,即便 foo = 2 沒有被執行到。
那為什么 ES6 沒有把這個錯誤設計為靜態錯誤呢?其實在 11 年 const 剛剛進入 ES6 草案時,在嚴格模式下為 const 變量重新賦值就是個靜態錯誤(錯誤類型為 SyntaxError),同時在嚴格模式下也是個運行時錯誤(錯誤類型為 TypeError),而且 Brendan Eich 同年也在 SpiderMonkey 里實現了這一規定(Firefox 7)。到了 12 年,草案改成了不在嚴格模式下也要報那個靜態錯誤,SpiderMonkey 的另外一個工程師 Tom Schuster 在 2014 年 11 月 19 號實現了這一改動(Firefox 36)。
有些同學就問了,為什么同一個錯誤要在兩個階段報?有靜態錯誤的情況下運行時錯誤應該永遠不會觸發才對啊?這是因為某些情況下的錯誤是無法或者說很難靜態檢測出來的,比如:
const foo = 1 let script = "foo = 2" eval(script) // 注定是個運行時錯誤
在 eval 里為 const 變量重新賦值,這個錯誤無論從規范上還是從實現上還是從邏輯上說,都是不可能靜態分析出的,還比如:
function f() { foo = 2 // 可能是個靜態錯誤嗎? } const foo = 1 f()
引擎在解析到 foo = 2 的時候,還不知道 foo 在后面會成為個只讀變量,引擎很難靜態檢測出這樣的錯誤。也許引擎可以實現,比如把前面解析到的函數內部的隱式全局變量的信息存下來,如果后面解析到了一個同名的 const 變量,再報錯,可否?誰知道呢,反正 Firefox 36 當時是檢測不到這樣的錯誤的,把聲明和賦值倒過來就可以了:
還有一種情況是,雖然是先聲明再重新賦值,但聲明和賦值分別處於兩個不同的 <script> 標簽里,如下:
<script> const foo = 1 </script> <script> foo = 2 </script>
引擎在解析第二個 <script> 里的 foo = 2 時可能不會去管 foo 是不是已經被聲明過了(比如你的編輯器在靜態解析這個 tab 里的 js 文件的時候,會去考慮另外一個 tab 里的 js 文件嗎?),Firefox 36 實現的就是這樣的(在 JS 命令行里執行的每一行代碼,都相當於是放在網頁里一個單獨的 <scirpt> 標簽里執行的一樣):
寫在一行就報靜態錯誤(非嚴格模式下也報靜態錯誤),分成兩行就靜默失敗(沒有被靜態分析出錯誤,且運行時錯誤只在嚴格模式下才報)。
關於第三種情況,當 Tom Schuster 在 14 年 11 月 7 號提了 bug 准備實現那個改動的時候,我就預料到會產生這樣的表現,我當天晚上在 IRC 群里找到了 Tom Schuster(evilpie),詢問他有沒有覺的這種表現有點怪,他的回答是說這種怪異只會發生在 JS 的命令行里,不會發生在網頁里。的確,在正常的網頁里,其實很難遇到聲明 const 變量和為它重新賦值出現在兩個 <script> 里的情況。我當時雖然覺得他說的有點道理,但還是隱約覺的哪里有問題,但連我自己也說不出來問題是什么。
然后大概第二天(記不清了),我就發現,原來早在一個月前(2014 年的 10 月份),SpiderMonkey 的另外一個工程師 Shu-yu Guo 就已經在 esdiscuss 提過一個相關的問題(這個帖子的內容是本文的核心)了,而且問題說的非常簡單明了:
1. 關於引擎應該多么努力去檢測這個靜態錯誤,規范說的太籠統,可能導致引擎實現有差異。
的確,下面就是當時規范里關於這個 early error 檢測的描述,規范只說了一句 can be statically determined,並沒有具體說 how,我上面舉的一些 Firefox 36 沒檢測到的情況也證實了這一點。
It is a Syntax Error if LeftHandSideExpression is an IdentifierReference that can be statically determined to always resolve to a declarative environment record binding and the resolved binding is an immutable binding.
2. 靜態錯誤是在任何模式下都報,而運行時錯誤卻是只在嚴格模式下才報。
我看到這里才恍然大悟,這不就是我前一天覺的有問題的點嗎。。。一句代碼都能靜態的分析出有錯了,結果在執行的時候卻沒錯?這說不通啊,沒天理了。
ES6 的編輯 Allen Wirfs-Brock 在帖子二樓針對這兩點一一做了回復:
1. 關於第一點,這個是已知的問題了,而且已經建了相關的 bug(網站的 https 證書過期了;里面還舉了另外兩個難以靜態檢測的例子),規范會嘗試去詳細闡述 can be statically determined 具體指什么。
2. 關於第二點,這個是規范的 bug,不是故意這樣設計的,bug 原因是因為在 ES6 里,為一個 const 變量重新賦值的運行時錯誤和 ES5 里嚴格模式下為一個函數表達式的函數名重新賦值的運行時錯誤是在同一個內部方法(SetMutableBinding)里拋出的:
(function foo() { "use strict" foo = 1 })()
因為后者是只在嚴格模式下報錯的,所以前者也繼承了這一表現,這是個 bug,這兩種錯誤應該分開。
其實 2 樓的回復已經解決了樓主的疑問,這個帖子原本要討論的東西已經有結論了,結論就是:不管什么模式都報靜態錯誤(規范會完善具體的靜態檢測規則);不管什么模式都報運行時錯誤(所有逃過靜態檢測的錯誤都會在這里被捕獲)。很完美,不是嗎。
然而這時,V8 的工程師 Erik Arvidsson 在三樓跳出來說:帶有預解析器的的引擎要實現這個靜態檢測難度很大,規范要不更強制一點,要不干脆刪掉這個要求,模棱兩口可能導致各引擎的實現不統一。
然后 V8 的另外一個工程師 Andreas Rossberg 也發帖說了一些看法,我總結一下他說的:
1. 報這個靜態錯誤需要有完整的 AST,而 V8 的預解析器目前做不到這一點
2. 非要讓引擎實現這個可能引起很大的性能問題,而且可能很難優化
3. 這種錯誤不是特別常見,非讓引擎處理性價比不高,還是交給 lint 工具去做吧
4. 一個同樣很難靜態檢測出的錯誤 - 嚴格模式下為不存在的變量賦值("use strict"; foo = 1),就是個運行時錯誤,這個錯誤不應該搞特殊
經過這個帖子的討論后,在一個月后也就是 2014 年 11 月 18 號的 TC39 會議上,TC39 決定把為 const 變量重新賦值的靜態錯誤刪去,只留下運行時錯誤(任何模式)。會議記錄里寫着刪掉的原因是“引擎實現有難度”和“哪些情況下為 const 變量重新賦值應該被靜態檢測出來沒有達成共識”。
然后我又跑到 SpiderMonkey 的 IRC 群里告訴他們:規范改了,你們前兩天實現的靜態錯誤應該去掉了,然后招來一群 SpiderMonkey 的人吐槽規范太不穩定了。
關於 let/const,目前網上較為推崇的一種代碼風格是全用 const,除非這個變量要被重新賦值,才改成 let。ESLint 有個 prefer-const 規則可以強制你做到這一點,我在此告誡各位,如果你使用這種編碼風格,你的編輯器最好開啟 ESLint 的 no-const-assign 的規則,否則我不確定這么用 const 能給你帶來多大的好處,但我知道有可能讓你遭遇文章開頭的那種線上 bug。
額外小竅門:如何判斷某個錯誤是靜態錯誤還是運行時錯誤
絕大多數情況下,SyntaxError 類型的錯誤就是靜態錯誤,而其他類型的錯誤就是運行時錯誤,但也有特例,比如:
1 = 2 // 靜態錯誤,但是個 ReferenceError還有:
/(/ // 在 V8 里是個運行時錯誤,但是個 SyntaxError,SpiderMonkey 里是個靜態錯誤怎么知道的?我通常是在瀏覽器開發者工具的控制台里寫 alert();然后后面跟上測試代碼,比如:
alert();1 = 2 // 不會彈出 alert,證明 1 = 2 是個靜態錯誤和:
alert();/(/ // Chrome 里會彈出 alert,證明 /(/ 是個運行時錯誤,Firefox 里相反