第五章:文法

最后更新于:2022-04-02 02:00:18

# 第五章:文法 我们想要解决的最后一个主要话题是JavaScript的语法如何工作(也称为它的文法)。你可能认为你懂得如何编写JS,但是语言文法的各个部分中有太多微妙的地方导致了困惑和误解,所以我们想要深入这些部分并搞清楚一些事情。 注意: 对于读者们来说,“文法(grammar)”一词不像“语法(syntax)”一词那么为人熟知。在许多意义上,它们是相似的词,描述语言如何工作的 *规则*。它们有一些微妙的不同,但是大部分对于我们在这里的讨论无关紧要。JavaScript的文法是一种结构化的方式,来描述语法(操作符,关键字,等等)如何组合在一起形成结构良好,合法的程序。换句话说,抛开文法来讨论语法将会忽略许多重要的细节。所以我们在本章中注目的内容的最准确的描述是 *文法*,尽管语言中的纯语法才是开发者们直接交互的。 ## 语句与表达式 一个很常见的现象是,开发者们假定“语句(statement)”和“表达式(expression)”是大致等价的。但是这里我们需要区分它们俩,因为在我们的JS程序中它们有一些非常重要的区别。 为了描述这种区别,让我们借用一下你可能更熟悉的术语:英语。 一个“句子(sentence)”是一个表达想法的词汇的完整构造。它由一个或多个“短语(phrase)”组成,它们每一个都可以用标点符号或连词(“和”,“或”等等)连接。一个短语本身可以由更小的短语组成。一些短语是不完整的,而且本身没有太多含义,而另一些短语可以自成一句。这些规则总体地称为英语的 *文法*。 JavaScript文法也类似。语句就是句子,表达式就是短语,而操作符就是连词/标点。 JS中的每一个表达式都可以被求值而成为一个单独的,具体的结果值。举例来说: ```source-js var a = 3 * 6; var b = a; b; ``` 在这个代码段中,`3 * 6`是一个表达式(求值得值`18`)。而第二行的`a`也是一个表达式,第三行的`b`也一样。对表达式`a`和`b`求值都会得到在那一时刻存储在这些变量中的值,也就偶然是`18`。 另外,这三行的每一行都是一个包含表达式的语句。`var a = 3 * 6`和`var b = a`称为“声明语句(declaration statments)”因为它们每一个都声明了一个变量(并选择性地给它赋值)。赋值`a = 3 * 6`和`b = a`(除去`var`)被称为赋值表达式(assignment expressions)。 第三行仅仅含有一个表达式`b`,但是它本身也是一个语句(虽然不是非常有趣的一个!)。这一般称为一个“表达式语句(expression statement)”。 ### 语句完成值 一个鲜为人知的事实是,所有语句都有完成值(即使这个值只是`undefined`)。 你要如何做才能看到一个语句的完成值呢? 最明显的答案是把语句敲进你的浏览器开发者控制台,因为当你运行它时,默认地控制台会报告最近一次执行的语句的完成值。 让我们考虑一下`var b = a`。这个语句的完成值是什么? `b = a`赋值表达式给出的结果是被赋予的值(上面的`18`),但是`var`语句本身给出的结果是`undefined`。为什么?因为在语言规范中`var`语句就是这么定义的。如果你在你的控制台中敲入`var a = 42`,你会看到`undefined`被报告而不是`42`。 注意: 技术上讲,事情要比这复杂一些。在ES5语言规范,12.2部分的“变量语句”中,`VariableDeclaration`算法实际上返回了一个值(一个包含被声明变量的名称的`string` —— 诡异吧!?),但是这个值基本上被`VariableStatement`算法吞掉了(除了在`for..in`循环中使用),而这强制产生一个空的(也就是`undefined`)完成值。 事实上,如果你曾在你的控制台上(或者一个JavaScript环境的REPL —— read/evaluate/print/loop工具)做过很多的代码实验的话,你可能看到过许多不同的语句都报告`undefined`,而且你也许从来没理解它是什么和为什么。简单地说,控制台仅仅报告语句的完成值。 但是控制台打印出的完成值并不是我们可以在程序中使用的东西。那么我们该如何捕获完成值呢? 这是个更加复杂的任务。在我们解释 *如何* 之前,让我们先探索一下 *为什么* 你想这样做。 我们需要考虑其他类型的语句的完成值。例如,任何普通的`{ .. }`块儿都有一个完成值,即它所包含的最后一个语句/表达式的完成值。 考虑如下代码: ```source-js var b; if (true) { b = 4 + 38; } ``` 如果你将这段代码敲入你的控制台/REPL,你可能会看到它报告`42`,因为`42`是`if`块儿的完成值,它取自`if`的最后一个复制表达式语句`b = 4 + 38`。 换句话说,一个块儿的完成值就像 *隐含地返回* 块儿中最后一个语句的值。 注意: 这在概念上与CoffeeScript这样的语言很类似,它们隐含地从`function`中`return`值,这些值与函数中最后一个语句的值是相同的。 但这里有一个明显的问题。这样的代码是不工作的: ```source-js var a, b; a = if (true) { b = 4 + 38; }; ``` 我们不能以任何简单的语法/文法来捕获一个语句的完成值并将它赋值给另一个变量(至少是还不能!)。 那么,我们能做什么? 警告: 仅用于演示的目的 —— 不要实际地在你的真实代码中做如下内容! 我们可以使用臭名昭著的`eval(..)`(有时读成“evil”)函数来捕获这个完成值。 ```source-js var a, b; a = eval( "if (true) { b = 4 + 38; }" ); a; // 42 ``` 啊呀呀。这太难看了。但是这好用!而且它展示了语句的完成值是一个真实的东西,不仅仅是在控制台中,还可以在我们的程序中被捕获。 有一个称为“do表达式”的ES7提案。这是它可能工作的方式: ```source-js var a, b; a = do { if (true) { b = 4 + 38; } }; a; // 42 ``` `do { .. }`表达式执行一个块儿(其中有一个或多个语句),这个块儿中的最后一个语句的完成值将成为`do`表达式的完成值,它可以像展示的那样被赋值给`a`。 这里的大意是能够将语句作为表达式对待 —— 他们可以出现在其他语句内部 —— 而不必将它们包装在一个内联的函数表达式中,并实施一个明确的`return ..`。 到目前为止,语句的完成值不过是一些琐碎的事情。不过随着JS的进化它们的重要性可能会进一步提高,而且很有希望的是`do { .. }`表达式将会降低使用`eval(..)`这样的东西的冲动。 警告: 重复我刚才的训诫:避开`eval(..)`。真的。更多解释参见本系列的 *作用域与闭包* 一书。 ### 表达式副作用 大多数表达式没有副作用。例如: ```source-js var a = 2; var b = a + 3; ``` 表达式`a + 3`本身并没有副作用,例如改变`a`。它有一个结果,就是`5`,而且这个结果在语句`b = a + 3`中被赋值给`b`。 一个最常见的(可能)带有副作用的表达式的例子是函数调用表达式: ```source-js function foo() { a = a + 1; } var a = 1; foo(); // 结果:`undefined`,副作用:改变 `a` ``` 还有其他的副作用表达式。例如: ```source-js var a = 42; var b = a++; ``` 表达式`a++`有两个分离的行为。*首先*,它返回`a`的当前值,也就是`42`(然后它被赋值给`b`)。但 *接下来*,它改变`a`本身的值,将它增加1。 ```source-js var a = 42; var b = a++; a; // 43 b; // 42 ``` 许多开发者错误的认为`b`和`a`一样拥有值`43`。这种困惑源自没有完全考虑`++`操作符的副作用在 *什么时候* 发生。 `++`递增操作符和`--`递减操作符都是一元操作符(见第四章),它们既可以用于后缀(“后面”)位置也可用于前缀(“前面”)位置。 ```source-js var a = 42; a++; // 42 a; // 43 ++a; // 44 a; // 44 ``` 当`++`像`++a`这样用于前缀位置时,它的副作用(递增`a`)发生在值从表达式中返回 *之前*,而不是`a++`那样发生在 *之后*。 注意: 你认为`++a++`是一个合法的语法吗?如果你试一下,你将会得到一个`ReferenceError`错误,但为什么?因为有副作用的操作符 要求一个变量引用 来作为它们副作用的目标。对于`++a++`来说,`a++`这部分会首先被求值(因为操作符优先级 —— 参见下面的讨论),它会给出`a`在递增 *之前* 的值。但然后它试着对`++42`求值,这将(如果你试一下)会给出相同的`ReferenceError`错误,因为`++`不能直接在`42`这样的值上施加副作用。 有时它会被错误地认为,你可以通过将`a++`包进一个`( )`中来封装它的 *后* 副作用,比如: ```source-js var a = 42; var b = (a++); a; // 43 b; // 42 ``` 不幸的是,`( )`本身不会像我们希望的那样,定义一个新的被包装的表达式,而它会在`a++`表达式的 *后副作用* 之 *后* 求值。事实上,就算它能,`a++`也会首先返回`42`,而且除非你有另一个表达式在`++`的副作用之后对`a`再次求值,你也不会从这个表达式中得到`43`,于是`b`不会被赋值为`43`。 虽然,有另一种选择:`,`语句序列逗号操作符。这个操作符允许你将多个独立的表达式语句连成一个单独的语句: ```source-js var a = 42, b; b = ( a++, a ); a; // 43 b; // 43 ``` 注意: `a++, a`周围的`( .. )`是必需的。其原因的操作符优先级,我们将在本章后面讨论。 表达式`a++, a`意味着第二个`a`语句表达式会在第一个`a++`语句表达式的 *后副作用* 之 *后* 进行求值,这表明它为`b`的赋值返回`43`。 另一个副作用操作符的例子是`delete`。正如我们在第二章中展示的,`delete`用于从一个`object`或一个`array`值槽中移除一个属性。但它经常作为一个独立语句被调用: ```source-js var obj = { a: 42 }; obj.a; // 42 delete obj.a; // true obj.a; // undefined ``` 如果被请求的操作是合法/可允许的,`delete`操作符的结果值为`true`,否则结果为`false`。但是这个操作符的副作用是它移除了属性(或数组值槽)。 注意: 我们说合法/可允许是什么意思?不存在的属性,或存在且可配置的属性(见本系列 *this与对象原型* 的第三章)将会从`delete`操作符中返回`true`。否则,其结果将是`false`或者一个错误。 副作用操作符的最后一个例子,可能既是明显的也是不明显的,是`=`赋值操作符。 考虑如下代码: ```source-js var a; a = 42; // 42 a; // 42 ``` 对于这个表达式来说,`a = 42`中的`=`看起来似乎不是一个副作用操作符。但如果我们检视语句`a = 42`的结果值,会发现它就是刚刚被赋予的值(`42`),所以向`a`赋予的相同的值实质上是一种副作用。 提示: 相同的原因也适用于`+=`,`-=`这样的复合赋值操作符的副作用。例如,`a = b += 2`被处理为首先进行`b += 2`(也就是`b = b + 2`),然后这个赋值的结果被赋予`a`。 这种赋值表达式(语句)得出被赋予的值的行为,主要在链式赋值上十分有用,就像这样: ```source-js var a, b, c; a = b = c = 42; ``` 这里,`c = 42`被求值得出`42`(带有将`42`赋值给`c`的副作用),然后`b = 42`被求值得出`42`(带有将`42`赋值给`b`的副作用),而最后`a = 42`被求值(带有将`42`赋值给`a`的副作用)。 警告: 一个开发者们常犯的错误是将链式赋值写成`var a = b = 42`这样。虽然这看起来是相同的东西,但它不是。如果这个语句发生在没有另外分离的`var b`(在作用域的某处)来正式声明它的情况下,那么`var a = b = 42`将不会直接声明`b`。根据`strict`模式的状态,它要么抛出一个错误,要么无意中创建一个全局变量(参见本系列的 *作用域与闭包*)。 另一个要考虑的场景是: ```source-js function vowels(str) { var matches; if (str) { // 找出所有的元音字母 matches = str.match( /[aeiou]/g ); if (matches) { return matches; } } } vowels( "Hello World" ); // ["e","o","o"] ``` 这可以工作,而且许多开发者喜欢这么做。但是使用一个我们可以利用赋值副作用的惯用法,可以通过将两个`if`语句组合为一个来进行简化: ```source-js function vowels(str) { var matches; // 找出所有的元音字母 if (str && (matches = str.match( /[aeiou]/g ))) { return matches; } } vowels( "Hello World" ); // ["e","o","o"] ``` 注意: `matches = str.match..`周围的`( .. )`是必需的。其原因是操作符优先级,我们将在本章稍后的“操作符优先级”一节中讨论。 我偏好这种短一些的风格,因为我认为它明白地表示了两个条件其实是有关联的,而非分离的。但是与大多数JS中的风格选择一样,哪一种 *更好* 纯粹是个人意见。 ### 上下文规则 在JavaScript文法规则中有好几个地方,同样的语法根据它们被使用的地方/方式不同意味着不同的东西。这样的东西可能,孤立的看,导致相当多的困惑。 我们不会在这里详尽地罗列所有这些情况,而只是指出常见的几个。 #### `{ .. }` 大括号 在你的代码中一对`{ .. }`大括号将主要出现在两种地方(随着JS的进化会有更多!)。让我们来看看它们每一种。 ##### 对象字面量 首先,作为一个`object`字面量: ```source-js // 假定有一个函数`bar()`的定义 var a = { foo: bar() }; ``` 我们怎么知道这是一个`object`字面量?因为`{ .. }`是一个被赋予给`a`的值。 注意: `a`这个引用被称为一个“l-值”(也称为左手边的值)因为它是赋值的目标。`{ .. }`是一个“r-值”(也称为右手边的值)因为它仅被作为一个值使用(在这里作为赋值的源)。 ##### 标签 如果我们移除上面代码的`var a =`部分会发生什么? ```source-js // 假定有一个函数`bar()`的定义 { foo: bar() } ``` 许多开发者臆测`{ .. }`只是一个独立的没有被赋值给任何地方的`object`字面量。但事实上完全不同。 这里,`{ .. }`只是一个普通的代码块儿。在JavaScript中拥有一个这样的独立`{ .. }`块儿并不是一个很惯用的形式(在其他语言中要常见得多!),但它是完美合法的JS文法。当与`let`块儿作用域声明组合使用时非常有用(见本系列的 *作用域与闭包*)。 这里的`{ .. }`代码块儿在功能上差不多与附着在一些语句后面的代码块儿是相同的,比如`for`/`while`循环,`if`条件,等等。 但如果它是一个一般代码块儿,那么那个看起来异乎寻常的`foo: bar()`语法是什么?它怎么会是合法的呢? 这是因为一个鲜为人知的(而且,坦白地说,不鼓励使用的)称为“打标签的语句”的JavaScript特性。`foo`是语句`bar()`(这个语句省略了末尾的`;`—— 见本章稍后的“自动分号”)的标签。但一个打了标签的语句有何意义? 如果JavaScript有一个`goto`语句,那么在理论上你就可以说`goto foo`并使程序的执行跳转到代码中的那个位置。`goto`通常被认为是一种糟糕的编码惯用形式,因为它们使代码更难于理解(也称为“面条代码”),所以JavaScript没有一般的`goto`语句是一件 *非常好的事情*。 然而,JS的确支持一种有限的,特殊形式的`goto`:标签跳转。`continue`和`break`语句都可以选择性地接受一个指定的标签,在这种情况下程序流会有些像`goto`一样“跳转”。考虑一下代码: ```source-js // 用`foo`标记的循环 foo: for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { // 每当循环相遇,就继续外层循环 if (j == i) { // 跳到被`foo`标记的循环的下一次迭代 continue foo; } // 跳过奇数的乘积 if ((j * i) % 2 == 1) { // 内层循环的普通(没有被标记的) `continue` continue; } console.log( i, j ); } } // 1 0 // 2 0 // 2 1 // 3 0 // 3 2 ``` 注意: `continue foo`不意味着“走到标记为‘foo’的位置并继续”,而是,“继续标记为‘foo’的循环,并进行下一次迭代”。所以,它不是一个 *真正的* 随意的`goto`。 如你所见,我们跳过了乘积为奇数的`3 1`迭代,而且被打了标签的循环跳转还跳过了`1 1`和`2 2`的迭代。 也许标签跳转的一个稍稍更有用的形式是,使用`break __`从一个内部循环里面跳出外部循环。没有带标签的`break`,同样的逻辑有时写起来非常尴尬: ```source-js // 用`foo`标记的循环 foo: for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { if ((i * j) >= 3) { console.log( "stopping!", i, j ); // 跳出被`foo`标记的循环 break foo; } console.log( i, j ); } } // 0 0 // 0 1 // 0 2 // 0 3 // 1 0 // 1 1 // 1 2 // stopping! 1 3 ``` 注意: `break foo`不意味着“走到‘foo’标记的位置并继续”,而是,“跳出标记为‘foo’的循环/代码块儿,并继续它 *后面* 的部分”。不是一个传统意义上的`goto`,对吧? 对于上面的问题,使用不带标签的`break`将可能会牵连一个或多个函数,共享作用域中变量的访问,等等。它很可能要比带标签的`break`更令人糊涂,所以在这里使用带标签的`break`也许是更好的选择。 一个标签也可以用于一个非循环的块儿,但只有`break`可以引用这样的非循环标签。你可以使用带标签的`break ___`跳出任何被标记的块儿,但你不能`continue ___`一个非循环标签,也不能用一个不带标签的`break`跳出一个块儿。 ```source-js function foo() { // 用`bar`标记的块儿 bar: { console.log( "Hello" ); break bar; console.log( "never runs" ); } console.log( "World" ); } foo(); // Hello // World ``` 带标签的循环/块儿极不常见,而且经常使人皱眉头。最好尽可能地避开它们;比如使用函数调用取代循环跳转。但是也许在一些有限的情况下它们会有用。如果你打算使用标签跳转,那么就确保使用大量注释在文档中记下你在做什么! 一个很常见的想法是,JSON是一个JS的恰当子集,所以一个JSON字符串(比如`{"a":42}` —— 注意属性名周围的引号是JSON必需的!)被认为是一个合法的JavaScript程序。不是这样的! 如果你试着把`{"a":42}`敲进你的JS控制台,你会得到一个错误。 这是因为语句标签周围不能有引号,所以`"a"`不是一个合法的标签,因此`:`不能出现在它后面。 所以,JSON确实是JS语法的子集,但是JSON本身不是合法的JS文法。 按照这个路线产生的一个极其常见的误解是,如果你将一个JS文件加载进一个` ';