【你不懂JS:类型与文法】第五章:文法

我们想要解决的最后一个主要话题是JavaScript的语法如何工作(也称为它的文法)。你可能认为你懂得如何编写JS,但是语言文法的各个部分中有太多微妙的地方导致了困惑和误解,所以我们想要深入这些部分并搞清楚一些事情。

注意: 对于读者们来说,“文法(grammar)”一词不像“语法(syntax)”一词那么为人熟知。在许多意义上,它们是相似的词,描述语言如何工作的 规则。它们有一些微妙的不同,但是大部分对于我们在这里的讨论无关紧要。JavaScript的文法是一种结构化的方式,来描述语法(操作符,关键字,等等)如何组合在一起形成结构良好,合法的程序。换句话说,抛开文法来讨论语法将会忽略许多重要的细节。所以我们在本章中注目的内容的最准确的描述是 文法,尽管语言中的纯语法才是开发者们直接交互的。

语句与表达式

一个很常见的现象是,开发者们假定“语句(statement)”和“表达式(expression)”是大致等价的。但是这里我们需要区分它们俩,因为在我们的JS程序中它们有一些非常重要的区别。

为了描述这种区别,让我们借用一下你可能更熟悉的术语:英语。

一个“句子(sentence)”是一个表达想法的词汇的完整构造。它由一个或多个“短语(phrase)”组成,它们每一个都可以用标点符号或连词(“和”,“或”等等)连接。一个短语本身可以由更小的短语组成。一些短语是不完整的,而且本身没有太多含义,而另一些短语可以自成一句。这些规则总体地称为英语的 文法

JavaScript文法也类似。语句就是句子,表达式就是短语,而操作符就是连词/标点。

JS中的每一个表达式都可以被求值而成为一个单独的,具体的结果值。举例来说:

  1. var a = 3 * 6;
  2. var b = a;
  3. b;

在这个代码段中,3 * 6是一个表达式(求值得值18)。而第二行的a也是一个表达式,第三行的b也一样。对表达式ab求值都会得到在那一时刻存储在这些变量中的值,也就偶然是18

另外,这三行的每一行都是一个包含表达式的语句。var a = 3 * 6var b = a称为“声明语句(declaration statments)”因为它们每一个都声明了一个变量(并选择性地给它赋值)。赋值a = 3 * 6b = 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,而且你也许从来没理解它是什么和为什么。简单地说,控制台仅仅报告语句的完成值。

但是控制台打印出的完成值并不是我们可以在程序中使用的东西。那么我们该如何捕获完成值呢?

这是个更加复杂的任务。在我们解释 如何 之前,让我们先探索一下 为什么 你想这样做。

我们需要考虑其他类型的语句的完成值。例如,任何普通的{ .. }块儿都有一个完成值,即它所包含的最后一个语句/表达式的完成值。

考虑如下代码:

  1. var b;
  2. if (true) {
  3. b = 4 + 38;
  4. }

如果你将这段代码敲入你的控制台/REPL,你可能会看到它报告42,因为42if块儿的完成值,它取自if的最后一个复制表达式语句b = 4 + 38

换句话说,一个块儿的完成值就像 隐含地返回 块儿中最后一个语句的值。

注意: 这在概念上与CoffeeScript这样的语言很类似,它们隐含地从functionreturn值,这些值与函数中最后一个语句的值是相同的。

但这里有一个明显的问题。这样的代码是不工作的:

  1. var a, b;
  2. a = if (true) {
  3. b = 4 + 38;
  4. };

我们不能以任何简单的语法/文法来捕获一个语句的完成值并将它赋值给另一个变量(至少是还不能!)。

那么,我们能做什么?

警告: 仅用于演示的目的 —— 不要实际地在你的真实代码中做如下内容!

我们可以使用臭名昭著的eval(..)(有时读成“evil”)函数来捕获这个完成值。

  1. var a, b;
  2. a = eval( "if (true) { b = 4 + 38; }" );
  3. a; // 42

啊呀呀。这太难看了。但是这好用!而且它展示了语句的完成值是一个真实的东西,不仅仅是在控制台中,还可以在我们的程序中被捕获。

有一个称为“do表达式”的ES7提案。这是它可能工作的方式:

  1. var a, b;
  2. a = do {
  3. if (true) {
  4. b = 4 + 38;
  5. }
  6. };
  7. a; // 42

do { .. }表达式执行一个块儿(其中有一个或多个语句),这个块儿中的最后一个语句的完成值将成为do表达式的完成值,它可以像展示的那样被赋值给a

这里的大意是能够将语句作为表达式对待 —— 他们可以出现在其他语句内部 —— 而不必将它们包装在一个内联的函数表达式中,并实施一个明确的return ..

到目前为止,语句的完成值不过是一些琐碎的事情。不过随着JS的进化它们的重要性可能会进一步提高,而且很有希望的是do { .. }表达式将会降低使用eval(..)这样的东西的冲动。

警告: 重复我刚才的训诫:避开eval(..)。真的。更多解释参见本系列的 作用域与闭包 一书。

表达式副作用

大多数表达式没有副作用。例如:

  1. var a = 2;
  2. var b = a + 3;

表达式a + 3本身并没有副作用,例如改变a。它有一个结果,就是5,而且这个结果在语句b = a + 3中被赋值给b

一个最常见的(可能)带有副作用的表达式的例子是函数调用表达式:

  1. function foo() {
  2. a = a + 1;
  3. }
  4. var a = 1;
  5. foo(); // 结果:`undefined`,副作用:改变 `a`

还有其他的副作用表达式。例如:

  1. var a = 42;
  2. var b = a++;

表达式a++有两个分离的行为。首先,它返回a的当前值,也就是42(然后它被赋值给b)。但 接下来,它改变a本身的值,将它增加1。

  1. var a = 42;
  2. var b = a++;
  3. a; // 43
  4. b; // 42

许多开发者错误的认为ba一样拥有值43。这种困惑源自没有完全考虑++操作符的副作用在 什么时候 发生。

++递增操作符和--递减操作符都是一元操作符(见第四章),它们既可以用于后缀(“后面”)位置也可用于前缀(“前面”)位置。

  1. var a = 42;
  2. a++; // 42
  3. a; // 43
  4. ++a; // 44
  5. a; // 44

++++a这样用于前缀位置时,它的副作用(递增a)发生在值从表达式中返回 之前,而不是a++那样发生在 之后

注意: 你认为++a++是一个合法的语法吗?如果你试一下,你将会得到一个ReferenceError错误,但为什么?因为有副作用的操作符 要求一个变量引用 来作为它们副作用的目标。对于++a++来说,a++这部分会首先被求值(因为操作符优先级 —— 参见下面的讨论),它会给出a在递增 之前 的值。但然后它试着对++42求值,这将(如果你试一下)会给出相同的ReferenceError错误,因为++不能直接在42这样的值上施加副作用。

有时它会被错误地认为,你可以通过将a++包进一个( )中来封装它的 副作用,比如:

  1. var a = 42;
  2. var b = (a++);
  3. a; // 43
  4. b; // 42

