翻译 | 像 JavaScript 一样思考

1,985 阅读8分钟
原文链接: www.ituring.com.cn

原文链接: https://davidwalsh.name/thinking-javascript

几天前我在一个专题讨论会讲 JavaScript,午饭时候一个学员跑来向我请教一个 JS 难题,而它确实把我给难住了。他保证说这个问题是偶然间遇到的,而我对此表示怀疑,因为这极有可能是一道有意而为之的烧脑题。

我得承认,最初的几次分析,我都搞错了,不得不借助解析器来运行代码,然后查阅规范(以及向一位大牛请教)来搞清楚到底发生了什么。既然我在这个过程中学到一些东西,我决定分享给大家。

我并不是教你有意写(但愿也不会读到)这样的代码,但是能够按照 JavaScript 自身的原理来进行思考可以让你写出更好的代码。

背景介绍

把我难住的是这么一个问题:为什么第一行代码可以正常运行(编译且运行)而第二行却报错了呢?

[[]][0]++;

[]++;

照理说 [[]][0][] 应该是等价的,所以二者要么都可以正常运行,要么都会失败。

思忖片刻之后,我的第一反应是二者都会报错,但是报错的原因不尽相同。在这里我犯了几点错误。实际上,第一行代码是合法的代码(即便看起来很傻)。

尽管我试图按照 JavaScript 的运行方式来思考,还是犯错了。很不幸,我把一些事情给搞混了。无论一个人的知识多么渊博,还是会轻易发现自己还有很多不知道的地方的。

这正是我试图让人们承认“你并不懂 JS ”的原因;没有人会对一门像 JS 这样复杂的编程语言完全精通。我们会先学会一部分,再学会一部分,然后一直学下去。这是一个会一直持续下去、没有终点的过程。

我犯的错误

首先我观察到这里用到了两个 ++ ,我的第一反应是二者都会运行失败,因为一元后置操作符 ++,比如在 x++ 中,几乎等同于 x = x + 1 。这意味着 x (无论是什么值)必须是一个可以出现在赋值运算符 = 左侧的合法的东东。

实际上呢,上面这段论述是对的,这一点我说的没问题,但是原因错了。

我出错的地方在于,我认为 x++ 相当于 x = x + 1;这样的话,[]++ 就相当于 [] = [] + 1,而这是不正确的。然而尽管这看起来很诡异,但实际上却是没有问题的。在 ES6 中,[] = .. 是数组解构赋值表达式,而这是合法的。

x++ 看作是 x = x + 1 具有误导性,在思维上偷懒了,也难怪让我误入歧途。

另外,我还认为第一行代码是完全错误的。我的思路是,[[]] 构成了一个数组(外层的 [ ]),内层的 [] 相当于属性访问操作,这样的话,它会被转化为字符串(""),结果就是 [""]。而这是没有任何意义的。我不知道我的思维为什么会如此混乱。

当然了,要让外层的 [ ] 作为被访问的数组,需要采用 x[[]] 的形式,其中 x 是被访问的对象,而不仅仅是 [[]] 自身。不管怎样,思路完全错了。犯傻了!

思路修正

让我们从最简单的开始修正。为什么 []++ 是非法的呢?

要获得正确的答案,我们应该求助于这方面的权威官方资料——规范

按照规范里的说法,x++ 中的 ++ 属于“Update Expression”的一种,称为“Postfix Increment Operator”。它要求 x 部分是合法的 “Left-Hand Side Expression” —— 简单来说,就是能够出现在 = 的左边的表达式。实际上,更准确的说法是赋值操作的合法对象。

查看一下赋值操作对象的合法表达式列表,我们可以在诸多表达式中看到“Primary Expression”和“Member Expression”这两种。

仔细看一下“基本类型表达式(Primary Expression)”,你会发现“数组字面量(Array Literal)” (比如我们前面提到的 [ ]!)是合法的,至少从语法的角度来说是这样的。

所以呢,等等![] 可以是一个合法的左侧表达式,那么就可以与 ++ 一起出现了。嗯!既然如此为什么 []++ 还会报错呢?

其实你忽略的一点——也是我之前所忽略的——是,报的错误根本不是语法错误(SyntaxError)!这是一个运行时错误——ReferenceError

有时候人们会问我另外一个令人困惑的——但与此关联紧密的—— JS 中的现象,即代码语法完全合法(但是会在运行时报错):

