为什么为 const 变量重新赋值不是个静态错误
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 里相反