不幸的是,( )本身不会像我们希望的那样,定义一个新的被包装的表达式,而它会在a++表达式的 后副作用 求值。事实上,就算它能,a++也会首先返回42,而且除非你有另一个表达式在++的副作用之后对a再次求值,你也不会从这个表达式中得到43,于是b不会被赋值为43

虽然,有另一种选择:,语句序列逗号操作符。这个操作符允许你将多个独立的表达式语句连成一个单独的语句:

  1. var a = 42, b;
  2. b = ( a++, a );
  3. a; // 43
  4. b; // 43

注意: a++, a周围的( .. )是必需的。其原因的操作符优先级,我们将在本章后面讨论。

表达式a++, a意味着第二个a语句表达式会在第一个a++语句表达式的 后副作用 进行求值,这表明它为b的赋值返回43

另一个副作用操作符的例子是delete。正如我们在第二章中展示的,delete用于从一个object或一个array值槽中移除一个属性。但它经常作为一个独立语句被调用:

  1. var obj = {
  2. a: 42
  3. };
  4. obj.a; // 42
  5. delete obj.a; // true
  6. obj.a; // undefined

如果被请求的操作是合法/可允许的,delete操作符的结果值为true,否则结果为false。但是这个操作符的副作用是它移除了属性(或数组值槽)。

注意: 我们说合法/可允许是什么意思?不存在的属性,或存在且可配置的属性(见本系列 this与对象原型 的第三章)将会从delete操作符中返回true。否则,其结果将是false或者一个错误。

副作用操作符的最后一个例子,可能既是明显的也是不明显的,是=赋值操作符。

考虑如下代码:

  1. var a;
  2. a = 42; // 42
  3. a; // 42

对于这个表达式来说,a = 42中的=看起来似乎不是一个副作用操作符。但如果我们检视语句a = 42的结果值,会发现它就是刚刚被赋予的值(42),所以向a赋予的相同的值实质上是一种副作用。

提示: 相同的原因也适用于+=-=这样的复合赋值操作符的副作用。例如,a = b += 2被处理为首先进行b += 2(也就是b = b + 2),然后这个赋值的结果被赋予a

这种赋值表达式(语句)得出被赋予的值的行为,主要在链式赋值上十分有用,就像这样:

  1. var a, b, c;
  2. 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模式的状态,它要么抛出一个错误,要么无意中创建一个全局变量(参见本系列的 作用域与闭包)。

另一个要考虑的场景是:

  1. function vowels(str) {
  2. var matches;
  3. if (str) {
  4. // 找出所有的元音字母
  5. matches = str.match( /[aeiou]/g );
  6. if (matches) {
  7. return matches;
  8. }
  9. }
  10. }
  11. vowels( "Hello World" ); // ["e","o","o"]

这可以工作,而且许多开发者喜欢这么做。但是使用一个我们可以利用赋值副作用的惯用法,可以通过将两个if语句组合为一个来进行简化:

  1. function vowels(str) {
  2. var matches;
  3. // 找出所有的元音字母
  4. if (str && (matches = str.match( /[aeiou]/g ))) {
  5. return matches;
  6. }
  7. }
  8. vowels( "Hello World" ); // ["e","o","o"]

注意: matches = str.match..周围的( .. )是必需的。其原因是操作符优先级,我们将在本章稍后的“操作符优先级”一节中讨论。

我偏好这种短一些的风格,因为我认为它明白地表示了两个条件其实是有关联的,而非分离的。但是与大多数JS中的风格选择一样,哪一种 更好 纯粹是个人意见。

上下文规则

在JavaScript文法规则中有好几个地方,同样的语法根据它们被使用的地方/方式不同意味着不同的东西。这样的东西可能,孤立的看,导致相当多的困惑。

我们不会在这里详尽地罗列所有这些情况,而只是指出常见的几个。

{ .. } 大括号

在你的代码中一对{ .. }大括号将主要出现在两种地方(随着JS的进化会有更多!)。让我们来看看它们每一种。

对象字面量

首先,作为一个object字面量:

  1. // 假定有一个函数`bar()`的定义
  2. var a = {
  3. foo: bar()
  4. };

我们怎么知道这是一个object字面量?因为{ .. }是一个被赋予给a的值。

注意: a这个引用被称为一个“l-值”(也称为左手边的值)因为它是赋值的目标。{ .. }是一个“r-值”(也称为右手边的值)因为它仅被作为一个值使用(在这里作为赋值的源)。

标签

如果我们移除上面代码的var a =部分会发生什么?

  1. // 假定有一个函数`bar()`的定义
  2. {
  3. foo: bar()
  4. }

许多开发者臆测{ .. }只是一个独立的没有被赋值给任何地方的object字面量。但事实上完全不同。

这里,{ .. }只是一个普通的代码块儿。在JavaScript中拥有一个这样的独立{ .. }块儿并不是一个很惯用的形式(在其他语言中要常见得多!),但它是完美合法的JS文法。当与let块儿作用域声明组合使用时非常有用(见本系列的 作用域与闭包)。

这里的{ .. }代码块儿在功能上差不多与附着在一些语句后面的代码块儿是相同的,比如for/while循环,if条件,等等。

但如果它是一个一般代码块儿,那么那个看起来异乎寻常的foo: bar()语法是什么?它怎么会是合法的呢?

这是因为一个鲜为人知的(而且,坦白地说,不鼓励使用的)称为“打标签的语句”的JavaScript特性。foo是语句bar()(这个语句省略了末尾的;—— 见本章稍后的“自动分号”)的标签。但一个打了标签的语句有何意义?

如果JavaScript有一个goto语句,那么在理论上你就可以说goto foo并使程序的执行跳转到代码中的那个位置。goto通常被认为是一种糟糕的编码惯用形式,因为它们使代码更难于理解(也称为“面条代码”),所以JavaScript没有一般的goto语句是一件 非常好的事情

然而,JS的确支持一种有限的,特殊形式的goto:标签跳转。continuebreak语句都可以选择性地接受一个指定的标签,在这种情况下程序流会有些像goto一样“跳转”。考虑一下代码:

  1. // 用`foo`标记的循环
  2. foo: for (var i=0; i<4; i++) {
  3. for (var j=0; j<4; j++) {
  4. // 每当循环相遇,就继续外层循环
  5. if (j == i) {
  6. // 跳到被`foo`标记的循环的下一次迭代
  7. continue foo;
  8. }
  9. // 跳过奇数的乘积
  10. if ((j * i) % 2 == 1) {
  11. // 内层循环的普通(没有被标记的) `continue`
  12. continue;
  13. }
  14. console.log( i, j );
  15. }
  16. }
  17. // 1 0
  18. // 2 0
  19. // 2 1
  20. // 3 0
  21. // 3 2

注意: continue foo不意味着“走到标记为‘foo’的位置并继续”,而是,“继续标记为‘foo’的循环,并进行下一次迭代”。所以,它不是一个 真正的 随意的goto

如你所见,我们跳过了乘积为奇数的3 1迭代,而且被打了标签的循环跳转还跳过了1 12 2的迭代。