2 = 3;

很显然,一个数字字面量不应该被赋值。这毫无意义。

但是这并不是非法的语法,只是不合理的运行时逻辑。

那么规范的哪一部分规定使得 2 = 3 运行失败呢?而导致 2 = 3 运行失败的原因就是导致 []++ 失败的原因。

上面的两个操作都用到了一个抽象的算法,在规范中称为 "PutValue"。该算法的第3步是这么说的:

If Type(V) is not Reference, throw a ReferenceError exception.

Reference规范中规定的一种特殊的类型,指代任何类型的、可以标记内存中的一块儿区域的、可以为此赋值的表达式。换句话说,只有 Reference 类型的变量可以作为合法的赋值对象。

很显然,2[] 都不属于 Reference 类型,这就是为什么会在运行时出现 ReferenceError;它们都不是合法的赋值对象呀!

但是...

放心吧,我还没把第一行代码给忘了,这行代码是可以运行的。要记得我先前的思路有问题,所以下面需要作出一些修正。

[[]] 本身根本不属于什么数组访问。它只是一个数组恰巧包含了另外一个数组作为它的唯一元素而已。诺,就像下面这样:

var a = [];
var b = [a];

b;  // [[]]

怎么样?

那么,[[]][0] 是个什么东西呢?让我们再次用临时变量进行分解:

var a = [];
var b = [a];

var c = b[0];
c;  // [] -- 又名: `a`!

所以之前的结论是正确的,[[]][0] 相当于 [] 本身。

现在回到最初的问题:为什么第一行代码可以运行而第二行却不能呢?

正如我们早先看到的那样,“Update Expression” 要求提供 “LeftHandSideExpression”。其中一个合法的表达式就是“ Member Expression”,比如 x[0] 中的 [0] 就属于成员表达式。

看起来是不是有点眼熟?对了,[[]][0] 就是一个成员表达式。

所以呢,就语法来说,[[]][0]++ 本身是合法的。

且慢!

如果 [] 不是 Reference 类型的话,[[]][0] —— 它的求值结果是 [] —— 怎么能被看做是 Reference 类型而 PutValue(..) 没有报错呢?

这就是让人觉得蹊跷的地方。这里我要感谢我的好朋友 Allen-Wirfs Brock —— JS 规范的前编辑团队成员,是他让我理清了头绪。

成员表达式的求值结果并非结果本身([]),而是对该值的一个引用(Reference)——可以参考这里的第8步。因此实际上,诸如 [0] 的访问返回的是对外部数组的第 0 个位置的引用,而非位于该位置的值本身。

这就是为什么 [[]][0] 可以作为合法的左侧表达式原因:归根结底这货是个引用类型值(Reference)啊!

因此,真是情况是,++ 确实将值进行了更新,这一点我们可以分步演示:

var a = [[]];
a[0]++;

a;  // [1]

成员表达式 a[0] 返回的是数组 [],而数学表达式 ++ 会将其强制转化为一个基本类型的数字(先转成 "" 再转成 0)。++ 操作会将该值增加到 1 并赋值给 a[0]a[0]++ 相当于 a[0] = a[0] + 1

一个小小的提醒:如果在浏览器的控制台运行 [[]][0]++ 的话返回的是 0 而不是 1[0] 。这是怎么回事呢?

原因在于 ++ 返回的是“初始值”(好吧,是经过转化后的值,参考这里的第2步与第5步),而不是经过更新后的值。所以 0 被返回给控制台,而 1 通过引用(Reference)放到了数组里。

当然了,如果没有像我们上面做的那样将外层数组赋值给一个变量进行引用的话,这种更新是没有任何意义的,因为这个值是无法获取的。但是它的的确确被更新了。嗯,Reference,棒棒哒!

复盘

我不知道你会因为这些细微的差异而更加喜欢 JS 呢还是倍感受挫。但是对我来说,上面的这番努力让我更加敬重这门语言,抖擞精神向着更深处进发。我认为任何一门编程语言都会存在一些边边角角的特性,有些大受欢迎,有些却让人抓狂。

不管你站在哪一边,有一点是没有争议的,那就是无论选择何种工具,按照工具自身的原理来进行思考,会让你用起来更加得心应手。像 JavaScript 一样思考,祝开心!