Chapter5-grammer
You Don't Know JS: Types & Grammar
Chapter 5: Grammar
我们要解决的最后一个主要话题是JavaScript的语言语法是如何工作的(也就是语法)。你可能认为自己知道如何写JS,但语言语法的各个部分存在很多细微差别导致混淆和误解,因此我们希望深入研究这些部分并搞清楚一些概念。
注意: “语法(grammar)”一词对读者来说可能比“语法(句法,syntax)”一词更不熟悉。在许多方面,它们是类似的术语,描述了语言如何工作的规则。存在微妙的差异,但它们对我们在这里的讨论大多无关紧要。JavaScript的语法是一种结构化的方式,用于描述语法(运算符,关键字等)如何组合成格式良好的有效程序。换句话说,讨论没有语法(grammar)的句法(syntax)会遗漏许多重要的细节。所以我们在本章中的重点是最准确地描述为语法,即使语言的原始句法是开发人员直接与之交互。
Statements & Expressions
开发人员认为术语“声明”和“表达”大致相同是很常见的。但是在这里我们需要区分这两者,因为我们的JS程序中存在一些非常重要的差异。
为了区分,让我们借用你可能更熟悉的术语:英语。
“句子”是表达思想的一个完整的词汇形式。由一个或多个“短语”组成,每个短语都可以用标点符号或连词(“和”,“或”等)连接。短语本身可以由较小的短语组成。有些短语是不完整的,自己完成不了多少,而其他短语可以独立存在。这些规则统称为英语语法(grammar)。
所以它与JavaScript语法一致。语句是句子,表达式是短语,运算符是连词/标点符号。
JS中的每个表达式都可以评估为单个特定值结果。例如:
在此片段中,3 * 6
是表达式(计算值18
)。但是第二行上的a
也是表达式,就像第三行中的b
一样。a
和b
表达式都评估当时存储在那些变量中的值,也恰好是18
。
而且,三行中的每一行都是包含表达式的语句。var a = 3 * 6
和var b = a
被称为"声明语句",因为它们都声明了一个变量(并且可选地为它赋值)。a = 3 * 6
和b =a
(除去var
)称为赋值表达式。
第三行只包含表达式b
,但它本身也是一个声明(虽然不是一个非常有趣的声明!)。
这通常被称为“表达式声明”。
Statement Completion Values
这是一个鲜为人知的事实,语句都有完成值(即使该值只是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
的报告,也许从来没有意识到为什么或那是什么。
但是控制台打印出的完成值不是我们可以在程序中使用的东西。那么我们如何捕获完成值呢?
这是一项更复杂的任务。在我们解释如何解释之前,让我们探讨一下 为什么 要这样做。
我们需要考虑其他类型的语句完成值。例如,任何常规{..}
块都具有完成值,就是其最后包含的语句/表达式的完成值。
考虑下面的代码:
如果你在你的控制台/ REPL中键入它,你可能会看到42
的报告,因为42
是if
块的完成值,它接受了它的最后赋值表达式语句b = 4 + 38
的完成值。
换句话说,块的完成值类似于块中最后一个语句值的 隐式 返回。
注意: 这在概念上与CoffeeScript等语言相似,它具有与function
中最后一个语句值相同的函数的隐式return
值。
但是有一个明显的问题。这种代码不起作用:
我们无法捕获语句的完成值,并以任何简单的句法/语法方式将其分配到另一个变量中(至少现在还没有!)。
所以,我们可以做什么?
注意: 仅用于演示目的 - 实际上不要在您的实际代码中执行以下操作!
我们可以使用备受诟病的eval(..)
(有时发音为“evil”)函数来捕获此完成值。
耶耶耶,这也太难看了。但是他工作了!它说明了语句完成值是一个真实的东西,不仅可以在我们的控制台中捕获,而且可以在我们的程序中捕获。
有一个名为“表达式”的ES7提案。以下是它的工作原理:
do {..}
表达式执行一个块(其中包含一个或多个语句),块内的最终语句完成值成为do
表达式的完成值,然后可以将其分配给a
,如上所示。
一般的想法是能够将语句视为表达式 - 它们可以显示在其他语句中 - 而无需将它们包装在内联函数表达式中并执行显式return..
。
目前,语句完成值只不过是一些琐事。但随着JS的发展,它们可能会扮演更重要的角色,并且希望do {..}
表达式能够减少使用eval(..)
之类的东西的诱惑。
警告: 重复我之前的警告:避免使用eval(..)
。这是认真的。有关更多说明,请参阅本系列的"Scope & Closures"标题。
Expression Side Effects
大多数表达式没有副作用。例如:
表达式a + 3
本身不具有副作用,例如改变a
。它有一个结果,即5
,结果在b = a + 3
的语句中被赋值给b
。
具有(可能的)副作用的表达式的最常见示例是函数调用表达式:
但是,还有其他副作用表达式。例如:
表达式a ++
有两个不同的行为。首先,它返回a
的当前值,即42
(然后将其分配给b
)。但接下来,它会改变a
自身的值,将其递增1
。
许多开发人员会错误地认为b
具有值43
就像a
一样。但是,混淆来自于没有充分考虑++
运算符的副作用的时间。
++
增量运算符和 -
减量运算符都是一元运算符(参见第4章),可以在后缀(“后”)位置或前缀(“前”)位置使用。
当++
在前缀位置使用作为++ a
时,其副作用(递增a
)发生在从表达式返回值之前,而不是在使用++
之后返回。
注意: 你认为++ a ++
是合法的句法吗?如果你尝试它,你将收到ReferenceError
错误,但为什么?因为副作用运算符需要变量引用来定位其副作用。对于++ a ++
,首先评估a ++
部分(因为运算符优先级 - 见下文),它在增量之前返回a
的值。但后来它试图评估++ 42
,它(如果你尝试的话)给出了相同的ReferenceError
错误,因为++
不能直接对像42
这样的值产生副作用。
有时会错误地认为你可以通过将它包装在()
对中来封装++
的后效应,例如:
不幸的是,()
本身没有定义一个新的包装表达式,它将在a ++
表达式的后副作用之后进行评估,正如我们所希望的那样。事实上,即使它确实如此,一个++
首先返回42
,除非你有另一个表达式在++
的副作用之后重新评估a
,你不会从该表达式得到43
,所以b
将不会被赋值43
。
但是有一个选项: ,
声明系列逗号运算符。此运算符允许你将多个独立表达式语句串联到一个语句中:
注意: (..)
在这里包围a++, a
是需要的。原因是运算符优先级,我们将在本章后面介绍。
表达式a++, a
,表示第二个语句表达式在第一个a ++
语句表达式的副作用之后被计算,这意味着它返回43
值以赋值给b
。
副作用运算符的另一个例子是delete
。正如我们在第2章中所示,delete
用于从object
或插槽中删除array
中的属性。但它通常只是作为独立声明调用:
如果请求的操作有效/允许,则delete
运算符的结果值为true
,否则为false
。但是运算符的副作用是它删除了属性(或数组槽)。
注意: 有效/允许是什么意思?不存在的属性或存在且可配置的属性(请参阅本系列的this&Object Prototypes标题的第3章)将从delete
运算符返回true
。否则,结果将为false
或错误。
副作用运算符的最后一个例子是=
赋值运算符,它可能同时是明显的和非显而易见的。
考虑下面的例子:
看起来似乎=
在 a = 42
是表达式的副作用运算符。但是如果我们检查a = 42
语句的结果值,它就是刚刚分配的值(42
),因此将相同值赋值给a
本质上是一个副作用。
提示: 关于副作用的相同推理适用于复合赋值运算符,如+ =
, - =
等。例如, a = b += 2
首先被作为b += 2
处理(这个就是b = b + 2
),然后将结果通过=
赋值给a
。
这种赋值表达式(或语句)赋值的行为主要用于链式赋值,例如:
这里,c = 42
被评估为42
(具有将42
分配给c
的副作用),然后b = 42
被评估为42
(具有将42
分配给b
的副作用),并且最终评估a = 42
(具有将42
分配给a
的副作用)。
警告: 开发人员使用链式赋值所犯的常见错误就像var a = b = 42
。虽然这看起来像是同样的事情,但事实并非如此。如果在没有单独的var b
(作用域内的某个地方)正式声明b
的情况下发生该声明,那么var a = b = 42
将不会直接声明b
。根据strict
模式,可能会抛出错误或创建全局的变量(请参阅本系列的“Scope & Closures”标题)。
另一种需要考虑的方案:
这是有效的,许多开发人员比较喜欢这样做。但是使用我们利用赋值副作用的惯用语,我们可以通过将两个if
语句组合成一个来简化:
注意: ( .. )
包围matches = str.match..
是必要的。原因是运算符优先级,我们将在本章后面的“Operator Precedence”一节中介绍。
我更喜欢这种较短的风格,因为我认为这两个条件实际上是相关的,连在一起比分开要更清楚。但是和JS中的大多数风格选择一样,认为哪一个 更好 纯粹是个人意见。
Contextual Rules
JavaScript语法规则中有很多地方,相同的语法意味着不同的东西,这取决于它在何处/如何使用。这种事情,孤立地使用,会引起相当多的困惑。
我们不会在这里详尽地列出所有这些案例,而只是列举一些常见案例。
{ .. } Curly Braces
有两个主要的地方(还有更多来自JS的演变!),你的代码中会出现一对{..}花括号。我们来看看他们的每一个。
Object Literals
首先,作为object
字面量:
我们怎么知道这是一个object
文字?因为{..}
是一个被分配给a
的值。
注意: a
引用称为“l值”(又称左边值),因为它是赋值的目标。{..}
对是一个“r值”(又名右边值),因为它仅用作值(在这种情况下作为赋值的来源)。
Labels
如果我们删除上述代码段的var a =
部分会发生什么?
许多开发人员认为{..}
只是一个独立的object
字面量,不会被分配到任何地方。但它实际上完全不同。
这里,{ .. }
只是一个常规的代码块。它在JavaScript中并不是非常惯用(在其他语言中更是如此!)拥有这样的独立{..}
块,但它是完全有效的JS语法。结合let
块级作用域声明时,它可能特别有用(请参阅本系列中的Scope&Closures标题)。
这里的{..}
代码块在功能上几乎与附加到某个语句的代码块相同,如for
/ while
循环,if
条件等。
但是,如果它是一个正常的代码块,那么奇怪的是foo: bar()
语法是什么,以及它是如何合法的?
这是因为JavaScript中有一个鲜为人知(并且坦率地说,不鼓励使用)的功能称为“带标签的语句”。foo
是语句bar()
的标签(已省略其尾随;
-- 请参阅本章后面的“Automatic Semicolons”)。但标签声明的重点是什么?
如果JavaScript有一个goto
语句,理论上你可以说goto foo
并在代码中跳转到该位置。goto
通常被认为是糟糕的编码习惯,因为它们使代码更难理解(也就是“意大利面条代码”),所以javascript没有一个通用的goto
是一件非常好的事情。
但是,JS确实支持一种有限的特殊形式的goto
:带标签的跳转。continue
和break
语句都可以选择接受指定的标签,在这种情况下,程序流“跳转”类似于goto
。考虑下面的代码:
注意: continue foo
并不意味着“转到'foo'标记的位置继续”,而是“继续使用下一次迭代标记'foo'的循环。”所以,它并不是一个任意的goto
。
如你所见,我们跳过了奇数倍3 1
的迭代,但标签循环跳转也跳过迭代11
和2 2
。
也许一个稍微有用的标记跳转形式是在内部循环内使用break __
,你想要突破外部循环。
也许更有用的标记跳转形式是从一个内部循环中break__
,在这个内部循环中,你希望从外部循环中断。如果没有标记的break
,这种相同的逻辑有时会很难编写:
注意: break foo
并不意味着“转向'foo'标记的位置继续,”而是“跳出标记为'foo'的循环/块并继续它后面的部分。”不完全是传统意义上的goto
,啊哈?
上述的非标记break
选项可能需要涉及一个或多个函数,共享范围变量访问等。它很可能比标记break
更令人困惑,因此使用带标签的break
可能是更好的选择。
标签可以应用于非循环块,但只有break
可以引用这样的非循环标签。可以在任何标记的块中执行标记的break___
,但是你不能continue ___
非循环标签,也不能从一个块中进行非标记的break
。
标记的循环/块是非常罕见的,并且经常不被赞成。如果可能的话,最好避免使用它们;例如,使用函数调用而不是循环跳转。但也许有些情况可能会有用。如果你打算使用带标签的跳转,请务必使用大量注释记录你正在做的事情!
人们普遍认为JSON是JS的一个子集,所以一个JSON字符串(如{"a":42}
- 注意到JSON要求的属性名称周围的引号!)被认为是一个有效的JavaScript程序。不对! 尝试将{"a":42}
放入JS控制台,你将收到错误消息。
那是因为语句标签周围没有引号,所以"a"
不是有效的标签,因此:
不能在它之后。
因此,JSON确实是JS语法的一个子集,但JSON本身并不是有效的JS语法。
沿着这些方向的一个非常常见的误解是,如果你要将JS文件加载到只有JSON内容的<script src = ..>
标记中(例如来自API调用),数据将被读取为有效的JavaScript,但程序无法访问。JSON-P(在函数调用中包装JSON数据的做法,如foo({"a": 42})
)通常被称为通过将值发送到程序的一个函数来解决这种不可访问性。
不对! 完全有效的JSON值{"a": 42}
本身实际上会抛出一个JS错误,因为它被解释为带有无效标签的语句块。但是foo({"a":42})
是有效的js,因为在这里,{"a":42}
是传递给foo(..)
的对象字面量值。所以,正确地说,JSON-P使JSON成为有效的JS语法!
Blocks
另一个常被引用的JS问题(与强制相关 - 见第4章)是:
这似乎意味着+
运算符根据第一个操作数是[]
还是{}
给出不同的结果。但这实际上与它无关!
在第一行,{}
出现在+
运算符的表达式中,因此被解释为实际值(空object
)。第4章解释说[]
被强制为""
,因此{}
也被强制转换为字符串值:"[object Object]"
。
但是在第二行,{}
被解释为一个独立的{}
空块(它什么都不做)。块不需要用分号来终止它们,所以这里缺少一个不是问题。最后,+ []
是一个表达式,它 明确地强制 (见第4章)[]
为一个number
,即0
值。
Object Destructuring
从ES6开始,您将看到另一个显示{..}
对的地方是“解构分配”(有关更多信息,请参阅本系列的ES6和Beyond标题),特别是object
解构。考虑:
你可能会说,var { a , b } = ..
是ES6解构分配的一种形式,这大致相当于:
注意: {a,b}
实际上是{a:a,b:b}
的ES6解构缩写,因此两者都有效,但预计较短的{a,b}
将成为首选形式。
使用{..}
对的对象解构也可以用于命名函数参数,对于同一种隐式对象属性赋值,它是语法糖:
因此,我们使用{..}
对的上下文完全决定了它们的含义,这说明了句法(syntax)和语法(grammer)之间的区别。理解这些细微差别以避免JS引擎的意外解释非常重要。
else if And Optional Blocks
人们普遍误解javascript有一个else if
子句,因为你可以这样做:
但是这里有一个隐藏的JS语法特征:这里没有else if
。但if
和else
语句如果只包含一个语句,则允许省略其附加块周围的{}
。你之前见过很多次,就是:
许多JS风格指南都会坚持让你总是在一个语句块周围使用{}
,例如:
但是,完全相同的语法规则适用于else
子句,因此,可能你一直编码的else if
形式实际 上被解析为:
if (b) { .. } else { .. }
是跟在else
后面的单个语句,所以你可以将周围的{}
放入或不放入。换句话说,当你使用else if
时,你在技术上打破了常见的样式指南规则,只是使用单个if
语句定义你的else
。
当然,else if
惯用法非常普遍,导致缩进程度较低,因此具有吸引力。无论你采用哪种方式,只需在你自己的样式指南/规则中明确指出,并且不要假定像else if
这样的东西是直接语法规则。
Operator Precedence
正如我们在第4章中所述,JavaScript的版本关于&&
和||
有趣的是,他们选择并返回其中一个操作数,而不仅仅是得到true
或false
。如果只有两个操作数和一个操作符,这很容易解释。
但是当涉及两个操作符和三个操作数时呢?
要理解这些表达式导致的结果,我们需要了解当表达式中存在多个表达式时,运算符处理方式的规则。
这些规则称为“运算符优先级”。
我敢打赌,大多数读者都认为他们对运算符的优先权有很好的把握。但正如我们在本系列丛书中所涵盖的所有其他内容一样,我们将深入了解这种理解,看看它究竟是多么坚固,并希望在此过程中学到一些新的东西。
回想一下上面的例子:
但是如果我们移除()
会发生什么?
等等!为什么这会改变分配给b
的值?
因为,
运算符的优先级低于=
运算符。因此,b = a++, a
被解释为(b = a++), a
。因为(正如我们前面所解释的)a++
有后副作用,所以给b
赋的值是在++
改变a
之前的值42
。
这只是需要理解运算符优先级的简单问题。如果你打算使用,
作为一个语句系列运算符,重要的是要知道它实际上具有最低优先级。其他所有运算符的绑定都将比,
更紧密。
现在,回忆一下之前的这个例子:
我们说赋值周围的()
是必需的,但为什么呢?因为&&
的优先级高于=
,所以没有()
强制绑定,表达式将被视为(str && matches) = str.match ...
。但这将是一个错误,因为(str && matches)
的结果不是一个变量,而是一个值(在这种情况下是undefined
的),因此它不能是在=
赋值的左侧!
好的,所以你可能认为你已经将这个运算符优先级搞懂了。
让我们继续讨论一个更复杂的例子(我们将在本章的下几部分中介绍),以真正测试你的理解:
好吧,邪恶,我承认了。没有人会像这样写一串表达式,对吧?可能不是,但我们将用它来检查将多个操作符链接在一起的各种问题,这是一项非常常见的任务。
上面的结果是42
。但是这并没有什么意思,除非我们自己能找到这个答案,而不仅仅是将其插入到JS程序中让JavaScript对其进行排序。
让我们深入研究。
第一个问题 - 你甚至可能没想到 - 是,第一部分(a && b || c
)是否表现得像(a && b) || c
或a && (b || c)
?你知道吗?你能说服自己他们实际上是不同的吗?
所以,有证据证明他们是不同的。但是,false && true || true
有什么样的表现?答案:
所以我们有答案。首先评估&&
运算符,然后评估||
运算符。
但这只是因为从左到右的处理?让我们颠倒运算符的顺序:
现在我们已经证明&&
先被评估,然后是||
,在这种情况下,这实际上与通常预期的从左到右的处理相反。
那是什么导致了这种行为? 运算符优先级。
每种语言都定义了自己的运算符优先级列表。令人沮丧的是,JS开发人员阅读了JS列表的是多么罕见。
如果你知道的话,上面的例子就不会让你困惑,因为你已经知道&&
比||
更优先。但我敢打赌,一定有相当数量的读者必须稍微考虑一下。
注意: 不幸的是,JS规范并没有真正将其操作符优先列表放在一个方便的单一位置。你必须解析并理解所有的语法规则。因此,我们将尝试以更方便的格式布置更常见和有用的位。有关运算符优先级的完整列表,请参阅MDN站点上的“Operator Precedence”(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence)。
Short Circuited
在第4章中,我们在旁注中提到了像&&
和||
这样的运算符的“短路”特性。现在让我们再详细介绍一下。
对于&&
和||
如果左侧操作数足以确定操作的结果,则 不会评估 右侧操作数。因此,名称“短路”(如果可能的话,它将采用早期的捷径)。
例如,使用a&& b
,如果a
是falsy的,则不会评估b
,因为&&
操作数的结果已经确定,所以没有必要费心去检查b
。同样,使用a|| b
,如果a
是truthy的,操作数的结果已经确定,所以没有理由检查b
。
这种短路非常有用,通常用于:
opts && opts.cool
的opts
部分充当了一种防护,因为如果opts
未设置(或不是object
),则表达式opts.cool
会抛出错误。opts
测试失败加上短路意味着opts.cool
甚至不会被评估,因此没有错误!
同样,你可以使用||
短路:
在这里,我们首先检查opts.cache
,如果它存在,我们不调用primeCache()
函数,从而避免可能不必要的工作。
Tighter Binding
但是让我们把注意力转回到早期的复杂语句示例和所有链式运算符,特别是? :
三元运算符部分。? :
运算符具有比&&
和||
更多或更少的优先级?
这更像是这样的:
或是这样的?
答案是第二个。但是为什么?
因为&&
比||
更优先,并且||
比? :
更优先。
所以,(a && b || c)
首先在? :
参与之前被评估了。另一种常见的解释方式是&&
和||
“绑定更紧密”,而不是? :
。如果相反,那么c ? c ...
会更紧密的绑定,它会表现为(作为第一选择),如a && b || ( c ? c ..)
。
Associativity
所以,&&
和||
运算符首先绑定,然后是? :
运算符。但是同样优先的多个运算符呢?他们总是从左到右或从右到左处理?
通常,运算符是左关联的或右关联的,指的是 分组是从左进行还是从右进行。
值得注意的是,关联性与从左到右或从右到左处理不同。
但是,为什么处理是从左到右还是从右到左都很重要?因为表达式可能有副作用,例如函数调用:
这里,foo()
首先被评估,然后可能是bar()
,这取决于foo()
表达式的结果。如果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
(又名从左到右)。
所以&&
和||
是左联想的并不重要,除了准确讨论他们的定义。
但情况并非总是如此。根据左关联性和右关联性,一些运算符的行为会有很大不同。
考虑一下? :
(“三元”或“条件”)运算符:
? :
是右关联的,所以哪个分组表示它将如何处理?
a ? b : (c ? d : e)
(a ? b : c) ? d : e
答案是a ? b : (c ? d : e)
。与上的&&
和||
不同,这里的右关联性实际上很重要,因为对于某些(但不是全部!)值的组合来说(a ? b : c) ? d : e
的行为将会不同。
一个这样的例子:
即使最终结果相同,更多细微差别潜伏在其他值组合中。考虑:
从那种情况来看,相同的最终结果意味着分组没有实际意义。然而:
那么,我们已经清楚地证明了这一点? :
是右关联的,并且关于运算符与自身链接的行为方式,它实际上很重要。
右关联性(分组)的另一个例子是=
运算符。回想一下本章前面的链式赋值示例:
我们之前断言a = b = c = 42
是首先评估c = 42
赋值,然后b = ..
,最后a = ...
,为什么?由于右关联性,它实际上像这样对待声明:a = (b = (c = 42))
。
还记得本章前面的运行复杂赋值表达式的示例吗?
有了我们的优先级和关联性知识,我们现在应该能够将代码分解为分组行为,如下所示:
或者,如果将其缩进更容易理解:
现在让我们来解决他:
(a && b)
就是"foo"
"foo" || c
就是"foo"
对于第一个
?
来说,"foo"
是truthy的(c || b)
是"foo"
对于第二个
?
,"foo"
是truthy的a
是42
就是这样的,我们完成了!答案就是42
,正如我们之前看到的那样。那真的不是很难,是吗?
Disambiguation
现在你应该更好地掌握运算符优先级(和关联性),并且更容易理解具有多个链式运算符的代码将如何表现。
但是一个重要的问题仍然存在:我们是否都应该编写理解代码的代码,并且完全依赖于运算符优先级/关联性的所有规则?我们是否应该在需要强制执行不同的处理绑定/顺序时使用()
手动分组?
或者,另一方面,我们是否应该认识到即使这些规则实际上是可以学习的,但仍有足够的理由来忽略自动优先/关联性?如果是这样,我们是否应该总是使用()
手动分组并消除对这些自动行为的依赖?
这场辩论具有高度的主观性,与第4章关于 隐性 强制的辩论非常对称。大多数开发人员对这两种辩论都有相同的看法:要么他们接受行为并且使用,要么他们放弃这两种行为并坚持手工/显性的惯用法。
当然,我不能像第四章那样,为读者明确地回答这个问题。。但是我向你展示了优点和缺点,并希望更深刻地理解能让你做出明智的决定,而不是跟风别人的决定。
在我看来,这里有一个重要的立场。我们应该将运算符优先级/关联性和()
手动分组混合到我们的程序中 - 我在第4章中以相同的方式对 隐式 强制的健康/安全使用进行了论证,但肯定不会无限制地认可它。
例如, if(a && b && c)..
对我来说完全没有问题,我就不会为了明确的强调关联性而去if((a && b) && c)..
,因为我认为这是过度的冗长。
另一方面,如果我需要链接两个? :
条件运算符在一起,我当然会使用()
手动分组来清楚地说明我的逻辑是什么。
因此,我的建议类似于第4章:使用运算符优先级/关联性可以使代码更短更清晰,但在有助于创建清晰度和减少混淆的地方使用()
手动分组。
Automatic Semicolons
ASI(自动分号插入)是指javascript假定;
在JS程序的某些地方,即使你没有将其放在那里,他就会进行ASI。
为什么会这样做?因为如果你省略一个必要的;
你的程序会失败。这是不可原谅的。ASI允许JS容忍某些地方;
通常认为没有必要。
值得注意的是,ASI只会在换行符存在时生效。分号不会插入到行的中间。
基本上,如果JS解析器解析一个可能发生解析器错误的行(应该是缺失的;
),并且它可以合理地插入;
,那么它会这样做。插入什么是合理的?只有当某些语句的结尾和该行的换行符之间只有空白和/或注释时。
考虑下面的代码:
JS应该将下一行的c
作为var
语句的一部分来处理吗?如果,
在b
和c
之间的任何地方(甚至是另一条线),它肯定会是的。但由于没有一个,
,JS反而假定有一个隐含的;
(在换行符处)在b
后面。因此c;
被保留为独立的表达式语句。
同样的:
这仍然是一个没有错误的有效程序,因为表达式语句也接受ASI。
在某些地方ASI很有帮助,比如:
语法需要一个;
在do..while
循环之后,但不是在while
或for
循环之后。但大多数开发人员都不记得了!因此,ASI很有帮助地介入并插入一个。
正如我们在本章前面所述,语句块不需要;
终止,所以不需要ASI:
ASI介入的另一个主要案例是break
、continue
、return
和(es6)yield
关键字:
return
语句不会作用于a * = 2
表达式,因为ASI假设为;
终止return
声明。当然,return
语句很容易跨多行,只是在return
除了换行符之外没有任何其他内容。
相同的推理适用于break
,continue
和yield
。
Error Correction
JS社区中最激烈的 宗教战争 之一(除了制表符与空格之外)是否严重/完全依赖ASI。
大多数(但不是全部)分号是可选的,但是两个;
在for(..)..
循环头中是必需的。
在本次辩论的专业方面,许多开发人员认为ASI是一种有用的机制,允许他们通过省略除严格要求的所有;
(非常少)来编写更简洁(更“漂亮”)的代码。经常声称ASI使得很多;
是可选的,所以一个 不带它们 而正确编写的程序与 带着它们 而正确编写的程序没什么区别。
在辩论的另一方面,许多其他开发人员将断言,有太多的地方可能是偶然的陷阱,特别是对于那些无意识的新的,经验不足的开发人员来说,这些地方的;
意外插入会改变其含义。类似地,一些开发人员会争辩说,如果他们省略了分号,这完全是一个错误,他们希望他们的工具(linters等)能够在JS引擎纠正错误之前抓住它。
让我分享一下我的观点。严格阅读规范意味着ASI是一个“纠错”程序。你可能会问什么样的错误?具体来说,解析器错误。 换句话说,为了让解析器失败更少,ASI让它更宽容。
但宽容什么?在我看来,解析器错误 发生的唯一方法是给出一个不正确/错误的程序来解析。因此,虽然ASI正在严格的纠正解析器错误,但是它得到此类错误的唯一方法是 首先出现程序编写错误 - 省略语法规则要求的分号。
所以,更直言不讳地说,当我听到有人声称他们想要省略“可选的分号”时,我的大脑会将这种说法翻译成“我想编写破坏解析的程序,但仍然可以使用它”。
我发现这是一个可笑的立场,以及节省几次键盘敲击和让更“漂亮的代码”充其量只是软弱的论点。
此外,我不同意这与空格与制表符的争论是一样的——这纯粹是表面化的——但我认为这是一个基本问题,即编写符合语法要求的代码,而不依赖于语法异常的代码,而这些代码几乎不会滑过。
另一种看待它的方式是,依赖ASI本质上是把换行看作是重要的“空白”。像Python这样的其他语言具有真正重要的空白。但是就今天的JavaScript来说,认为它拥有有意义的换行真的合适吗?
我的看法:使用分号,只要你知道它们是“必需的”,并将你对ASI的假设限制在最低限度。
但是,不要只听我的话。早在2012年,JavaScript的创建者Brendan Eich(http://brendaneich.com/2012/04/the-infernal-semicolon/)表示如下:
这个故事的寓意:ASI(正式来说)是一种语法错误纠正程序。如果你开始编码就认为他好像是一个普遍的重要换行规则,那你就会遇到麻烦。 .. 如果回到1995年五月的那十天,我希望我使换行在JS中更有意义。注意不要使用ASI,就好像它给了JS重要的换行符。
Errors
与运行时期间发生的所有其他错误相比,JavaScript不仅具有不同的错误子类型(TypeError
,ReferenceError
,SyntaxError
等),而且语法定义了在编译时要强制执行的某些错误。
特别是,长期以来一直存在许多应该被捕获并报告为“早期错误”的特定条件(在编译期间)。任何直接语法错误都是早期错误(例如,a = ,
),但语法也定义了语法上有效但不允许的东西。
由于你的代码尚未开始执行,因此try..catch
无法捕获这些错误。他们将无法解析/编译你的程序。
提示: 规范中没有关于浏览器(和开发者工具)应该如何报告错误的具体要求。因此,你可能会在以下错误示例中看到浏览器之间的差异,报告的错误的特定子类型或包含的错误消息文本的内容。
一个简单的例子是在正则表达式字面量中使用语法。这里的JS语法没有任何问题,但无效的正则表达式会抛出一个早期错误:
赋值的目标必须是标识符(或生成一个或多个标识符的ES6解构表达式),因此该位置中的42
之类的值是非法的,可以立即报告:
ES5的strict
模式定义了更多的早期错误。例如,在strict
模式中,函数参数名称不能重复:
另一个strict
模式早期错误是具有多个同名属性的对象字面量:
注意: 从语义上讲,这样的错误在技术上不是句法错误,而是更多的语法错误 - 上面的片段在语法上是有效的。但由于没有GrammarError
类型,因此某些浏览器使用SyntaxError
。
Using Variables Too Early
ES6定义了一个名为TDZ("Temporal Dead Zone")的新概念(坦白地说容易混淆)。
TDZ指的是代码中还不能进行变量引用的地方,因为它还没有达到所需的初始化。
最明显的例子是使用ES6 let
块级作用域:
赋值a=2
正在(它确实是在块范围{}
中)在它被let a
声明初始化之前访问a
变量,所以它在tdz
中表示a
,并抛出一个错误。
有趣的是,虽然typeof
有一个例外,对于未声明的变量是安全的(见第1章),但没有为TDZ的引用做出这样的安全例外:
Function Arguments
使用ES6默认参数值是TDZ违规的另一个示例(请参阅本系列的ES6和Beyond标题):
赋值中的b
引用将发生在参数b
的TDZ中(不是请求外部b
的引用),因此它将引发错误。但是,赋值中的a
很好,因为到那时它已经经过参数a
的TDZ。
使用ES6的默认参数值时,如果省略参数,或者在其位置传递undefined
的值,则会将默认值应用于参数:
注意: null
被强制转换为a + 1
表达式中的0
值。有关详细信息,请参阅第4章。
从ES6默认参数值的角度来看,省略参数和传递undefined
的值之间没有区别。但是,有一种方法可以检测某些情况下的差异:
即使默认参数值应用于a
和b
参数,如果在这些插槽中没有传递参数,arguments
数组也不会有数据。
相反,如果显式传递undefined
参数,则该参数的arguments
数组中将存在一个条目,但它将是undefined
的, 与应用于同一插槽的命名参数的默认值不同。
虽然ES6默认参数值可以在arguments
数组槽和相应的命名参数变量之间创建差异,但在ES5中,同样的不连续性也可能以复杂的方式出现:
如果传递参数,则arguments
槽和命名参数将链接到始终具有相同的值。如果省略参数,则不会发生此类链接。
但在strict
模式下,无论如何都不存在联系:
几乎可以肯定,依赖任何这样的链接都是一个坏主意,事实上,链接本身是一个泄漏的抽象,它公开了引擎的底层实现细节,而不是一个正确设计的特性。
使用arguments
数组已被弃用(特别是支持ES6 ...
rest参数 - 请参阅本系列的ES6和Beyond标题),但这并不意味着它一切都很糟糕。
在ES6之前,arguments
是获取所有传递参数的数组以传递给其他函数的唯一方法,这证明是非常有用的。你还可以将命名参数与arguments
数组混合并且是安全的,只要你遵循一个简单的规则:永远不要同时引用命名参数及其对应的arguments
槽。
如果你避免这种不良做法,你将永远不会暴露泄漏的联系行为。
try..finally
try..finally
你可能熟悉try..catch
块是如何工作的。但是你有没有停下来考虑可以与之配对的finally
子句?事实上,你是否意识到try
只需要catch
或finally
,但两者都可以在需要时出现。
finally
子句中的代码总是运行(无论如何都会),它总是在try
(和catch
,如果存在)完成之后运行, 并且在任何其他代码运行之前。从某种意义上说,你可以将finally
子句中的代码视为在回调函数中,无论块的其余部分如何运行,都会始终调用该函数。
那么如果在try
子句中有一个return
语句会发生什么?它显然会返回一个值,对吗?但接收该值的调用代码是在finally
之前还是之后运行?
return 42
立即运行,他设置了foo()
调用的完成值。此操作完成try
子句,接下来finally
子句立即运行。只有这样才能完成foo()
函数,以便返回其完成值以供console.log(..)
语句使用。
在try
里throw
具有相同的行为:
现在,如果在finally
子句中抛出(意外或有意)异常,它将覆盖该函数的主要完成。如果try
块中的先前return
已为该函数设置了完成值,则该值将被舍弃。
其他非线性控制语句(如continue
和break
)表现出类似的return
和throw
行为,这一点不足为奇:
console.log(i)
语句在循环迭代结束时运行,这是由continue
语句引起的。但是,它仍然在i++
迭代更新语句之前运行,这就是为什么打印的值是0..9
而不是1..10
。
注意: ES6在生成器中添加了一个yield
语句(参见本系列的Async和Performance标题),它在某些方面可以看作是一个中间return
语句。但是,与return
不同,在生成器重新开始之前,yield
不会完成,这意味着try { .. yield .. }
尚未完成。因此,附加的finally
子句不会像return
一样在yield
之后运行。
finally
中的return
有着覆盖前一个try
或catch
子句中的return
的特殊能力,但是仅在return
被明确调用的情况下:
通常,在函数中忽略return
与return;
相同或者甚至与return undefined;
相同,但是在finally
块中,省略return
并不像一个重写的return undefined;
,它只是让之前的return
生效。
事实上,如果我们finally
将结合标记的break
(在本章前面讨论过),我们可以真正提高疯狂度:
但是......不要这样做。我是认真的。使用finally
+标记的break
来有效地取消return
,是你正在尽最大努力创建最容易混淆的代码。我敢打赌,任何注释都无法救赎这段代码。
switch
switch
让我们简要探讨一下switch
语句,这是一种if..else if..else ..
语句链的一种语法简写。
你可以看到,它计算一次a
,然后将结果值与每个case
表达式匹配(这里只是简单的值表达式)。如果找到匹配,则执行将在该匹配的case
下开始,并且将一直持续到遇到break
或者直到找到switch
块的结尾。
这可能不会让你感到惊讶,但是有一些关于switch
的怪异行为你以前可能没有注意到。
首先,表达式a
和每个case
表达式之间的匹配与===
算法相同(参见第4章)。通常,switch
在case
语句中与绝对值一起使用,如上所示,因此严格匹配是合适的。
然而,你可能希望强制相等(就是==
,参考第四章),为了做到强制相等,你可能需要对switch
语句进行一点“黑客”处理:
这可以工作,是因为case
子句可以有任何表达式(不仅仅是简单的值),这意味着他将严格的匹配表达式的结果和测试表达式(true
)。因为a==42
在这里结果为true
,所以匹配成功。
尽管==
,switch
匹配本身仍然严格,在这里介于true
和true
之间。如果case
表达式结果是truthy的,但不是严格的true
(看第四章),这不会工作。例如,如果在表达式中使用“逻辑运算符”,例如||
或&&
,则可能会坑到你:
因为(a || b == 10)
的结果是"hello world"
不是true
,严格匹配失败。在这个案例里,修复方法是强制表达式显式地为true
或false
,例如case !!(a || b==10):
(见第4章)。
最后,default
子句是可选的,不一定要在末尾出现(尽管这是强约定)。即使在default
子句里,同样的规则也适用于遇到break
或不遇到break
:
注意: 正如前面关于带标签的break
的讨论,case
子句中的break
也可以标记。
此代码段处理的方式是首先通过所有case
子句匹配,找不到匹配,然后返回到default
子句并开始执行。由于那里没有break
,它会在已经跳过的case3
块中继续执行,然后一旦它到达该break
就停止。
虽然这种循环逻辑在JavaScript中显然是可能的,但它几乎不可能形成合理或可理解的代码。如果你发现自己想要创建这样的循环逻辑流,那就请你得好好的想想了,如果你真的这样做,请确保包含大量的代码注释来解释你的目的!
Review
JavaScript语法有很多细微的差别,作为开发人员,我们应该花费更多的时间来关注这些细微的差别。一点点的努力可以巩固你对语言的更深层次的了解。
声明和表达式在英语中都有类似物 - 声明就像句子,表达式就像短语。表达式可以是纯/自包含的,也可以有副作用。
JavaScript语法在纯句法之上层叠语义使用规则(也称为上下文)。例如,程序中各个位置使用的{}
对可以表示语句块,对象字面量,(ES6)解构赋值或(ES6)命名函数参数。
JavaScript运算符都有明确定义的优先级规则(首先绑定在其他之前)和关联性(多个运算符表达式如何隐式分组)。一旦你学会了这些规则,由你来决定优先级/关联性是否过于隐含于自己的用处,或者它们是否有助于编写更短,更清晰的代码。
ASI(自动分号插入)是JS引擎中内置的解析器错误更正机制,允许它在某些情况下插入假定的;
在需要插入的地方,省略了插入,并且插入修复了解析器错误。争论这种行为是否意味着更多;
是可选的(可以/应该省略,为了更干净的代码)或者是否意味着省略它们会导致JS引擎只为你清理错误。
JavaScript有几种类型的错误,但不太知道它有两个错误分类:“早期”(编译器抛出,不可捕获)和“运行时”(可以try..catch
)。所有句法错误显然是在程序运行之前就停止程序的早期错误,但也有其他错误。
函数参数与其正式声明的命名参数有一个有趣的关系。具体来说,如果你不小心,arguments
数组有许多漏洞抽象行为。如果可以,请避免使用arguments
,但如果必须使用它,则一定要避免在arguments
中使用位置槽的同时为同一参数使用命名参数。
finally
子句附加到了try
(或者try..catch
),在执行处理顺序方面提供了一些非常有趣的怪癖。其中一些怪癖可能会有所帮助,但是可能会造成很多混乱,特别是如果与标记的块结合使用。与往常一样,使用finally
以使代码更好更清晰,而不是聪明过头或令人困惑。
switch
为if..else if ..
声明提供了一些不错的简写,但要注意关于其行为的许多常见的简化假设。如果你不小心的话,有几个怪癖可能会坑到你,但switch
也有一些巧妙的隐藏技巧!
Last updated