也许标签跳转的一个稍稍更有用的形式是,使用break __从一个内部循环里面跳出外部循环。没有带标签的break,同样的逻辑有时写起来非常尴尬:

  1. // 用`foo`标记的循环
  2. foo: for (var i=0; i<4; i++) {
  3. for (var j=0; j<4; j++) {
  4. if ((i * j) >= 3) {
  5. console.log( "stopping!", i, j );
  6. // 跳出被`foo`标记的循环
  7. break foo;
  8. }
  9. console.log( i, j );
  10. }
  11. }
  12. // 0 0
  13. // 0 1
  14. // 0 2
  15. // 0 3
  16. // 1 0
  17. // 1 1
  18. // 1 2
  19. // stopping! 1 3

注意: break foo不意味着“走到‘foo’标记的位置并继续”,而是,“跳出标记为‘foo’的循环/代码块儿,并继续它 后面 的部分”。不是一个传统意义上的goto,对吧?

对于上面的问题,使用不带标签的break将可能会牵连一个或多个函数,共享作用域中变量的访问,等等。它很可能要比带标签的break更令人糊涂,所以在这里使用带标签的break也许是更好的选择。

一个标签也可以用于一个非循环的块儿,但只有break可以引用这样的非循环标签。你可以使用带标签的break ___跳出任何被标记的块儿,但你不能continue ___一个非循环标签,也不能用一个不带标签的break跳出一个块儿。

  1. function foo() {
  2. // 用`bar`标记的块儿
  3. bar: {
  4. console.log( "Hello" );
  5. break bar;
  6. console.log( "never runs" );
  7. }
  8. console.log( "World" );
  9. }
  10. foo();
  11. // Hello
  12. // World

带标签的循环/块儿极不常见,而且经常使人皱眉头。最好尽可能地避开它们;比如使用函数调用取代循环跳转。但是也许在一些有限的情况下它们会有用。如果你打算使用标签跳转,那么就确保使用大量注释在文档中记下你在做什么!

一个很常见的想法是,JSON是一个JS的恰当子集,所以一个JSON字符串(比如{"a":42} —— 注意属性名周围的引号是JSON必需的!)被认为是一个合法的JavaScript程序。不是这样的! 如果你试着把{"a":42}敲进你的JS控制台,你会得到一个错误。

这是因为语句标签周围不能有引号,所以"a"不是一个合法的标签,因此:不能出现在它后面。

所以,JSON确实是JS语法的子集,但是JSON本身不是合法的JS文法。

按照这个路线产生的一个极其常见的误解是,如果你将一个JS文件加载进一个<script src=..>标签,而它里面仅含有JSON内容的话(就像从API调用中得到那样),这些数据将作为合法的JavaScript被读取,但只是不能从程序中访问。JSON-P(将JSON数据包进一个函数调用的做法,比如foo({"a":42}))经常被说成是解决了这种不可访问性,通过向你程序中的一个函数发送这些值。

不是这样的! 实际上完全合法的JSON值{"a":42}本身将会抛出一个JS错误,因为它被翻译为一个带有非法标签的语句块儿。但是foo({"a":42})是一个合法的JS,因为在它里面,{"a":42}是一个被传入foo(..)object字面量值。所以,更合适的说法是,JSON-P使JSON成为合法的JS文法!

块儿

另一个常为人所诟病的JS坑(与强制转换有关 —— 见第四章)是:

  1. [] + {}; // "[object Object]"
  2. {} + []; // 0

这看起来暗示着+操作符会根据第一个操作数是[]还是{}而给出不同的结果。但实际上这与它一点儿关系都没有!

在第一行中,{}出现在+操作符的表达式中,因此被翻译为一个实际的值(一个空object)。第四章解释过,[]被强制转换为""因此{}也会被强制转换为一个string"[object Object]"

但在第二行中,{}被翻译为一个独立的{}空代码块儿(它什么也不做)。块儿不需要分号来终结它们,所以这里缺少分号不是一个问题。最终,+ []是一个将[]明确强制转换number的表达式,而它的值是0

对象解构

从ES6开始,你将看到{ .. }出现的另一个地方是“解构赋值”(更多信息参见本系列的 ES6与未来),确切地说是object解构。考虑下面的代码:

  1. function getData() {
  2. // ..
  3. return {
  4. a: 42,
  5. b: "foo"
  6. };
  7. }
  8. var { a, b } = getData();
  9. console.log( a, b ); // 42 "foo"

正如你可能看出来的,var { a , b } = ..是ES6解构赋值的一种形式,它大体等价于:

  1. var res = getData();
  2. var a = res.a;
  3. var b = res.b;

注意: { a, b } 实际上是{ a: a, b: b }的ES6解构缩写,两者都能工作,但是人们期望短一些的{ a, b }能成为首选的形式。

使用一个{ .. }进行对象解构也可用于被命名的函数参数,这时它是同种类的隐含对象属性赋值的语法糖:

  1. function foo({ a, b, c }) {
  2. // 不再需要:
  3. // var a = obj.a, b = obj.b, c = obj.c
  4. console.log( a, b, c );
  5. }
  6. foo( {
  7. c: [1,2,3],
  8. a: 42,
  9. b: "foo"
  10. } ); // 42 "foo" [1, 2, 3]

所以,我们使用{ .. }的上下文环境整体上决定了它们的含义,这展示了语法和文法之间的区别。理解这些微妙之处以回避JS引擎进行意外的翻译是很重要的。

else if 和可选块儿

一个常见的误解是JavaScript拥有一个else if子句,因为你可以这么做:

  1. if (a) {
  2. // ..
  3. }
  4. else if (b) {
  5. // ..
  6. }
  7. else {
  8. // ..
  9. }

但是这里有一个JS文法隐藏的性质:它没有else if。但是如果附着在ifelse语句后面的代码块儿仅包含一个语句时,ifelse语句允许省略这些代码块儿周围的{ }。毫无疑问,你以前已经见过这种现象很多次了:

  1. if (a) doSomething( a );

许多JS编码风格指引坚持认为,你应当总是在一个单独的语句块儿周围使用{ },就像:

  1. if (a) { doSomething( a ); }

然而,完全相同的文法规则也适用于else子句,所以你经常编写的else if形式 实际上 被解析为:

  1. if (a) {
  2. // ..
  3. }
  4. else {
  5. if (b) {
  6. // ..
  7. }
  8. else {
  9. // ..
  10. }
  11. }

if (b) { .. } else { .. }是一个紧随着else的单独的语句,所以你在它周围放不放一个{ }都可以。换句话说,当你使用else if的时候,从技术上讲你就打破了那个常见的编码风格指导的规则,而且只是用一个单独的if语句定义了你的else

当然,else if惯用法极其常见,而且减少了一级缩进,所以它很吸引人。无论你用哪种方式,就在你自己的编码风格指导/规则中明确地指出它,并且不要臆测else if是直接的文法规则。

操作符优先级

就像我们在第四章中讲解的,JavaScript版本的&&||很有趣,因为它们选择并返回它们的操作数之一,而不是仅仅得出truefalse的结果。如果只有两个操作数和一个操作符,这很容易推理。

  1. var a = 42;
  2. var b = "foo";
  3. a && b; // "foo"
  4. a || b; // 42

但是如果牵扯到两个操作符,和三个操作数呢?

  1. var a = 42;
  2. var b = "foo";
  3. var c = [1,2,3];
  4. a && b || c; // ???
  5. a || b && c; // ???

要明白这些表达式产生什么结果,我们就需要理解当在一个表达式中有多于一个操作符时,什么样的规则统治着操作符被处理的方式。

这些规则称为“操作符优先级”。

我打赌大多数读者都觉得自己已经很好地理解了操作符优先级。但是和我们在本系列丛书中讲解的其他一切东西一样,我们将拨弄这种理解来看看它到底有多扎实,并希望能在这个过程中学到一些新东西。

回想上面的例子:

  1. var a = 42, b;
  2. b = ( a++, a );
  3. a; // 43
  4. b; // 43

要是我们移除了( )会怎样?

  1. var a = 42, b;
  2. b = a++, a;
  3. a; // 43
  4. b; // 42

等一下!为什么这改变了赋给b的值?

因为,操作符要比=操作符的优先级低。所以,b = a++, a被翻译为(b = a++), a。因为(如我们前面讲解的)a++拥有 后副作用,赋值给b的值就是在++改变a之前的值42

这只是为了理解操作符优先级所需的一个简单事实。如果你将要把,作为一个语句序列操作符使用,那么知道它实际上拥有最低的优先级是很重要的。任何其他的操作符都将要比,结合得更紧密。

现在,回想上面的这个例子:

  1. if (str && (matches = str.match( /[aeiou]/g ))) {
  2. // ..
  3. }

我们说过赋值语句周围的( )是必须的,但为什么?因为&&拥有的优先级比=更高,所以如果没有( )来强制结合,这个表达式将被作为(str && matches) = str.match..对待。但是这将是个错误,因为(str && matches)的结果将不是一个变量(在这里是undefined),而是一个值,因此它不能成为=赋值的左边!

好了,那么你可能认为你已经搞定操作符优先级了。

让我们移动到更复杂的例子(在本章下面几节中我们将一直使用这个例子),来 真正 测试一下你的理解:

  1. var a = 42;
  2. var b = "foo";
  3. var c = false;
  4. var d = a && b || c ? c || b ? a : c && b : a;
  5. d; // ??

好的,邪恶,我承认。没有人会写这样的表达式串,对吧?也许 不会,但是我们将使用它来检视将多个操作符链接在一起时的各种问题,而链接多个操作符是一个非常常见的任务。

上面的结果是42。但是这根本没意思,除非我们自己能搞清楚这个答案,而不是将它插进JS程序来让JavaScript搞定它。

让我们深入挖掘一下。

第一个问题 —— 你可能还从来没问过 —— 是,第一个部分(a && b || c)是像(a && b) || c那样动作,还是像a && (b || c)那样动作?你能确定吗?你能说服你自己它们实际上是不同的吗?

  1. (false && true) || true; // true
  2. false && (true || true); // false

那么,这就是它们不同的证据。但是false && true || true到底是如何动作的?答案是:

  1. false && true || true; // true
  2. (false && true) || true; // true

那么我们有了答案。&&操作符首先被求值,而||操作符第二被求值。

但这不是因为从左到右的处理顺序吗?让我们把操作符的顺序倒过来:

  1. true || false && false; // true
  2. (true || false) && false; // false -- 不
  3. true || (false && false); // true -- 这才是胜利者!

现在我们证明了&&首先被求值,然后才是||,而且在这个例子中的顺序实际上是与一般希望的从左到右的顺序相反的。

那么什么导致了这种行为?操作符优先级。

每种语言都定义了自己的操作符优先级列表。虽然令人焦虑,但是JS开发者读过JS的列表却不太常见。

如果你熟知它,上面的例子一点儿都不会绊到你,因为你已经知道了&&要比||优先级高。但是我打赌有相当一部分读者不得不将它考虑一会。

注意: 不幸的是,JS语言规范没有将它的操作符优先级罗列在一个方便,单独的位置。你不得不通读并理解所有的文法规则。所以我们将试着以一种更方便的格式排列出更常见和更有用的部分。要得到完整的操作符优先级列表,参见MDN网站的操作符优先级

短接

在第四章中,我们在一个边注中提到了操作符&&||的“短接”性质。让我们更详细地重温它们。

对于&&||两个操作符来说,如果左手边的操作数足够确定操作的结果,那么右手边的操作数将 不会被求值。故而,有了“短接”(如果可能,它就会取捷径退出)这个名字。

例如,说a && b,如果a是falsyb就不会被求值,因为&&操作数的结果已经确定了,所以再去麻烦地检查b是没有意义的。同样的,说a || b,如果a是truthy,那么操作的结果就已经确定了,所以没有理由再去检查b

这种短接非常有帮助,而且经常被使用:

  1. function doSomething(opts) {
  2. if (opts && opts.cool) {
  3. // ..
  4. }
  5. }

opts && opts.cool测试的opts部分就像某种保护,因为如果opts没有被赋值(或不是一个object),那么表达式opts.cool就将抛出一个错误。opts测试失败加上短接意味着opts.cool根本不会被求值,因此没有错误!

相似地,你可以用||短接:

  1. function doSomething(opts) {
  2. if (opts.cache || primeCache()) {
  3. // ..
  4. }
  5. }

这里,我们首先检查opts.cache,如果它存在,我们就不会调用primeCache()函数,如此避免了潜在的不必要的工作。

更紧密的绑定

让我们把注意力转回前面全是链接的操作符的复杂语句的例子,特别是? :三元操作符的部分。? :操作对的优先级与&&||操作符比起来是高还是低?

  1. a && b || c ? c || b ? a : c && b : a

它是更像这样:

  1. a && b || (c ? c || (b ? a : c) && b : a)

还是这样?

  1. (a && b || c) ? (c || b) ? a : (c && b) : a

答案是第二个。但为什么?

因为&&优先级比||高,而||优先级比? :高。

所以,表达式(a && b || c)? :参与之前被 首先 求值。另一种常见的解释方式是,&&||要比? :“结合的更紧密”。如果倒过来成立的话,那么c ? c..将结合的更紧密,那么它就会如a && b || (c ? c..)那样动作(就像第一种选择)。

结合性

所以,&&||操作符首先集合,然后是? :操作符。但是多个同等优先级的操作符呢?它们总是从左到右或是从右到左地处理吗?

一般来说,操作符不是左结合的就是右结合的,这要看 分组是从左边发生还是从右边发生。

至关重要的是,结合性与从左到右或从右到左的处理 不是 同一个东西。

但为什么处理是从左到右或从右到左那么重要?因为表达式可以有副作用,例如函数调用:

  1. var a = foo() && bar();

这里,foo()首先被求值,然后根据表达式foo()的结果,bar()可能会求值。如果bar()foo()之前被调用绝对会得出不同的程序行为。

但是这个行为就是从左到右的处理(JavaScript中的默认行为!)—— 它与&&的结合性无关。在这个例子中,因为这里只有一个&&因此没有相关的分组,所以根本谈不上结合性。

但是像a && b && c这样的表达式,分组将会隐含地发生,意味着不是a && b就是b && c会先被求值。

技术上讲,a && b && c将会作为(a && b) && c处理,因为&&是左结合的(顺带一提,||也是)。然而,右结合的a && (b && c)也表现出相同的行为。对于相同的值,相同的表达式是按照相同的顺序求值的。

注意: 如果假设&&是右结合的,它就会与你手动使用( )建立a && (b && c)这样的分组的处理方式一样。但是这仍然 不意味着 c将会在b之前被处理。右结合性的意思 不是 从右到左求值,它的意思是从右到左 分组。不管哪种方式,无论分组/结合性怎样,严格的求值顺序将是a,然后b,然后c(也就是从左到右)。

因此,除了使我们对它们定义的讨论更准确以外,&&||是左结合这件事没有那么重要。

但事情不总是这样。一些操作符根据左结合性与右结合性将会做出不同的行为。

考虑? :(“三元”或“条件”)操作符:

  1. a ? b : c ? d : e;

? :是右结合的,那么哪种分组表现了它将被处理的方式?

  • a ? b : (c ? d : e)
  • (a ? b : c) ? d : e

答案是a ? b : (c ? d : e)。不像上面的&&||,在这里右结合性很重要,因为对于一些(不是全部!)值的组合来说(a ? b : c) ? d : e的行为将会不同。

一个这样的例子是:

  1. true ? false : true ? true : true; // false
  2. true ? false : (true ? true : true); // false
  3. (true ? false : true) ? true : true; // true

在其他的值的组合中潜伏着更加微妙的不同,即便他们的最终结果是相同的。考虑:

  1. true ? false : true ? true : false; // false
  2. true ? false : (true ? true : false); // false
  3. (true ? false : true) ? true : false; // false

在这个场景中,相同的最终结果暗示着分组是没有实际意义的。然而:

  1. var a = true, b = false, c = true, d = true, e = false;
  2. a ? b : (c ? d : e); // false, 仅仅对 `a` 和 `b` 求值
  3. (a ? b : c) ? d : e; // false, 对 `a`, `b` 和 `e` 求值

这样,我们就清楚地证明了? :是右结合的,而且在这个操作符与它自己链接的方式上,右结合性是发挥影响的。

另一个右结合(分组)的例子是=操作符。回想本章早先的链式赋值的例子:

  1. var a, b, c;
  2. a = b = c = 42;

我们早先断言过,a = b = c = 42的处理方式是,首先对c = 42赋值求值,然后是b = ..,最后是a = ..。为什么?因为右结合性,它实际上这样看待这个语句:a = (b = (c = 42))

记得本章前面,我们的复杂赋值表达式的实例吗?

  1. var a = 42;
  2. var b = "foo";
  3. var c = false;
  4. var d = a && b || c ? c || b ? a : c && b : a;
  5. d; // 42

随着我们使用优先级和结合性的知识把自己武装起来,我们应当可以像这样把这段代码分解为它的分组行为:

  1. ((a && b) || c) ? ((c || b) ? a : (c && b)) : a

或者,如果这样容易理解的话,可以用缩进表达:

  1. (
  2. (a && b)
  3. ||
  4. c
  5. )
  6. ?
  7. (
  8. (c || b)
  9. ?
  10. a
  11. :
  12. (c && b)
  13. )
  14. :
  15. a

让我们解析它:

  1. (a && b)"foo".
  2. "foo" || c"foo".
  3. 对于第一个?测试,"foo"是truthy。
  4. (c || b)"foo".
  5. 对于第二个?测试, "foo"是truthy。
  6. a42.

就是这样,我们搞定了!答案是42,正如我们早先看到的。其实它没那么难,不是吗?

消除歧义

现在你应该对操作符优先级(和结合性)有了更好的把握,并对理解多个链接的操作符如何动作感到更适应了。

但还存在一个重要的问题:我们应当一直编写完美地依赖于操作符优先级/结合性的代码吗?我们应该仅在有必要强制一种不同的处理顺序时使用( )手动分组吗?

或者,另一方面,我们应当这样认识吗:虽然这样的规则 实际上 是可以学懂的,但是太多的坑让我们不得不忽略自动优先级/结合性?如果是这样,我们应当总是使用( )手动分组并移除对这些自动行为的所有依赖吗?

这种争论是非常主观的,而且和第四章中关于 隐含 强制转换的争论是强烈对称的。大多数开发者对这两个争论的感觉是一样的:要么他们同时接受这两种行为并使用它们编码,要么他们同时摒弃两种行为并坚持手动/明确的写法。

当然,在这个问题上,我们不能给出比我在第四章中给出的更绝对的答案。但我向你展示了利弊,并且希望促进了你更深刻的理解,以使你可以做出合理而不是人云亦云的决定。

在我看来,这里有一个重要的中间立场。我们应当将操作符优先级/结合性 ( )手动分组两者混合进我们的程序 —— 我在第四章中对于 隐含的 强制转换的健康/安全用法做过同样的辩论,但当然不会没有界限地仅仅拥护它。

例如,对我来说if (a && b && c) ..是完全没问题的,而我不会为了明确表现结合性而写出if ((a && b) && c) ..,因为我认为这过于繁冗了。

另一方面,如果我需要链接两个? :条件操作符,我会理所当然地使用( )手动分组来使我意图的逻辑表达的绝对清晰。

因此,我在这里的意见和在第四章中的相似:在操作符优先级/结合性可以使代码更短更干净的地方使用操作符优先级/结合性,在( )手动分组可以帮你创建更清晰的代码并减少困惑的地方使用( )手动分组

自动分号

当JavaScript认为在你的JS程序中特定的地方有一个;时,就算你没在那里放一个;,它就会进行ASI(Automatic Semicolon Insertion —— 自动分号插入)。

为什么它这么做?因为就算你只省略了一个必需的;,你的程序就会失败。不是非常宽容。ASI允许JS容忍那些通常被认为是不需要;的特定地方省略;

必须注意的是,ASI将仅在换行存在时起作用。分号不会被插入一行的中间。

基本上,如果JS解析器在解析一行时发生了解析错误(缺少一个应有的;),而且它可以合理的插入一个;,它就会这么做。什么样的地方对插入是合理的?仅在一个语句和这一行的换行之间除了空格和/或注释没有别的东西时。

考虑如下代码:

  1. var a = 42, b
  2. c;

JS应当将下一行的c作为var语句的一部分看待吗?如果在bc之间的任意一个地方出现一个,,它当然会的。但是因为没有,所以JS认为在b后面有一个隐含的;(在换行处)。如此c;就剩下来作为一个独立的表达式语句。

类似地:

  1. var a = 42, b = "foo";
  2. a
  3. b // "foo"

这仍然是一个没有错误的合法程序,因为表达式语句也接受ASI。

有一些特定的地方ASI很有帮助,例如:

  1. var a = 42;
  2. do {
  3. // ..
  4. } while (a) // <-- 这里需要;!
  5. a;

文法要求do..while循环后面要有一个;,但是whilefor循环后面则没有。但是大多数开发者都不记得它!所以ASI帮助性地介入并插入一个。

如我们在本章早先说过的,语句块儿不需要;终结,所以ASI是不必要的:

  1. var a = 42;
  2. while (a) {
  3. // ..
  4. } // <-- 这里不需要;
  5. a;

另一个ASI介入的主要情况是,与breakcontinuereturn,和(ES6)yield关键字:

  1. function foo(a) {
  2. if (!a) return
  3. a *= 2;
  4. // ..
  5. }

这个return语句的作用不会超过换行到a *= 2表达式,因为ASI认为;终结了return语句。当然,return语句 可以 很容易地跨越多行,只要return后面不是除了换行外什么都没有就行。

  1. function foo(a) {
  2. return (
  3. a * 2 + 3 / 12
  4. );
  5. }

同样的道理也适用于breakcontinue,和yield

纠错

在JS社区中斗得最火热的 宗教战争 之一(除了制表与空格以外),就是是否应当严重/唯一地依赖ASI。

大多数,但不是全部的,分号是可选的,但是for ( .. ) ..循环的头部的两个;是必须的。

在这场争论的正方,许多开发者相信ASI是一种有用的机制,允许他们通过省略除了必须(很少几个)以外的所有;写出更简洁(和更“美观”)的代码。他们经常断言因为ASI使许多;成为可选的,所以一个 不带它们 而正确编写的程序,与 带着它们 而正确编写的程序没有区别。

在这场争论的反方,许多开发者将断言有 太多 的地方可以成为意想不到的坑了,特别是对那些新来的,缺乏经验的开发者来说,无意间被魔法般插入的;改变了程序的含义。类似地,一些开发者将会争论如果他们省略了一个分号,这就是一个直白的错误,而且他们希望他们的工具(linter等等)在JS引擎背地里 纠正 它之前就抓住他。

让我分享一下我的观点。仔细阅读语言规范,会发现它暗示ASI是一个 纠错 过程。你可能会问,什么样的错误?明确地讲,是一个 解析器错误。换句话说,为了使解析器失败的少一些,ASI让它更宽容。

但是宽容什么?在我看来,一个 解析器错误 发生的唯一方式是,它被给予了一个不正确/错误的程序去解析。所以虽然ASI在严格地纠正解析器错误,但是它得到这样的错误的唯一方式是,程序首先就写错了 —— 在文法要求使用分号的地方忽略了它们。

所以,更直率地讲,当我听到有人声称他们想要省略“可选的分号”时,我的大脑就将它翻译为“我想尽量编写最能破坏解析器但依然可以工作的程序。”

我发现这种立场很荒唐,而且省几下键盘敲击和更“美观的代码”的观点是软弱无力的。

进一步讲,我不同意这和空格与制表符的争论是同一种东西 —— 那纯粹是表面上的 —— 我宁愿相信这是一个根本问题:是编写遵循文法要求的代码,还是编写依赖于文法异常但仅仅将之忽略不计的代码。

另一种看待这个问题的方式是,依赖ASI实质上将换行视为有意义的“空格”。像Python那样的其他语言中有真正的有意义的空格。但是就今天的JavaScript来说,认为它拥有有意义的换行真的合适吗?

我的意见是:在你知道分号是“必需的”地方使用分号,并且把你对ASI的臆测限制到最小。

不要光听我的一面之词。回到2012年,JavaScript的创造者Brendan Eich说过下面的话

这个故事的精神是:ASI是一种(正式地说)语法错误纠正过程。如果你在好像有一种普遍的有意义的换行的规则的前提下开始编码,你将会陷入麻烦。
..
如果回到1995年五月的那十天,我希望我使换行在JS中更有意义。
..
如果ASI好像给了JS有意义的换行,那么要小心不要使用它。

错误

JavaScript不仅拥有不同的错误 子类型TypeErrorReferenceErrorSyntaxError等等),而且和其他在运行时期间发生的错误相比,它的文法还定义了在编译时被强制执行的特定错误。

尤其是,早就有许多明确的情况应当被作为“早期错误”(编译期间)被捕获和报告。任何直接的语法错误都是一个早期错误(例如,a = ,),而且文法还定义了一些语法上合法但是无论怎样都不允许的东西。

因为你的代码还没有开始执行,这些错误不能使用try..catch捕获;它们只是会在你的程序进行解析/编译时导致失败。

提示: 在语言规范中没有要求浏览器(和开发者工具)到底应当怎样报告错误。所以在下面的错误例子中,对于哪一种错误的子类型会被报告或它包含什么样的错误消息,你可能会在各种浏览器中看到不同的形式,

一个简单的例子是正则表达式字面量中的语法。这里的JS语法没有错误,而是不合法的正则表达式将会抛出一个早期错误:

  1. var a = /+foo/; // 错误!

一个赋值的目标必须是一个标识符(或者一个产生一个或多个标识符的ES6解构表达式),所以一个像42这样的值在这个位置上是不合法的,因此可以立即被报告:

  1. var a;
  2. 42 = a; // 错误!

ES5的strict模式定义了更多的早期错误。例如,在strict模式中,函数参数的名称不能重复:

  1. function foo(a,b,a) { } // 还好
  2. function bar(a,b,a) { "use strict"; } // 错误!

另一种strict模式的早期错误是,一个对象字面量拥有一个以上的同名属性:

  1. (function(){
  2. "use strict";
  3. var a = {
  4. b: 42,
  5. b: 43
  6. }; // 错误!
  7. })();

注意: 从语义上讲,这样的错误技术上不是 语法 错误,而是 文法 错误 —— 上面的代码段是语法上合法的。但是因为没有GrammarError类型,一些浏览器使用SyntaxError代替。

过早使用变量

ES6定义了一个(坦白地说,让人困惑地命名的)新的概念,称为TDZ(“Temporal Dead Zone” —— 时间死区)

TDZ指的是代码中还不能使用变量引用的地方,因为它还没有到完成它所必须的初始化。

对此最明白的例子就是ES6的let块儿作用域:

  1. {
  2. a = 2; // ReferenceError!
  3. let a;
  4. }

赋值a = 2在变量a(它确实是在{ .. }块儿作用域中)被声明let a初始化之前就访问它,所以a位于TDZ中并抛出一个错误。

有趣的是,虽然typeof有一个例外,它对于未声明的变量是安全的(见第一章),但是对于TDZ引用却没有这样的安全例外:

  1. {
  2. typeof a; // undefined
  3. typeof b; // ReferenceError! (TDZ)
  4. let b;
  5. }

函数参数值

另一个违反TDZ的例子可以在ES6的参数默认值(参见本系列的 ES6与未来)中看到:

  1. var b = 3;
  2. function foo( a = 42, b = a + b + 5 ) {
  3. // ..
  4. }

在赋值中的b引用将在参数b的TDZ中发生(不会被拉到外面的b引用),所以它会抛出一个错误。然而,赋值中的a是没有问题的,因为那时参数a的TDZ已经过去了。

当使用ES6的参数默认值时,如果你省略一个参数,或者你在它的位置上传递一个undefined值的话,就会应用这个默认值。

  1. function foo( a = 42, b = a + 1 ) {
  2. console.log( a, b );
  3. }
  4. foo(); // 42 43
  5. foo( undefined ); // 42 43
  6. foo( 5 ); // 5 6
  7. foo( void 0, 7 ); // 42 7
  8. foo( null ); // null 1

注意: 在表达式a + 1null被强制转换为值0。更多信息参考第四章。

从ES6参数默认值的角度看,忽略一个参数和传递一个undefined值之间没有区别。然而,有一个办法可以在一些情况下探测到这种区别:

  1. function foo( a = 42, b = a + 1 ) {
  2. console.log(
  3. arguments.length, a, b,
  4. arguments[0], arguments[1]
  5. );
  6. }
  7. foo(); // 0 42 43 undefined undefined
  8. foo( 10 ); // 1 10 11 10 undefined
  9. foo( 10, undefined ); // 2 10 11 10 undefined
  10. foo( 10, null ); // 2 10 null 10 null

即便参数默认值被应用到了参数ab上,但是如果没有参数传入这些值槽,数组arguments也不会有任何元素。

反过来,如果你明确地传入一个undefined参数,在数组argument中就会为这个参数存在一个元素,但它将是undefined,并且与同一值槽中的被命名参数将被提供的默认值不同。

虽然ES6参数默认值会在数组arguments的值槽和相应的命名参数变量之间造成差异,但是这种脱节也会以诡异的方式发生在ES5中:

  1. function foo(a) {
  2. a = 42;
  3. console.log( arguments[0] );
  4. }
  5. foo( 2 ); // 42 (链接了)
  6. foo(); // undefined (没链接)

如果你传递一个参数,arguments的值槽和命名的参数总是链接到同一个值上。如果你省略这个参数,就没有这样的链接会发生。

但是在strict模式下,这种链接无论怎样都不存在了:

  1. function foo(a) {
  2. "use strict";
  3. a = 42;
  4. console.log( arguments[0] );
  5. }
  6. foo( 2 ); // 2 (没链接)
  7. foo(); // undefined (没链接)

依赖于这样的链接几乎可以肯定是一个坏主意,而且事实上这种连接本身是一种抽象泄漏,它暴露了引擎的底层实现细节,而不是一个合适的设计特性。

arguments数组的使用已经废弃了(特别是被ES6...剩余参数取代以后 —— 参见本系列的 ES6与未来),但这不意味着它都是不好的。

在ES6以前,要得到向另一个函数传递的所有参数值的数组,arguments是唯一的办法,它被证实十分有用。你也可以安全地混用被命名参数和arguments数组,只要你遵循一个简单的规则:绝不同时引用一个被命名参数 它相应的arguments值槽。如果你能避开那种错误的实践,你就永远也不会暴露这种易泄漏的链接行为。

  1. function foo(a) {
  2. console.log( a + arguments[1] ); // 安全!
  3. }
  4. foo( 10, 32 ); // 42

try..finally

你可能很熟悉try..catch块儿是如何工作的。但是你有没有停下来考虑过可以与之成对出现的finally子句呢?事实上,你有没有意识到try只要求catchfinally两者之一,虽然如果有需要它们可以同时出现。

finally子句中的代码 总是 运行的(无论发生什么),而且它总是在try(和catch,如果存在的话)完成后立即运行,在其他任何代码之前。从一种意义上说,你似乎可以认为finally子句中的代码是一个回调函数,无论块儿中的其他代码如何动作,它总是被调用。

那么如果在try子句内部有一个return语句将会怎样?很明显它将返回一个值,对吧?但是调用端代码是在finally之前还是之后才收到这个值呢?

  1. function foo() {
  2. try {
  3. return 42;
  4. }
  5. finally {
  6. console.log( "Hello" );
  7. }
  8. console.log( "never runs" );
  9. }
  10. console.log( foo() );
  11. // Hello
  12. // 42

return 42立即运行,它设置好foo()调用的完成值。这个动作完成了try子句而finally子句接下来立即运行。只有这之后foo()函数才算完成,所以被返回的完成值交给console.log(..)语句使用。

对于try内部的throw来说,行为是完全相同的:

  1. function foo() {
  2. try {
  3. throw 42;
  4. }
  5. finally {
  6. console.log( "Hello" );
  7. }
  8. console.log( "never runs" );
  9. }
  10. console.log( foo() );
  11. // Hello
  12. // Uncaught Exception: 42

现在,如果一个异常从finally子句中被抛出(偶然地或有意地),它将会作为这个函数的主要完成值进行覆盖。如果try块儿中的前一个return已经设置好了这个函数的完成值,那么这个值就会被抛弃。

  1. function foo() {
  2. try {
  3. return 42;
  4. }
  5. finally {
  6. throw "Oops!";
  7. }
  8. console.log( "never runs" );
  9. }
  10. console.log( foo() );
  11. // Uncaught Exception: Oops!

其他的诸如continuebreak这样的非线性控制语句表现出与returnthrow相似的行为是没什么令人吃惊的:

  1. for (var i=0; i<10; i++) {
  2. try {
  3. continue;
  4. }
  5. finally {
  6. console.log( i );
  7. }
  8. }
  9. // 0 1 2 3 4 5 6 7 8 9

console.log(i)语句在continue语句引起的每次循环迭代的末尾运行。然而,它依然是运行在更新语句i++之前的,这就是为什么打印出的值是0..9而非1..10

注意: ES6在generator(参见本系列的 异步与性能)中增加了yield语句,generator从某些方面可以看作是中间的return语句。然而,和return不同的是,一个yield在generator被推进前不会完成,这意味着try { .. yield .. }还没有完成。所以附着在其上的finally子句将不会像它和return一起时那样,在yield之后立即运行。

一个在finally内部的return有着覆盖前一个trycatch子句中的return的特殊能力,但是仅在return被明确调用的情况下:

  1. function foo() {
  2. try {
  3. return 42;
  4. }
  5. finally {
  6. // 这里没有 `return ..`,所以返回值不会被覆盖
  7. }
  8. }
  9. function bar() {
  10. try {
  11. return 42;
  12. }
  13. finally {
  14. // 覆盖前面的 `return 42`
  15. return;
  16. }
  17. }
  18. function baz() {
  19. try {
  20. return 42;
  21. }
  22. finally {
  23. // 覆盖前面的 `return 42`
  24. return "Hello";
  25. }
  26. }
  27. foo(); // 42
  28. bar(); // undefined
  29. baz(); // "Hello"

一般来说,在函数中省略returnreturn;或者return undefined;是相同的,但是在一个finally块儿内部,return的省略不是用一个return undefined覆盖;它只是让前一个return继续生效。

事实上,如果将打了标签的break(在本章早先讨论过)与finally相组合,我们真的可以制造一种疯狂:

  1. function foo() {
  2. bar: {
  3. try {
  4. return 42;
  5. }
  6. finally {
  7. // 跳出标记为`bar`的块儿
  8. break bar;
  9. }
  10. }
  11. console.log( "Crazy" );
  12. return "Hello";
  13. }
  14. console.log( foo() );
  15. // Crazy
  16. // Hello

但是……别这么做。说真的。使用一个finally + 打了标签的break实质上取消了return,这是你在尽最大的努力制造最令人困惑的代码。我打赌没有任何注释可以拯救这段代码。

switch

让我们简单探索一下switch语句,某种if..else if..else..语句链的语法缩写。

  1. switch (a) {
  2. case 2:
  3. // 做一些事
  4. break;
  5. case 42:
  6. // 做另一些事
  7. break;
  8. default:
  9. // 这里是后备操作
  10. }

如你所见,它对a求值一次,然后将结果值与每个case表达式进行匹配(这里只是一些简单的值表达式)。如果找到一个匹配,就会开始执行那个匹配的case,它将会持续执行直到遇到一个break或者遇到switch块儿的末尾。

这些可能不会令你吃惊,但是关于switch,有几个你以前可能从没注意过的奇怪的地方。

首先,在表达式a和每一个case表达式之间的匹配与===算法(见第四章)是相同的。switch经常在case语句中使用绝对值,就像上面展示的,因此严格匹配是恰当的。

然而,你也许希望允许宽松等价(也就是==,见第四章),而这么做你需要“黑”一下switch语句:

  1. var a = "42";
  2. switch (true) {
  3. case a == 10:
  4. console.log( "10 or '10'" );
  5. break;
  6. case a == 42:
  7. console.log( "42 or '42'" );
  8. break;
  9. default:
  10. // 永远不会运行到这里
  11. }
  12. // 42 or '42'

这可以工作是因为case子句可以拥有任何表达式(不仅是简单值),这意味着它将用这个表达式的结果与测试表达式(true)进行严格匹配。因为这里a == 42的结果为true,所以匹配成功。

尽管==switch的匹配本身依然是严格的,在这里是truetrue之间。如果case表达式得出truthy的结果而不是严格的true,它就不会工作。例如如果在你的表达式中使用||&&这样的“逻辑操作符”,这就可能咬到你:

  1. var a = "hello world";
  2. var b = 10;
  3. switch (true) {
  4. case (a || b == 10):
  5. // 永远不会运行到这里
  6. break;
  7. default:
  8. console.log( "Oops" );
  9. }
  10. // Oops

因为(a || b == 10)的结果是"hello world"而不是true,所以严格匹配失败了。这种情况下,修改的方法是强制表达式明确成为一个truefalse,比如case !!(a || b == 10):(见第四章)。

最后,default子句是可选的,而且它不一定非要位于末尾(虽然那是一种强烈的惯例)。即使是在default子句中,是否遇到break的规则也是一样的:

  1. var a = 10;
  2. switch (a) {
  3. case 1:
  4. case 2:
  5. // 永远不会运行到这里
  6. default:
  7. console.log( "default" );
  8. case 3:
  9. console.log( "3" );
  10. break;
  11. case 4:
  12. console.log( "4" );
  13. }
  14. // default
  15. // 3

注意: 就像我们前面讨论的打标签的breakcase子句内部的break也可以被打标签。

这段代码的处理方式是,它首先通过所有的case子句,没有找到匹配,然后它回到default子句开始执行。因为这里没有break,它会继续走进已经被跳过的块儿case 3,在遇到那个break后才会停止。

虽然这种有些迂回的逻辑在JavaScript中是明显可能的,但是它几乎不可能制造出合理或易懂的代码。要对你自己是否想要创建这种环状的逻辑流程保持怀疑,如果你真的想要这么做,确保你留下了大量的代码注释来解释你要做什么!

复习

JavaScript文法有相当多的微妙之处,我们作为开发者应当比平常多花一点儿时间来关注它。一点儿努力可以帮助你巩固对这个语言更深层次的知识。

语句和表达式在英语中有类似的概念 —— 语句就像句子,而表达式就像短语。表达式可以是纯粹的/自包含的,或者他们可以有副作用。

JavaScript文法层面的语义用法规则(也就是上下文),是在纯粹的语法之上的。例如,用于你程序中不同地方的{ }可以意味着块儿,object字面量,(ES6)解构语句,或者(ES6)被命名的函数参数。

JavaScript操作符都有严格定义的优先级(哪一个操作符首先结合)和结合性(多个操作符表达式如何隐含地分组)规则。一旦你学会了这些规则,你就可以自己决定优先级/结合性是否是为了它们自己有利而 过于明确,或者它们是否会对编写更短,更干净的代码有所助益。

ASI(自动分号插入)是一种内建在JS引擎找中的解析器纠错机制,它允许JS引擎在特定的环境下,在需要;但是被省略了的地方,并且插入可以纠正解析错误时,插入一个;。有一场争论是关于这种行为是否暗示着大多数;都是可选的(而且为了更干净的代码可以/应当省略),或者是否它意味着省略它们是在制造JS引擎帮你扫清的错误。

JavaScript有几种类型的错误,但很少有人知道它有两种类别的错误:“早期”(编译器抛出的不可捕获的)和“运行时”(可以try..catch的)。所有在程序运行之前就使它停止的语法错误都明显是早期错误,但也有一些别的错误。

函数参数值与它们正式声明的命名参数之间有一种有趣的联系。明确地说,如果你不小心,arguments数组会有一些泄漏抽象行为的坑。尽可能避开arguments,但如果你必须使用它,那就设法避免同时使用arguments中带有位置的值槽,和相同参数的命名参数。

附着在try(或try..catch)上的finall在执行处理顺序上提供了一些非常有趣的能力。这些能力中的一些可以很有帮助,但是它也可能制造许多困惑,特别是在与打了标签的块儿组合使用时。像往常一样,为了更好更干净的代码而使用finally,不是为了显得更聪明或更糊涂。

switchif..else if..语句提供了一个不错的缩写形式,但是要小心许多常见的关于它的简化假设。如果你不小心,会有几个奇怪的地方绊倒你,但是switch手上也有一些隐藏的高招!


你不懂JS(系列丛书)-网页版目录
上一篇 ●【你不懂JS:类型与文法】第四章:强制转换
下一篇 ● 【你不懂JS: 异步与性能】第一章: 异步:现在与稍后

写于 2018年5月25日229

如非特别注明,文章皆为原创。

转载请注明出处: https://www.liayal.com/article/5b07ea474b90272bebb17a62

你不想说点啥么?
😀😃😄😁😆😅😂🤣☺️😊😇🙂🙃😉😌😍😘😗😙😚😋😜😝😛🤑🤗🤓😎🤡🤠😏😒😞😔😟😕🙁☹️😣😖😫😩😤😠😡😶😐😑😯😦😧😮😲😵😳😱😨😰😢😥🤤😭😓😪😴🙄🤔🤥😬🤐🤢🤧😷🤒🤕😈👿👹👺💩👻💀☠️👽👾🤖🎃😺😸😹😻😼😽🙀😿😾👐👐🏻👐🏼👐🏽👐🏾👐🏿🙌🙌🏻🙌🏼🙌🏽🙌🏾🙌🏿👏👏🏻👏🏼👏🏽👏🏾👏🏿🙏🙏🏻🙏🏼🙏🏽🙏🏾🙏🏿🤝👍👍🏻👍🏼👍🏽👍🏾👍🏿👎👎🏻👎🏼👎🏽👎🏾👎🏿👊👊🏻👊🏼👊🏽👊🏾👊🏿✊🏻✊🏼✊🏽✊🏾✊🏿

评论

~ 评论还没有,沙发可以有 O(∩_∩)O~